feat(M9): active product backlog — persistent active PB, NavBar splits, sprint card styling (#10)
* feat(tooling): extend backlog parser to support PBI-x milestone headers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(backlog): mark ST-801–806 as done Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): sorteer PBI's en stories op prio/code/datum, onthoud keuze in localStorage; vergroot sprint-afronden dialoog Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-901): add user.active_product_id with FK to Product - Nullable relation User → Product with onDelete: SetNull - Index on active_product_id for join performance - Migration: 20260427165329_add_user_active_product_id - Install @tanstack/react-table (was missing from node_modules) - Fix PRIORITY_COLORS ref removed in earlier refactor - Note: User schema change affects vendor/scrum4me-mcp submodule — run prisma generate + tsc --noEmit there after merge Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: restore priority color on PBI filter pill Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-902): add setActiveProduct + clearActiveProduct server actions - actions/active-product.ts: setActiveProductAction validates access via productAccessFilter, rejects archived products and demo users - archiveProductAction: clears active_product_id for all affected users in transaction - removeProductMemberAction: clears active_product_id for removed member - leaveProductAction: clears active_product_id for leaving user Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-903): load active product in layout, replace cookie with DB lookup in solo - layout.tsx: fetch active_product_id, resolve product, clear stale ref server-side - NavBar: add activeProduct prop (rendering changes in ST-904) - solo/page.tsx: redirect via user.active_product_id instead of lastProductId cookie - proxy.ts: remove lastProductId cookie logic - lib/cookies.ts: deleted (no longer used) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-904): split NavBar into 5 tabs with disabled-states and product-switcher dropdown Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-905): add Activeer button per product row in dashboard and product header * feat(ST-906): redirect to dashboard with toast when active product becomes inaccessible * feat(ST-907): tests for active-product actions and functional spec update for M9 * docs(M9): add implementation plan document and link from backlog * feat: active PB indicator, Maak actief button and new product link in settings * feat: apply priority-color card style to sprint story rows * fix: move add-to-sprint click from entire card to + Toevoegen button * feat: apply priority-color card style to sprint task rows * fix(sprint-backlog): prevent text selection on PBI collapse button * chore: bump version to 0.4.0 (M9 active product backlog) * fix(landing): align logged-in nav left to match app NavBar --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c1c219639a
commit
88dca4102c
28 changed files with 1184 additions and 481 deletions
|
|
@ -24,6 +24,7 @@ Lees het relevante document voordat je aan een feature begint. Nooit gokken over
|
||||||
| `docs/API.md` | REST-API contract voor Claude Code — endpoints, status-enums, foutcodes, voorbeeld-curls |
|
| `docs/API.md` | REST-API contract voor Claude Code — endpoints, status-enums, foutcodes, voorbeeld-curls |
|
||||||
| `docs/scrum4me-styling.md` | **Lees dit voor elk component** — MD3-kleuren, shadcn patronen |
|
| `docs/scrum4me-styling.md` | **Lees dit voor elk component** — MD3-kleuren, shadcn patronen |
|
||||||
| `docs/agent-instruction-audit.md` | Waarom de agent-instructies zijn aangescherpt; checklist voor toekomstige wijzigingen |
|
| `docs/agent-instruction-audit.md` | Waarom de agent-instructies zijn aangescherpt; checklist voor toekomstige wijzigingen |
|
||||||
|
| `docs/plans/<milestone-key>-*.md` | Implementatieplan per milestone — Bestanden, Stappen, Aandachtspunten, Verificatie. Lees vóór je aan een ST begint. Milestone-key matcht backlog-header (`M9`, `M3.5`, `PBI-9`, …). |
|
||||||
| [`madhura68/scrum4me-mcp`](https://github.com/madhura68/scrum4me-mcp) | MCP-server repo: native tools voor Claude Code, schema-sync via git submodule |
|
| [`madhura68/scrum4me-mcp`](https://github.com/madhura68/scrum4me-mcp) | MCP-server repo: native tools voor Claude Code, schema-sync via git submodule |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
101
__tests__/actions/active-product.test.ts
Normal file
101
__tests__/actions/active-product.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
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({}),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
product: { findFirst: vi.fn() },
|
||||||
|
user: { update: vi.fn() },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { getIronSession } from 'iron-session'
|
||||||
|
import { setActiveProductAction, clearActiveProductAction } from '@/actions/active-product'
|
||||||
|
|
||||||
|
const mockPrisma = prisma as unknown as {
|
||||||
|
product: { findFirst: ReturnType<typeof vi.fn> }
|
||||||
|
user: { update: ReturnType<typeof vi.fn> }
|
||||||
|
}
|
||||||
|
const mockGetIronSession = getIronSession as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
const PRODUCT = { id: 'product-1', name: 'Test Product', archived: false }
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValue(PRODUCT)
|
||||||
|
mockPrisma.user.update.mockResolvedValue({})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setActiveProductAction', () => {
|
||||||
|
it('sets active_product_id for authenticated user', async () => {
|
||||||
|
const result = await setActiveProductAction('product-1')
|
||||||
|
expect(result).toEqual({ success: true, productId: 'product-1' })
|
||||||
|
expect(mockPrisma.user.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'user-1' },
|
||||||
|
data: { active_product_id: 'product-1' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error when not logged in', async () => {
|
||||||
|
mockGetIronSession.mockResolvedValue({ userId: undefined, isDemo: false })
|
||||||
|
const result = await setActiveProductAction('product-1')
|
||||||
|
expect(result).toEqual({ error: 'Niet ingelogd' })
|
||||||
|
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error for demo user', async () => {
|
||||||
|
mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: true })
|
||||||
|
const result = await setActiveProductAction('product-1')
|
||||||
|
expect(result).toEqual({ error: 'Niet beschikbaar in demo-modus' })
|
||||||
|
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error when product is archived or inaccessible', async () => {
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValue(null)
|
||||||
|
const result = await setActiveProductAction('product-1')
|
||||||
|
expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' })
|
||||||
|
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error for empty product id', async () => {
|
||||||
|
const result = await setActiveProductAction('')
|
||||||
|
expect(result).toEqual({ error: 'Ongeldig product-id' })
|
||||||
|
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clearActiveProductAction', () => {
|
||||||
|
it('clears active_product_id for authenticated user', async () => {
|
||||||
|
const result = await clearActiveProductAction()
|
||||||
|
expect(result).toEqual({ success: true })
|
||||||
|
expect(mockPrisma.user.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'user-1' },
|
||||||
|
data: { active_product_id: null },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error when not logged in', async () => {
|
||||||
|
mockGetIronSession.mockResolvedValue({ userId: undefined, isDemo: false })
|
||||||
|
const result = await clearActiveProductAction()
|
||||||
|
expect(result).toEqual({ error: 'Niet ingelogd' })
|
||||||
|
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error for demo user', async () => {
|
||||||
|
mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: true })
|
||||||
|
const result = await clearActiveProductAction()
|
||||||
|
expect(result).toEqual({ error: 'Niet beschikbaar in demo-modus' })
|
||||||
|
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
51
actions/active-product.ts
Normal file
51
actions/active-product.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { getIronSession } from 'iron-session'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { SessionData, sessionOptions } from '@/lib/session'
|
||||||
|
import { productAccessFilter } from '@/lib/product-access'
|
||||||
|
|
||||||
|
async function getSession() {
|
||||||
|
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSchema = z.object({ productId: z.string().min(1) })
|
||||||
|
|
||||||
|
export async function setActiveProductAction(productId: string) {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||||
|
|
||||||
|
const parsed = setSchema.safeParse({ productId })
|
||||||
|
if (!parsed.success) return { error: 'Ongeldig product-id' }
|
||||||
|
|
||||||
|
const product = await prisma.product.findFirst({
|
||||||
|
where: { id: parsed.data.productId, archived: false, ...productAccessFilter(session.userId) },
|
||||||
|
})
|
||||||
|
if (!product) return { error: 'Product niet gevonden of niet toegankelijk' }
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: session.userId },
|
||||||
|
data: { active_product_id: parsed.data.productId },
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/', 'layout')
|
||||||
|
return { success: true, productId: parsed.data.productId }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearActiveProductAction() {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: session.userId },
|
||||||
|
data: { active_product_id: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/', 'layout')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
@ -148,9 +148,16 @@ export async function archiveProductAction(id: string) {
|
||||||
})
|
})
|
||||||
if (!product) return { error: 'Product niet gevonden' }
|
if (!product) return { error: 'Product niet gevonden' }
|
||||||
|
|
||||||
await prisma.product.update({ where: { id }, data: { archived: true } })
|
await prisma.$transaction([
|
||||||
|
// Clear active_product_id for all users who had this product active
|
||||||
|
prisma.user.updateMany({
|
||||||
|
where: { active_product_id: id },
|
||||||
|
data: { active_product_id: null },
|
||||||
|
}),
|
||||||
|
prisma.product.update({ where: { id }, data: { archived: true } }),
|
||||||
|
])
|
||||||
|
|
||||||
revalidatePath('/dashboard')
|
revalidatePath('/', 'layout')
|
||||||
redirect('/dashboard')
|
redirect('/dashboard')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -215,7 +222,13 @@ export async function removeProductMemberAction(productId: string, memberId: str
|
||||||
const product = await prisma.product.findFirst({ where: { id: productId, user_id: session.userId } })
|
const product = await prisma.product.findFirst({ where: { id: productId, user_id: session.userId } })
|
||||||
if (!product) return { error: 'Product niet gevonden of geen eigenaar' }
|
if (!product) return { error: 'Product niet gevonden of geen eigenaar' }
|
||||||
|
|
||||||
await prisma.productMember.deleteMany({ where: { product_id: productId, user_id: memberId } })
|
await prisma.$transaction([
|
||||||
|
prisma.user.updateMany({
|
||||||
|
where: { id: memberId, active_product_id: productId },
|
||||||
|
data: { active_product_id: null },
|
||||||
|
}),
|
||||||
|
prisma.productMember.deleteMany({ where: { product_id: productId, user_id: memberId } }),
|
||||||
|
])
|
||||||
|
|
||||||
revalidatePath(`/products/${productId}/settings`)
|
revalidatePath(`/products/${productId}/settings`)
|
||||||
return { success: true }
|
return { success: true }
|
||||||
|
|
@ -225,8 +238,15 @@ export async function leaveProductAction(productId: string) {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
|
||||||
await prisma.productMember.deleteMany({ where: { product_id: productId, user_id: session.userId } })
|
await prisma.$transaction([
|
||||||
|
prisma.user.updateMany({
|
||||||
|
where: { id: session.userId, active_product_id: productId },
|
||||||
|
data: { active_product_id: null },
|
||||||
|
}),
|
||||||
|
prisma.productMember.deleteMany({ where: { product_id: productId, user_id: session.userId } }),
|
||||||
|
])
|
||||||
|
|
||||||
|
revalidatePath('/', 'layout')
|
||||||
revalidatePath('/settings')
|
revalidatePath('/settings')
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,15 @@ export default async function DashboardPage({ searchParams }: Props) {
|
||||||
const { archived } = await searchParams
|
const { archived } = await searchParams
|
||||||
const showArchived = archived === '1'
|
const showArchived = archived === '1'
|
||||||
|
|
||||||
const products = await prisma.product.findMany({
|
const [products, user] = await Promise.all([
|
||||||
where: { archived: showArchived, ...productAccessFilter(session.userId) },
|
prisma.product.findMany({
|
||||||
orderBy: { created_at: 'desc' },
|
where: { archived: showArchived, ...productAccessFilter(session.userId) },
|
||||||
})
|
orderBy: { created_at: 'desc' },
|
||||||
|
}),
|
||||||
|
session.userId
|
||||||
|
? prisma.user.findUnique({ where: { id: session.userId }, select: { active_product_id: true } })
|
||||||
|
: null,
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl mx-auto w-full">
|
<div className="p-6 max-w-4xl mx-auto w-full">
|
||||||
|
|
@ -47,6 +52,7 @@ export default async function DashboardPage({ searchParams }: Props) {
|
||||||
products={products}
|
products={products}
|
||||||
isDemo={session.isDemo ?? false}
|
isDemo={session.isDemo ?? false}
|
||||||
showArchived={showArchived}
|
showArchived={showArchived}
|
||||||
|
activeProductId={user?.active_product_id ?? null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,13 @@ import { cookies } from 'next/headers'
|
||||||
import { getIronSession } from 'iron-session'
|
import { getIronSession } from 'iron-session'
|
||||||
import { SessionData, sessionOptions } from '@/lib/session'
|
import { SessionData, sessionOptions } from '@/lib/session'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { productAccessFilter } from '@/lib/product-access'
|
||||||
import { NavBar } from '@/components/shared/nav-bar'
|
import { NavBar } from '@/components/shared/nav-bar'
|
||||||
import { MinWidthBanner } from '@/components/shared/min-width-banner'
|
import { MinWidthBanner } from '@/components/shared/min-width-banner'
|
||||||
import { StatusBar } from '@/components/shared/status-bar'
|
import { StatusBar } from '@/components/shared/status-bar'
|
||||||
import { SoloRealtimeBridge } from '@/components/solo/realtime-bridge'
|
import { SoloRealtimeBridge } from '@/components/solo/realtime-bridge'
|
||||||
|
import { AlertToast } from '@/components/shared/alert-toast'
|
||||||
|
import { Suspense } from 'react'
|
||||||
|
|
||||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||||
|
|
@ -15,15 +18,20 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||||
redirect('/login')
|
redirect('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
const [user, userRoles] = await Promise.all([
|
const [user, userRoles, accessibleProducts] = await Promise.all([
|
||||||
prisma.user.findUnique({
|
prisma.user.findUnique({
|
||||||
where: { id: session.userId },
|
where: { id: session.userId },
|
||||||
select: { username: true, email: true },
|
select: { username: true, email: true, active_product_id: true },
|
||||||
}),
|
}),
|
||||||
prisma.userRole.findMany({
|
prisma.userRole.findMany({
|
||||||
where: { user_id: session.userId },
|
where: { user_id: session.userId },
|
||||||
select: { role: true },
|
select: { role: true },
|
||||||
}),
|
}),
|
||||||
|
prisma.product.findMany({
|
||||||
|
where: { archived: false, ...productAccessFilter(session.userId) },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
const roles = userRoles.map(r => r.role as string)
|
const roles = userRoles.map(r => r.role as string)
|
||||||
|
|
||||||
|
|
@ -31,6 +39,30 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||||
redirect('/login')
|
redirect('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve active product — clear stale reference if archived or inaccessible
|
||||||
|
let activeProduct: { id: string; name: string } | null = null
|
||||||
|
let hasActiveSprint = false
|
||||||
|
if (user.active_product_id) {
|
||||||
|
const product = await prisma.product.findFirst({
|
||||||
|
where: { id: user.active_product_id, archived: false, ...productAccessFilter(session.userId) },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
if (product) {
|
||||||
|
activeProduct = product
|
||||||
|
const sprint = await prisma.sprint.findFirst({
|
||||||
|
where: { product_id: product.id, status: 'ACTIVE' },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
hasActiveSprint = !!sprint
|
||||||
|
} else {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: session.userId },
|
||||||
|
data: { active_product_id: null },
|
||||||
|
})
|
||||||
|
redirect('/dashboard?alert=product_unavailable')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-background flex flex-col overflow-hidden">
|
<div className="h-screen bg-background flex flex-col overflow-hidden">
|
||||||
<a href="#main-content" className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-md focus:text-sm">
|
<a href="#main-content" className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-md focus:text-sm">
|
||||||
|
|
@ -42,6 +74,9 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||||
userId={session.userId}
|
userId={session.userId}
|
||||||
username={user.username}
|
username={user.username}
|
||||||
email={user.email}
|
email={user.email}
|
||||||
|
activeProduct={activeProduct}
|
||||||
|
products={accessibleProducts}
|
||||||
|
hasActiveSprint={hasActiveSprint}
|
||||||
/>
|
/>
|
||||||
<MinWidthBanner />
|
<MinWidthBanner />
|
||||||
<main id="main-content" className="flex-1 flex flex-col overflow-y-auto min-h-0">
|
<main id="main-content" className="flex-1 flex flex-col overflow-y-auto min-h-0">
|
||||||
|
|
@ -49,6 +84,9 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||||
</main>
|
</main>
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
<SoloRealtimeBridge />
|
<SoloRealtimeBridge />
|
||||||
|
<Suspense>
|
||||||
|
<AlertToast />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { PbiList } from '@/components/backlog/pbi-list'
|
||||||
import { StoryPanel } from '@/components/backlog/story-panel'
|
import { StoryPanel } from '@/components/backlog/story-panel'
|
||||||
import type { Story } from '@/components/backlog/story-panel'
|
import type { Story } from '@/components/backlog/story-panel'
|
||||||
import { StartSprintButton } from '@/components/sprint/start-sprint-button'
|
import { StartSprintButton } from '@/components/sprint/start-sprint-button'
|
||||||
|
import { ActivateProductButton } from '@/components/shared/activate-product-button'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -21,9 +22,10 @@ export default async function ProductBacklogPage({ params }: Props) {
|
||||||
const product = await getAccessibleProduct(id, session.userId)
|
const product = await getAccessibleProduct(id, session.userId)
|
||||||
if (!product) notFound()
|
if (!product) notFound()
|
||||||
|
|
||||||
const activeSprint = await prisma.sprint.findFirst({
|
const [activeSprint, user] = await Promise.all([
|
||||||
where: { product_id: id, status: 'ACTIVE' },
|
prisma.sprint.findFirst({ where: { product_id: id, status: 'ACTIVE' } }),
|
||||||
})
|
prisma.user.findUnique({ where: { id: session.userId! }, select: { active_product_id: true } }),
|
||||||
|
])
|
||||||
|
|
||||||
const pbis = await prisma.pbi.findMany({
|
const pbis = await prisma.pbi.findMany({
|
||||||
where: { product_id: id },
|
where: { product_id: id },
|
||||||
|
|
@ -42,6 +44,7 @@ export default async function ProductBacklogPage({ params }: Props) {
|
||||||
priority: true,
|
priority: true,
|
||||||
status: true,
|
status: true,
|
||||||
pbi_id: true,
|
pbi_id: true,
|
||||||
|
created_at: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -65,6 +68,9 @@ export default async function ProductBacklogPage({ params }: Props) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{user?.active_product_id !== id && (
|
||||||
|
<ActivateProductButton productId={id} isDemo={isDemo} redirectTo={`/products/${id}`} />
|
||||||
|
)}
|
||||||
{activeSprint ? (
|
{activeSprint ? (
|
||||||
<Link href={`/products/${id}/sprint`} className="text-xs text-primary hover:underline font-medium">
|
<Link href={`/products/${id}/sprint`} className="text-xs text-primary hover:underline font-medium">
|
||||||
Sprint actief →
|
Sprint actief →
|
||||||
|
|
@ -88,7 +94,7 @@ export default async function ProductBacklogPage({ params }: Props) {
|
||||||
left={
|
left={
|
||||||
<PbiList
|
<PbiList
|
||||||
productId={id}
|
productId={id}
|
||||||
pbis={pbis.map((p: (typeof pbis)[number]) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description }))}
|
pbis={pbis.map((p: (typeof pbis)[number]) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at }))}
|
||||||
isDemo={isDemo}
|
isDemo={isDemo}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
|
||||||
import { RoleManager } from '@/components/settings/role-manager'
|
import { RoleManager } from '@/components/settings/role-manager'
|
||||||
import { LeaveProductButton } from '@/components/settings/leave-product-button'
|
import { LeaveProductButton } from '@/components/settings/leave-product-button'
|
||||||
import { ProfileEditor } from '@/components/settings/profile-editor'
|
import { ProfileEditor } from '@/components/settings/profile-editor'
|
||||||
|
import { ActivateProductButton } from '@/components/shared/activate-product-button'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
|
|
@ -13,7 +14,7 @@ export default async function SettingsPage() {
|
||||||
const [user, userRoles, ownedProducts, memberships] = await Promise.all([
|
const [user, userRoles, ownedProducts, memberships] = await Promise.all([
|
||||||
prisma.user.findUnique({
|
prisma.user.findUnique({
|
||||||
where: { id: session.userId },
|
where: { id: session.userId },
|
||||||
select: { username: true, email: true, bio: true, bio_detail: true, avatar_data: true, updated_at: true },
|
select: { username: true, email: true, bio: true, bio_detail: true, avatar_data: true, updated_at: true, active_product_id: true },
|
||||||
}),
|
}),
|
||||||
prisma.userRole.findMany({ where: { user_id: session.userId } }),
|
prisma.userRole.findMany({ where: { user_id: session.userId } }),
|
||||||
prisma.product.findMany({
|
prisma.product.findMany({
|
||||||
|
|
@ -33,7 +34,9 @@ export default async function SettingsPage() {
|
||||||
| { kind: 'owner'; id: string; name: string }
|
| { kind: 'owner'; id: string; name: string }
|
||||||
| { kind: 'member'; id: string; name: string; ownerUsername: string }
|
| { kind: 'member'; id: string; name: string; ownerUsername: string }
|
||||||
|
|
||||||
const productBacklogs: PbEntry[] = [
|
const activeProductId = user?.active_product_id ?? null
|
||||||
|
|
||||||
|
const allBacklogs: PbEntry[] = [
|
||||||
...ownedProducts.map(p => ({ kind: 'owner' as const, id: p.id, name: p.name })),
|
...ownedProducts.map(p => ({ kind: 'owner' as const, id: p.id, name: p.name })),
|
||||||
...memberships.map(m => ({
|
...memberships.map(m => ({
|
||||||
kind: 'member' as const,
|
kind: 'member' as const,
|
||||||
|
|
@ -43,6 +46,12 @@ export default async function SettingsPage() {
|
||||||
})),
|
})),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Active product floats to the top
|
||||||
|
const productBacklogs = [
|
||||||
|
...allBacklogs.filter(pb => pb.id === activeProductId),
|
||||||
|
...allBacklogs.filter(pb => pb.id !== activeProductId),
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-2xl mx-auto w-full space-y-6">
|
<div className="p-6 max-w-2xl mx-auto w-full space-y-6">
|
||||||
<h1 className="text-xl font-medium text-foreground">Instellingen</h1>
|
<h1 className="text-xl font-medium text-foreground">Instellingen</h1>
|
||||||
|
|
@ -78,11 +87,21 @@ export default async function SettingsPage() {
|
||||||
<RoleManager currentRoles={currentRoles} isDemo={session.isDemo ?? false} />
|
<RoleManager currentRoles={currentRoles} isDemo={session.isDemo ?? false} />
|
||||||
|
|
||||||
<div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-3">
|
<div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-3">
|
||||||
<div>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<h2 className="text-sm font-medium text-foreground">Product Backlogs</h2>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<h2 className="text-sm font-medium text-foreground">Product Backlogs</h2>
|
||||||
Alle product backlogs waarbij je betrokken bent.
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
</p>
|
Alle product backlogs waarbij je betrokken bent.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!session.isDemo && (
|
||||||
|
<Link
|
||||||
|
href="/products/new"
|
||||||
|
className="shrink-0 text-xs text-primary hover:underline font-medium"
|
||||||
|
>
|
||||||
|
+ Nieuw product
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{productBacklogs.length === 0 ? (
|
{productBacklogs.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|
@ -91,33 +110,52 @@ export default async function SettingsPage() {
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{productBacklogs.map(pb => (
|
{productBacklogs.map(pb => {
|
||||||
<li key={`${pb.kind}-${pb.id}`} className="flex items-center justify-between gap-3 rounded-lg bg-surface-container px-3 py-2.5">
|
const isActive = pb.id === activeProductId
|
||||||
<div className="min-w-0">
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<li key={`${pb.kind}-${pb.id}`} className={`flex items-center justify-between gap-3 rounded-lg px-3 py-2.5 ${
|
||||||
<Link
|
isActive ? 'bg-primary-container/30 border border-primary/20' : 'bg-surface-container'
|
||||||
href={`/products/${pb.id}`}
|
}`}>
|
||||||
className="text-sm font-medium text-foreground hover:text-primary hover:underline truncate"
|
<div className="min-w-0">
|
||||||
>
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{pb.name}
|
<Link
|
||||||
</Link>
|
href={`/products/${pb.id}`}
|
||||||
<span className={`shrink-0 text-xs px-1.5 py-0.5 rounded font-medium ${
|
className="text-sm font-medium text-foreground hover:text-primary hover:underline truncate"
|
||||||
pb.kind === 'owner'
|
>
|
||||||
? 'bg-primary-container text-primary-container-foreground'
|
{pb.name}
|
||||||
: 'bg-secondary-container text-secondary-container-foreground'
|
</Link>
|
||||||
}`}>
|
<span className={`shrink-0 text-xs px-1.5 py-0.5 rounded font-medium ${
|
||||||
{pb.kind === 'owner' ? 'Eigenaar' : 'Developer'}
|
pb.kind === 'owner'
|
||||||
</span>
|
? 'bg-primary-container text-primary-container-foreground'
|
||||||
|
: 'bg-secondary-container text-secondary-container-foreground'
|
||||||
|
}`}>
|
||||||
|
{pb.kind === 'owner' ? 'Eigenaar' : 'Developer'}
|
||||||
|
</span>
|
||||||
|
{isActive && (
|
||||||
|
<span className="shrink-0 text-xs px-1.5 py-0.5 rounded font-medium bg-primary text-primary-foreground">
|
||||||
|
Actief
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{pb.kind === 'member' && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Eigenaar: {pb.ownerUsername}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{pb.kind === 'member' && (
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Eigenaar: {pb.ownerUsername}</p>
|
{!isActive && (
|
||||||
)}
|
<ActivateProductButton
|
||||||
</div>
|
productId={pb.id}
|
||||||
{pb.kind === 'member' && !session.isDemo && (
|
isDemo={session.isDemo ?? false}
|
||||||
<LeaveProductButton productId={pb.id} />
|
label="Maak actief"
|
||||||
)}
|
/>
|
||||||
</li>
|
)}
|
||||||
))}
|
{pb.kind === 'member' && !session.isDemo && (
|
||||||
|
<LeaveProductButton productId={pb.id} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { getLastProductCookie } from '@/lib/cookies'
|
import { productAccessFilter } from '@/lib/product-access'
|
||||||
import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access'
|
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { ProductPicker } from '@/components/solo/product-picker'
|
import { ProductPicker } from '@/components/solo/product-picker'
|
||||||
|
|
||||||
|
|
@ -9,11 +8,17 @@ export default async function SoloPage() {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session.userId) redirect('/login')
|
if (!session.userId) redirect('/login')
|
||||||
|
|
||||||
const lastProductId = await getLastProductCookie()
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.userId },
|
||||||
|
select: { active_product_id: true },
|
||||||
|
})
|
||||||
|
|
||||||
if (lastProductId) {
|
if (user?.active_product_id) {
|
||||||
const product = await getAccessibleProduct(lastProductId, session.userId)
|
const product = await prisma.product.findFirst({
|
||||||
if (product && !product.archived) redirect(`/products/${lastProductId}/solo`)
|
where: { id: user.active_product_id, archived: false, ...productAccessFilter(session.userId) },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (product) redirect(`/products/${user.active_product_id}/solo`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const products = await prisma.product.findMany({
|
const products = await prisma.product.findMany({
|
||||||
|
|
|
||||||
88
app/page.tsx
88
app/page.tsx
|
|
@ -31,51 +31,49 @@ export default async function LandingPage() {
|
||||||
<AppIcon size={24} />
|
<AppIcon size={24} />
|
||||||
Scrum4Me
|
Scrum4Me
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="ml-auto flex items-center gap-2">
|
{isLoggedIn ? (
|
||||||
{isLoggedIn ? (
|
<nav className="flex items-center gap-1 ml-2">
|
||||||
<>
|
<Link
|
||||||
<Link
|
href="/dashboard"
|
||||||
href="/dashboard"
|
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
|
||||||
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
|
>
|
||||||
>
|
Producten
|
||||||
Producten
|
</Link>
|
||||||
</Link>
|
<Link
|
||||||
<Link
|
href="/solo"
|
||||||
href="/solo"
|
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
|
||||||
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
|
>
|
||||||
>
|
Solo
|
||||||
Solo
|
</Link>
|
||||||
</Link>
|
<Link
|
||||||
<Link
|
href="/todos"
|
||||||
href="/todos"
|
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
|
||||||
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
|
>
|
||||||
>
|
Todo's
|
||||||
Todo's
|
</Link>
|
||||||
</Link>
|
<Link
|
||||||
<Link
|
href="/settings"
|
||||||
href="/settings"
|
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
|
||||||
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
|
>
|
||||||
>
|
Instellingen
|
||||||
Instellingen
|
</Link>
|
||||||
</Link>
|
</nav>
|
||||||
</>
|
) : (
|
||||||
) : (
|
<nav className="ml-auto flex items-center gap-2">
|
||||||
<>
|
<Link
|
||||||
<Link
|
href="/login"
|
||||||
href="/login"
|
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
|
||||||
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
|
>
|
||||||
>
|
Inloggen
|
||||||
Inloggen
|
</Link>
|
||||||
</Link>
|
<Link
|
||||||
<Link
|
href="/register"
|
||||||
href="/register"
|
className="px-4 py-1.5 rounded-md text-sm bg-primary text-primary-foreground hover:opacity-90 transition-opacity font-medium"
|
||||||
className="px-4 py-1.5 rounded-md text-sm bg-primary text-primary-foreground hover:opacity-90 transition-opacity font-medium"
|
>
|
||||||
>
|
Registreren
|
||||||
Registreren
|
</Link>
|
||||||
</Link>
|
</nav>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1 overflow-y-auto">
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { PbiDialog, type PbiDialogState } from './pbi-dialog'
|
import { PbiDialog, type PbiDialogState } from './pbi-dialog'
|
||||||
import { BacklogCard } from './backlog-card'
|
import { BacklogCard } from './backlog-card'
|
||||||
|
import { PRIORITY_COLORS } from '@/components/shared/priority-select'
|
||||||
|
|
||||||
const PRIORITY_LABELS: Record<number, string> = {
|
const PRIORITY_LABELS: Record<number, string> = {
|
||||||
1: 'Kritiek',
|
1: 'Kritiek',
|
||||||
|
|
@ -40,12 +41,8 @@ const PRIORITY_LABELS: Record<number, string> = {
|
||||||
4: 'Laag',
|
4: 'Laag',
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRIORITY_COLORS: Record<number, string> = {
|
|
||||||
1: 'bg-priority-critical/15 text-priority-critical border-priority-critical/30',
|
type SortMode = 'priority' | 'code' | 'date'
|
||||||
2: 'bg-priority-high/15 text-priority-high border-priority-high/30',
|
|
||||||
3: 'bg-priority-medium/15 text-priority-medium border-priority-medium/30',
|
|
||||||
4: 'bg-priority-low/15 text-priority-low border-priority-low/30',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Pbi {
|
interface Pbi {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -53,6 +50,7 @@ interface Pbi {
|
||||||
title: string
|
title: string
|
||||||
priority: number
|
priority: number
|
||||||
description?: string | null
|
description?: string | null
|
||||||
|
created_at: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PbiListProps {
|
interface PbiListProps {
|
||||||
|
|
@ -129,10 +127,16 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
|
||||||
const { selectedPbiId, selectPbi } = useSelectionStore()
|
const { selectedPbiId, selectPbi } = useSelectionStore()
|
||||||
const { pbiOrder, pbiPriority, initPbis, reorderPbis, rollbackPbis, updatePbiPriority } = usePlannerStore()
|
const { pbiOrder, pbiPriority, initPbis, reorderPbis, rollbackPbis, updatePbiPriority } = usePlannerStore()
|
||||||
const [filterPriority, setFilterPriority] = useState<number | null>(null)
|
const [filterPriority, setFilterPriority] = useState<number | null>(null)
|
||||||
|
const [sortMode, setSortMode] = useState<SortMode>(() => {
|
||||||
|
const saved = typeof window !== 'undefined' ? localStorage.getItem('scrum4me:pbi_sort') : null
|
||||||
|
return (saved === 'priority' || saved === 'code' || saved === 'date') ? saved : 'priority'
|
||||||
|
})
|
||||||
const [dialogState, setDialogState] = useState<PbiDialogState | null>(null)
|
const [dialogState, setDialogState] = useState<PbiDialogState | null>(null)
|
||||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||||
const [, startTransition] = useTransition()
|
const [, startTransition] = useTransition()
|
||||||
|
|
||||||
|
useEffect(() => { localStorage.setItem('scrum4me:pbi_sort', sortMode) }, [sortMode])
|
||||||
|
|
||||||
// Sync server data into store — use stable string dep to avoid infinite loop
|
// Sync server data into store — use stable string dep to avoid infinite loop
|
||||||
const pbiIdKey = pbis.map(p => p.id).join(',')
|
const pbiIdKey = pbis.map(p => p.id).join(',')
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -150,14 +154,18 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map(p => ({ ...p, priority: pbiPriority[p.id] ?? p.priority }))
|
.map(p => ({ ...p, priority: pbiPriority[p.id] ?? p.priority }))
|
||||||
|
|
||||||
const filtered = filterPriority ? orderedPbis.filter(p => p.priority === filterPriority) : orderedPbis
|
const base = filterPriority ? orderedPbis.filter(p => p.priority === filterPriority) : orderedPbis
|
||||||
|
|
||||||
const grouped = [1, 2, 3, 4].reduce<Record<number, Pbi[]>>((acc, p) => {
|
const filtered = [...base].sort((a, b) => {
|
||||||
acc[p] = filtered.filter(pbi => pbi.priority === p)
|
if (sortMode === 'code') {
|
||||||
return acc
|
return (a.code ?? '').localeCompare(b.code ?? '', 'nl', { numeric: true })
|
||||||
}, {} as Record<number, Pbi[]>)
|
}
|
||||||
|
if (sortMode === 'date') {
|
||||||
const visiblePriorities = [1, 2, 3, 4].filter(p => grouped[p].length > 0)
|
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||||
|
}
|
||||||
|
// priority: sort by priority asc, then drag-and-drop sort_order within group
|
||||||
|
return a.priority !== b.priority ? a.priority - b.priority : 0
|
||||||
|
})
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
|
@ -231,6 +239,19 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
|
||||||
<span>×</span>
|
<span>×</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<Select
|
||||||
|
value={sortMode}
|
||||||
|
onValueChange={(v) => setSortMode(v as SortMode)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 w-28 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="priority">Prioriteit</SelectItem>
|
||||||
|
<SelectItem value="code">Code</SelectItem>
|
||||||
|
<SelectItem value="date">Datum</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<Select
|
<Select
|
||||||
value={filterPriority?.toString() ?? 'all'}
|
value={filterPriority?.toString() ?? 'all'}
|
||||||
onValueChange={(v) => setFilterPriority(!v || v === 'all' ? null : parseInt(v))}
|
onValueChange={(v) => setFilterPriority(!v || v === 'all' ? null : parseInt(v))}
|
||||||
|
|
@ -277,46 +298,24 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<div className="p-3 space-y-4">
|
<SortableContext
|
||||||
{visiblePriorities.map(priority => (
|
items={filtered.map(p => p.id)}
|
||||||
<div key={priority}>
|
strategy={verticalListSortingStrategy}
|
||||||
<div className="flex items-center gap-2 mb-2">
|
>
|
||||||
<span className={cn('text-xs font-semibold px-2 py-0.5 rounded-full border', PRIORITY_COLORS[priority])}>
|
<div className="p-3 flex flex-col gap-2">
|
||||||
{PRIORITY_LABELS[priority]}
|
{filtered.map(pbi => (
|
||||||
</span>
|
<SortablePbiRow
|
||||||
<div className="flex-1 h-px bg-border" />
|
key={pbi.id}
|
||||||
{!isDemo && (
|
pbi={pbi}
|
||||||
<button
|
isSelected={selectedPbiId === pbi.id}
|
||||||
onClick={() => setDialogState({ mode: 'create', productId, defaultPriority: priority })}
|
isDemo={isDemo}
|
||||||
className="text-xs text-muted-foreground hover:text-foreground"
|
onSelect={() => selectPbi(pbi.id)}
|
||||||
aria-label={`Nieuw PBI aanmaken (${PRIORITY_LABELS[priority]})`}
|
onEdit={() => setDialogState({ mode: 'edit', productId, pbi })}
|
||||||
>
|
onDelete={() => handleDelete(pbi.id)}
|
||||||
+
|
/>
|
||||||
</button>
|
))}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</SortableContext>
|
||||||
|
|
||||||
<SortableContext
|
|
||||||
items={grouped[priority].map(p => p.id)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{grouped[priority].map(pbi => (
|
|
||||||
<SortablePbiRow
|
|
||||||
key={pbi.id}
|
|
||||||
pbi={pbi}
|
|
||||||
isSelected={selectedPbiId === pbi.id}
|
|
||||||
isDemo={isDemo}
|
|
||||||
onSelect={() => selectPbi(pbi.id)}
|
|
||||||
onEdit={() => setDialogState({ mode: 'edit', productId, pbi })}
|
|
||||||
onDelete={() => handleDelete(pbi.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
{activePbi && (
|
{activePbi && (
|
||||||
|
|
|
||||||
|
|
@ -29,16 +29,10 @@ import { useSelectionStore } from '@/stores/selection-store'
|
||||||
import { usePlannerStore } from '@/stores/planner-store'
|
import { usePlannerStore } from '@/stores/planner-store'
|
||||||
import { reorderStoriesAction } from '@/actions/stories'
|
import { reorderStoriesAction } from '@/actions/stories'
|
||||||
import { StoryDialog, type StoryDialogState } from './story-dialog'
|
import { StoryDialog, type StoryDialogState } from './story-dialog'
|
||||||
import { BacklogCard, PRIORITY_BORDER } from './backlog-card'
|
import { BacklogCard } from './backlog-card'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const PRIORITY_LABELS: Record<number, string> = { 1: 'Kritiek', 2: 'Hoog', 3: 'Gemiddeld', 4: 'Laag' }
|
type SortMode = 'priority' | 'code' | 'date'
|
||||||
const PRIORITY_COLORS: Record<number, string> = {
|
|
||||||
1: 'bg-priority-critical/15 text-priority-critical border-priority-critical/30',
|
|
||||||
2: 'bg-priority-high/15 text-priority-high border-priority-high/30',
|
|
||||||
3: 'bg-priority-medium/15 text-priority-medium border-priority-medium/30',
|
|
||||||
4: 'bg-priority-low/15 text-priority-low border-priority-low/30',
|
|
||||||
}
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
OPEN: 'bg-status-todo/15 text-status-todo border-status-todo/30',
|
OPEN: 'bg-status-todo/15 text-status-todo border-status-todo/30',
|
||||||
IN_SPRINT: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
|
IN_SPRINT: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
|
||||||
|
|
@ -59,6 +53,7 @@ export interface Story {
|
||||||
priority: number
|
priority: number
|
||||||
status: string
|
status: string
|
||||||
pbi_id: string
|
pbi_id: string
|
||||||
|
created_at: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StoryPanelProps {
|
interface StoryPanelProps {
|
||||||
|
|
@ -111,10 +106,16 @@ export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps)
|
||||||
const { storyOrder, initStories, reorderStories, rollbackStories } = usePlannerStore()
|
const { storyOrder, initStories, reorderStories, rollbackStories } = usePlannerStore()
|
||||||
const [filterStatus, setFilterStatus] = useState<string | null>(null)
|
const [filterStatus, setFilterStatus] = useState<string | null>(null)
|
||||||
const [filterPriority, setFilterPriority] = useState<number | null>(null)
|
const [filterPriority, setFilterPriority] = useState<number | null>(null)
|
||||||
|
const [sortMode, setSortMode] = useState<SortMode>(() => {
|
||||||
|
const saved = typeof window !== 'undefined' ? localStorage.getItem('scrum4me:story_sort') : null
|
||||||
|
return (saved === 'priority' || saved === 'code' || saved === 'date') ? saved : 'priority'
|
||||||
|
})
|
||||||
const [storyDialogState, setStoryDialogState] = useState<StoryDialogState | null>(null)
|
const [storyDialogState, setStoryDialogState] = useState<StoryDialogState | null>(null)
|
||||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||||
const [, startTransition] = useTransition()
|
const [, startTransition] = useTransition()
|
||||||
|
|
||||||
|
useEffect(() => { localStorage.setItem('scrum4me:story_sort', sortMode) }, [sortMode])
|
||||||
|
|
||||||
const rawStories = selectedPbiId ? (storiesByPbi[selectedPbiId] ?? []) : []
|
const rawStories = selectedPbiId ? (storiesByPbi[selectedPbiId] ?? []) : []
|
||||||
|
|
||||||
// Sync into store — use stable string dep to avoid infinite loop
|
// Sync into store — use stable string dep to avoid infinite loop
|
||||||
|
|
@ -130,16 +131,19 @@ export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps)
|
||||||
const order = (selectedPbiId ? storyOrder[selectedPbiId] : null) ?? rawStories.map(s => s.id)
|
const order = (selectedPbiId ? storyOrder[selectedPbiId] : null) ?? rawStories.map(s => s.id)
|
||||||
const orderedStories = order.map(id => storyMap[id]).filter(Boolean)
|
const orderedStories = order.map(id => storyMap[id]).filter(Boolean)
|
||||||
|
|
||||||
const filtered = orderedStories
|
const base = orderedStories
|
||||||
.filter(s => !filterStatus || s.status === filterStatus)
|
.filter(s => !filterStatus || s.status === filterStatus)
|
||||||
.filter(s => !filterPriority || s.priority === filterPriority)
|
.filter(s => !filterPriority || s.priority === filterPriority)
|
||||||
|
|
||||||
const grouped = [1, 2, 3, 4].reduce<Record<number, Story[]>>((acc, p) => {
|
const filtered = [...base].sort((a, b) => {
|
||||||
acc[p] = filtered.filter(s => s.priority === p)
|
if (sortMode === 'code') {
|
||||||
return acc
|
return (a.code ?? '').localeCompare(b.code ?? '', 'nl', { numeric: true })
|
||||||
}, {} as Record<number, Story[]>)
|
}
|
||||||
|
if (sortMode === 'date') {
|
||||||
const visiblePriorities = [1, 2, 3, 4].filter(p => grouped[p].length > 0)
|
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||||
|
}
|
||||||
|
return a.priority !== b.priority ? a.priority - b.priority : 0
|
||||||
|
})
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
|
@ -195,6 +199,16 @@ export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps)
|
||||||
Filter wissen ×
|
Filter wissen ×
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<Select value={sortMode} onValueChange={(v) => setSortMode(v as SortMode)}>
|
||||||
|
<SelectTrigger className="h-7 w-28 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="priority">Prioriteit</SelectItem>
|
||||||
|
<SelectItem value="code">Code</SelectItem>
|
||||||
|
<SelectItem value="date">Datum</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<Select
|
<Select
|
||||||
value={filterStatus ?? 'all'}
|
value={filterStatus ?? 'all'}
|
||||||
onValueChange={(v) => setFilterStatus(!v || v === 'all' ? null : v)}
|
onValueChange={(v) => setFilterStatus(!v || v === 'all' ? null : v)}
|
||||||
|
|
@ -244,42 +258,17 @@ export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps)
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<SortableContext items={filtered.map(s => s.id)} strategy={rectSortingStrategy}>
|
||||||
{visiblePriorities.map(priority => (
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<div key={priority}>
|
{filtered.map(story => (
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<SortableStoryBlock
|
||||||
<span className={cn('text-xs font-semibold px-2 py-0.5 rounded-full border', PRIORITY_COLORS[priority])}>
|
key={story.id}
|
||||||
{PRIORITY_LABELS[priority]}
|
story={story}
|
||||||
</span>
|
onClick={() => setStoryDialogState({ mode: 'edit', story, productId })}
|
||||||
<div className="flex-1 h-px bg-border" />
|
/>
|
||||||
{!isDemo && selectedPbiId && (
|
))}
|
||||||
<button
|
</div>
|
||||||
onClick={() => setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: priority })}
|
</SortableContext>
|
||||||
className="text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SortableContext
|
|
||||||
items={grouped[priority].map(s => s.id)}
|
|
||||||
strategy={rectSortingStrategy}
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
{grouped[priority].map(story => (
|
|
||||||
<SortableStoryBlock
|
|
||||||
key={story.id}
|
|
||||||
story={story}
|
|
||||||
onClick={() => setStoryDialogState({ mode: 'edit', story, productId })}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
{activeDragId && storyMap[activeDragId] && (
|
{activeDragId && storyMap[activeDragId] && (
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ import { useRouter } from 'next/navigation'
|
||||||
import { useTransition } from 'react'
|
import { useTransition } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { CodeBadge } from '@/components/shared/code-badge'
|
import { CodeBadge } from '@/components/shared/code-badge'
|
||||||
import { restoreProductAction } from '@/actions/products'
|
import { restoreProductAction } from '@/actions/products'
|
||||||
|
import { setActiveProductAction } from '@/actions/active-product'
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -20,9 +22,10 @@ interface ProductListProps {
|
||||||
products: Product[]
|
products: Product[]
|
||||||
isDemo: boolean
|
isDemo: boolean
|
||||||
showArchived?: boolean
|
showArchived?: boolean
|
||||||
|
activeProductId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProductList({ products, isDemo, showArchived = false }: ProductListProps) {
|
export function ProductList({ products, isDemo, showArchived = false, activeProductId }: ProductListProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [, startTransition] = useTransition()
|
const [, startTransition] = useTransition()
|
||||||
|
|
||||||
|
|
@ -34,6 +37,15 @@ export function ProductList({ products, isDemo, showArchived = false }: ProductL
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleActivate(id: string) {
|
||||||
|
if (isDemo) { toast.error('Niet beschikbaar in demo-modus'); return }
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await setActiveProductAction(id)
|
||||||
|
if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Activeren mislukt')
|
||||||
|
else router.push(`/products/${id}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (products.length === 0) {
|
if (products.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface-container-low rounded-xl border border-border p-12 text-center space-y-3">
|
<div className="bg-surface-container-low rounded-xl border border-border p-12 text-center space-y-3">
|
||||||
|
|
@ -87,6 +99,18 @@ export function ProductList({ products, isDemo, showArchived = false }: ProductL
|
||||||
Repo
|
Repo
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{!showArchived && (
|
||||||
|
product.id === activeProductId
|
||||||
|
? <Badge className="bg-primary-container text-primary-container-foreground text-xs px-2 py-0">Actief</Badge>
|
||||||
|
: (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleActivate(product.id) }}
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Activeer
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
{showArchived && !isDemo && (
|
{showArchived && !isDemo && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleRestore(product.id) }}
|
onClick={(e) => { e.stopPropagation(); handleRestore(product.id) }}
|
||||||
|
|
|
||||||
39
components/shared/activate-product-button.tsx
Normal file
39
components/shared/activate-product-button.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useTransition } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { setActiveProductAction } from '@/actions/active-product'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
productId: string
|
||||||
|
isDemo: boolean
|
||||||
|
/** Navigate here after activation. Omit to refresh the current page in place. */
|
||||||
|
redirectTo?: string
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivateProductButton({ productId, isDemo, redirectTo, label = 'Activeer' }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
function handleActivate() {
|
||||||
|
if (isDemo) { toast.error('Niet beschikbaar in demo-modus'); return }
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await setActiveProductAction(productId)
|
||||||
|
if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Activeren mislukt')
|
||||||
|
else if (redirectTo) router.push(redirectTo)
|
||||||
|
else router.refresh()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleActivate}
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-xs text-primary hover:underline font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
components/shared/alert-toast.tsx
Normal file
28
components/shared/alert-toast.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const ALERT_MESSAGES: Record<string, string> = {
|
||||||
|
product_unavailable: 'Je actieve product is niet meer beschikbaar',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertToast() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const pathname = usePathname()
|
||||||
|
const alert = searchParams.get('alert')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!alert || !ALERT_MESSAGES[alert]) return
|
||||||
|
toast.error(ALERT_MESSAGES[alert])
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
params.delete('alert')
|
||||||
|
const next = params.toString() ? `${pathname}?${params}` : pathname
|
||||||
|
router.replace(next)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [alert])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,23 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname, useRouter } from 'next/navigation'
|
||||||
|
import { useTransition } from 'react'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { AppIcon } from '@/components/shared/app-icon'
|
import { AppIcon } from '@/components/shared/app-icon'
|
||||||
import { UserMenu } from '@/components/shared/user-menu'
|
import { UserMenu } from '@/components/shared/user-menu'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useProductStore } from '@/stores/product-store'
|
import { setActiveProductAction } from '@/actions/active-product'
|
||||||
|
|
||||||
interface NavBarProps {
|
interface NavBarProps {
|
||||||
isDemo: boolean
|
isDemo: boolean
|
||||||
|
|
@ -15,23 +25,84 @@ interface NavBarProps {
|
||||||
userId: string
|
userId: string
|
||||||
username: string
|
username: string
|
||||||
email: string | null
|
email: string | null
|
||||||
|
activeProduct: { id: string; name: string } | null
|
||||||
|
products: { id: string; name: string }[]
|
||||||
|
hasActiveSprint: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavBar({ isDemo, roles, userId, username, email }: NavBarProps) {
|
export function NavBar({
|
||||||
|
isDemo,
|
||||||
|
roles,
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
activeProduct,
|
||||||
|
products,
|
||||||
|
hasActiveSprint,
|
||||||
|
}: NavBarProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const currentProduct = useProductStore(s => s.currentProduct)
|
const router = useRouter()
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
const productMatch = pathname.match(/^\/products\/([^/]+)/)
|
function handleSwitchProduct(productId: string) {
|
||||||
const productId = productMatch ? productMatch[1] : null
|
startTransition(async () => {
|
||||||
|
const result = await setActiveProductAction(productId)
|
||||||
|
if (result?.error) {
|
||||||
|
toast.error(typeof result.error === 'string' ? result.error : 'Wisselen mislukt')
|
||||||
|
} else {
|
||||||
|
router.push(`/products/${productId}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const sprintHref = productId ? `/products/${productId}/sprint` : null
|
const activeId = activeProduct?.id ?? null
|
||||||
|
|
||||||
const navLinks = [
|
// Nav link helpers
|
||||||
{ href: '/dashboard', label: 'Producten', active: pathname.startsWith('/dashboard') || (pathname.startsWith('/products') && !pathname.includes('/solo')) },
|
const disabledSpan = (label: string) => (
|
||||||
{ href: sprintHref, label: 'Sprint', active: pathname.includes('/sprint') },
|
<span
|
||||||
{ href: '/solo', label: 'Solo', active: pathname.includes('/solo') },
|
key={label}
|
||||||
{ href: '/todos', label: "Todo's", active: pathname.startsWith('/todos') },
|
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground/40 cursor-not-allowed select-none"
|
||||||
]
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
const navLink = (href: string, label: string, isActive: boolean) => (
|
||||||
|
<Link
|
||||||
|
key={label}
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 rounded-md text-sm transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-primary-container text-primary-container-foreground font-medium'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-surface-container'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
|
||||||
|
const sprintNode = () => {
|
||||||
|
if (!activeId) return disabledSpan('Sprint')
|
||||||
|
const href = `/products/${activeId}/sprint`
|
||||||
|
const isActive = pathname.includes('/sprint')
|
||||||
|
if (!hasActiveSprint) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider key="sprint">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground/40 cursor-not-allowed select-none"
|
||||||
|
aria-disabled="true"
|
||||||
|
>
|
||||||
|
Sprint
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Geen actieve sprint</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return navLink(href, 'Sprint', isActive)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-surface-container-low border-b border-border h-14 flex items-center px-4 shrink-0">
|
<header className="bg-surface-container-low border-b border-border h-14 flex items-center px-4 shrink-0">
|
||||||
|
|
@ -48,50 +119,63 @@ export function NavBar({ isDemo, roles, userId, username, email }: NavBarProps)
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="flex items-center gap-1 ml-2">
|
<nav className="flex items-center gap-1 ml-2">
|
||||||
{navLinks.map(link =>
|
{navLink('/dashboard', 'Producten', pathname.startsWith('/dashboard'))}
|
||||||
link.href ? (
|
{activeId
|
||||||
<Link
|
? navLink(
|
||||||
key={link.label}
|
`/products/${activeId}`,
|
||||||
href={link.href}
|
'Product Backlog',
|
||||||
className={cn(
|
pathname.startsWith(`/products/${activeId}`) && !pathname.includes('/sprint') && !pathname.includes('/solo')
|
||||||
'px-3 py-1.5 rounded-md text-sm transition-colors',
|
)
|
||||||
link.active
|
: disabledSpan('Product Backlog')}
|
||||||
? 'bg-primary-container text-primary-container-foreground font-medium'
|
{sprintNode()}
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-surface-container'
|
{activeId
|
||||||
)}
|
? navLink(
|
||||||
>
|
`/products/${activeId}/solo`,
|
||||||
{link.label}
|
'Solo',
|
||||||
</Link>
|
pathname.includes('/solo')
|
||||||
) : (
|
)
|
||||||
<span
|
: disabledSpan('Solo')}
|
||||||
key={link.label}
|
{navLink('/todos', "Todo's", pathname.startsWith('/todos'))}
|
||||||
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground/40 cursor-default select-none"
|
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Midden: productnaam */}
|
{/* Midden: actief product dropdown */}
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
{currentProduct && (
|
{activeProduct ? (
|
||||||
<TooltipProvider>
|
<DropdownMenu>
|
||||||
<Tooltip>
|
<DropdownMenuTrigger
|
||||||
<TooltipTrigger render={
|
disabled={isPending}
|
||||||
<Link
|
className="flex items-center gap-1 text-sm font-medium text-foreground hover:text-primary transition-colors px-2 rounded-md hover:bg-surface-container focus:outline-none"
|
||||||
href={`/products/${currentProduct.id}`}
|
>
|
||||||
className="text-sm font-medium text-foreground hover:text-primary transition-colors px-2 truncate max-w-[200px]"
|
<span className="truncate max-w-[180px]">
|
||||||
/>
|
{activeProduct.name.length > 22
|
||||||
}>
|
? activeProduct.name.slice(0, 22) + '…'
|
||||||
{currentProduct.name.length > 20
|
: activeProduct.name}
|
||||||
? currentProduct.name.slice(0, 20) + '…'
|
</span>
|
||||||
: currentProduct.name}
|
<ChevronDown className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</DropdownMenuTrigger>
|
||||||
<TooltipContent>{currentProduct.name}</TooltipContent>
|
<DropdownMenuContent align="center" className="w-56">
|
||||||
</Tooltip>
|
{products.map(p => (
|
||||||
</TooltipProvider>
|
<DropdownMenuItem
|
||||||
|
key={p.id}
|
||||||
|
onSelect={() => p.id !== activeProduct.id && handleSwitchProduct(p.id)}
|
||||||
|
className={cn(
|
||||||
|
p.id === activeProduct.id && 'bg-primary-container text-primary-container-foreground font-medium'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{p.name}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Link href="/dashboard" className="w-full">
|
||||||
|
Producten beheren →
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground/50 select-none">Geen actief product</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useTransition } from 'react'
|
import { useState, useTransition } from 'react'
|
||||||
import { Trash2, MoreHorizontal } from 'lucide-react'
|
import { Trash2, MoreHorizontal, ChevronsUp, ChevronsDown, ListFilter } from 'lucide-react'
|
||||||
import { useDroppable, useDraggable } from '@dnd-kit/core'
|
import { useDroppable, useDraggable } from '@dnd-kit/core'
|
||||||
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
|
@ -15,6 +15,8 @@ import {
|
||||||
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
|
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
|
||||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { PRIORITY_BORDER } from '@/components/backlog/backlog-card'
|
||||||
import { useSprintStore } from '@/stores/sprint-store'
|
import { useSprintStore } from '@/stores/sprint-store'
|
||||||
import { claimStoryAction, unclaimStoryAction, reassignStoryAction, claimAllUnassignedInActiveSprintAction } from '@/actions/stories'
|
import { claimStoryAction, unclaimStoryAction, reassignStoryAction, claimAllUnassignedInActiveSprintAction } from '@/actions/stories'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
@ -26,14 +28,6 @@ const STATUS_COLORS: Record<string, string> = {
|
||||||
}
|
}
|
||||||
const STATUS_LABELS: Record<string, string> = { OPEN: 'Open', IN_SPRINT: 'In Sprint', DONE: 'Klaar' }
|
const STATUS_LABELS: Record<string, string> = { OPEN: 'Open', IN_SPRINT: 'In Sprint', DONE: 'Klaar' }
|
||||||
|
|
||||||
const PRIORITY_COLORS: Record<number, string> = {
|
|
||||||
1: 'bg-priority-critical/15 text-priority-critical border-priority-critical/30',
|
|
||||||
2: 'bg-priority-high/15 text-priority-high border-priority-high/30',
|
|
||||||
3: 'bg-priority-medium/15 text-priority-medium border-priority-medium/30',
|
|
||||||
4: 'bg-priority-low/15 text-priority-low border-priority-low/30',
|
|
||||||
}
|
|
||||||
const PRIORITY_LABELS: Record<number, string> = { 1: 'Kritiek', 2: 'Hoog', 3: 'Gemiddeld', 4: 'Laag' }
|
|
||||||
|
|
||||||
export interface SprintStory {
|
export interface SprintStory {
|
||||||
id: string
|
id: string
|
||||||
code: string | null
|
code: string | null
|
||||||
|
|
@ -124,86 +118,89 @@ function SortableSprintRow({
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
className={cn(
|
className="group px-2 py-1 cursor-pointer"
|
||||||
'group flex items-start gap-3 px-4 py-2.5 border-b border-border cursor-pointer transition-colors',
|
|
||||||
isSelected
|
|
||||||
? 'bg-primary-container text-primary-container-foreground'
|
|
||||||
: 'hover:bg-surface-container'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{!isDemo && (
|
<div className={cn(
|
||||||
<span
|
'flex items-start gap-2 rounded border border-border px-3 py-2 transition-colors',
|
||||||
{...attributes}
|
PRIORITY_BORDER[story.priority],
|
||||||
{...listeners}
|
isSelected
|
||||||
aria-label="Versleep om te sorteren of naar Product Backlog"
|
? 'bg-primary-container border-primary text-primary-container-foreground'
|
||||||
className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 select-none text-sm mt-0.5"
|
: 'bg-surface-container hover:bg-surface-container-high'
|
||||||
onClick={e => e.stopPropagation()}
|
)}>
|
||||||
>
|
|
||||||
⠿
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<p className="text-sm truncate flex-1">{story.title}</p>
|
|
||||||
{story.code && <CodeBadge code={story.code} className="shrink-0 mt-0.5" />}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 mt-0.5">
|
|
||||||
<Badge className={cn('text-[10px] px-1.5 py-0 border', PRIORITY_COLORS[story.priority])}>
|
|
||||||
{PRIORITY_LABELS[story.priority]}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-xs text-muted-foreground">{story.doneCount}/{story.taskCount} klaar</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 mt-1">
|
|
||||||
{story.assignee_id ? (
|
|
||||||
<>
|
|
||||||
<UserAvatar userId={story.assignee_id} username={story.assignee_username ?? '?'} size="xs" />
|
|
||||||
<span className="text-xs text-muted-foreground truncate">{story.assignee_username}</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground italic">Niet geclaimd</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 shrink-0 mt-0.5" onClick={e => e.stopPropagation()}>
|
|
||||||
<DemoTooltip show={isDemo}>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
disabled={isDemo}
|
|
||||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground p-0.5 rounded bg-transparent border-0 cursor-pointer"
|
|
||||||
aria-label="Story opties"
|
|
||||||
>
|
|
||||||
<MoreHorizontal size={14} />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" onClick={e => e.stopPropagation()}>
|
|
||||||
{story.assignee_id !== currentUserId && (
|
|
||||||
<DropdownMenuItem onClick={handleClaim}>Pak op</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{story.assignee_id && (
|
|
||||||
<DropdownMenuItem onClick={handleUnclaim}>Geef terug aan team</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuSub>
|
|
||||||
<DropdownMenuSubTrigger>Wijs toe aan</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuSubContent>
|
|
||||||
{members.map(m => (
|
|
||||||
<DropdownMenuItem key={m.userId} onClick={e => handleReassign(e, m.userId, m.username)}>
|
|
||||||
<UserAvatar userId={m.userId} username={m.username} size="xs" />
|
|
||||||
<span>{m.username}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</DemoTooltip>
|
|
||||||
{!isDemo && (
|
{!isDemo && (
|
||||||
<button
|
<span
|
||||||
onClick={e => { e.stopPropagation(); onRemove() }}
|
{...attributes}
|
||||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-error"
|
{...listeners}
|
||||||
aria-label="Verwijder uit sprint"
|
aria-label="Versleep om te sorteren of naar Product Backlog"
|
||||||
|
className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 select-none text-sm mt-0.5"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
⠿
|
||||||
</button>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="text-sm leading-snug line-clamp-2 flex-1">{story.title}</p>
|
||||||
|
{story.code && <CodeBadge code={story.code} className="shrink-0 mt-0.5" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2 mt-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<Badge className={cn('text-[10px] px-1.5 py-0 border shrink-0', STATUS_COLORS[story.status])}>
|
||||||
|
{STATUS_LABELS[story.status]}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">{story.doneCount}/{story.taskCount} klaar</span>
|
||||||
|
{story.assignee_id ? (
|
||||||
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
|
<UserAvatar userId={story.assignee_id} username={story.assignee_username ?? '?'} size="xs" />
|
||||||
|
<span className="text-xs text-muted-foreground truncate">{story.assignee_username}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground italic">Niet geclaimd</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0" onClick={e => e.stopPropagation()}>
|
||||||
|
<DemoTooltip show={isDemo}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
disabled={isDemo}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground p-0.5 rounded bg-transparent border-0 cursor-pointer"
|
||||||
|
aria-label="Story opties"
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={14} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" onClick={e => e.stopPropagation()}>
|
||||||
|
{story.assignee_id !== currentUserId && (
|
||||||
|
<DropdownMenuItem onClick={handleClaim}>Pak op</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{story.assignee_id && (
|
||||||
|
<DropdownMenuItem onClick={handleUnclaim}>Geef terug aan team</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>Wijs toe aan</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
{members.map(m => (
|
||||||
|
<DropdownMenuItem key={m.userId} onClick={e => handleReassign(e, m.userId, m.username)}>
|
||||||
|
<UserAvatar userId={m.userId} username={m.username} size="xs" />
|
||||||
|
<span>{m.username}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</DemoTooltip>
|
||||||
|
{!isDemo && (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onRemove() }}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-error"
|
||||||
|
aria-label="Verwijder uit sprint"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -324,37 +321,46 @@ function DraggablePbiStoryRow({
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
onClick={!isDemo ? onAdd : undefined}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 px-6 py-2 border-b border-border/50 transition-colors',
|
'px-2 py-1',
|
||||||
!isDemo && 'cursor-pointer hover:bg-primary/5',
|
|
||||||
isDemo && 'opacity-60',
|
isDemo && 'opacity-60',
|
||||||
isDragging && 'opacity-40'
|
isDragging && 'opacity-40'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!isDemo && (
|
<div className={cn(
|
||||||
<span
|
'flex items-center gap-2 rounded border border-border px-3 py-2 transition-colors bg-surface-container',
|
||||||
{...attributes}
|
PRIORITY_BORDER[story.priority],
|
||||||
{...listeners}
|
)}>
|
||||||
aria-label="Sleep naar Sprint Backlog"
|
{!isDemo && (
|
||||||
className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 select-none text-sm"
|
<span
|
||||||
onClick={e => e.stopPropagation()}
|
{...attributes}
|
||||||
>
|
{...listeners}
|
||||||
⠿
|
aria-label="Sleep naar Sprint Backlog"
|
||||||
</span>
|
className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 select-none text-sm"
|
||||||
)}
|
>
|
||||||
<div className="flex-1 min-w-0">
|
⠿
|
||||||
<div className="flex items-start justify-between gap-2">
|
</span>
|
||||||
<p className="text-sm truncate flex-1">{story.title}</p>
|
)}
|
||||||
{story.code && <CodeBadge code={story.code} className="shrink-0 mt-0.5" />}
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="text-sm leading-snug line-clamp-2 flex-1">{story.title}</p>
|
||||||
|
{story.code && <CodeBadge code={story.code} className="shrink-0 mt-0.5" />}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<Badge className={cn('text-[10px] px-1.5 py-0 border', STATUS_COLORS[story.status])}>
|
||||||
|
{STATUS_LABELS[story.status]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge className={cn('text-[10px] px-1.5 py-0 border mt-0.5', STATUS_COLORS[story.status])}>
|
{!isDemo && (
|
||||||
{STATUS_LABELS[story.status]}
|
<button
|
||||||
</Badge>
|
onClick={onAdd}
|
||||||
|
className="text-xs text-primary hover:underline shrink-0"
|
||||||
|
>
|
||||||
|
+ Toevoegen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isDemo && (
|
|
||||||
<span className="text-xs text-muted-foreground shrink-0">+ toevoegen</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -367,7 +373,15 @@ interface SprintBacklogRightProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, onAdd }: SprintBacklogRightProps) {
|
export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, onAdd }: SprintBacklogRightProps) {
|
||||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
|
||||||
|
const auto = new Set<string>()
|
||||||
|
for (const pbi of pbisWithStories) {
|
||||||
|
if (pbi.stories.length > 0 && pbi.stories.every(s => s.status === 'DONE')) {
|
||||||
|
auto.add(pbi.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return auto
|
||||||
|
})
|
||||||
const { setNodeRef, isOver } = useDroppable({ id: 'backlog-zone' })
|
const { setNodeRef, isOver } = useDroppable({ id: 'backlog-zone' })
|
||||||
|
|
||||||
function toggle(pbiId: string) {
|
function toggle(pbiId: string) {
|
||||||
|
|
@ -378,9 +392,50 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, on
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collapseAll() {
|
||||||
|
setCollapsed(new Set(pbisWithStories.map(p => p.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandAll() {
|
||||||
|
setCollapsed(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
function onlyNotDone() {
|
||||||
|
const auto = new Set<string>()
|
||||||
|
for (const pbi of pbisWithStories) {
|
||||||
|
if (pbi.stories.length > 0 && pbi.stories.every(s => s.status === 'DONE')) {
|
||||||
|
auto.add(pbi.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCollapsed(auto)
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapseActions = (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger onClick={collapseAll} className="text-muted-foreground hover:text-foreground p-0.5 rounded" aria-label="Alles inklappen">
|
||||||
|
<ChevronsUp size={14} />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Alles inklappen</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger onClick={expandAll} className="text-muted-foreground hover:text-foreground p-0.5 rounded" aria-label="Alles uitklappen">
|
||||||
|
<ChevronsDown size={14} />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Alles uitklappen</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger onClick={onlyNotDone} className="text-muted-foreground hover:text-foreground p-0.5 rounded" aria-label="Alleen niet klaar">
|
||||||
|
<ListFilter size={14} />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Alleen niet klaar</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PanelNavBar title="Product Backlog" />
|
<PanelNavBar title="Product Backlog" actions={collapseActions} />
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -392,33 +447,39 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, on
|
||||||
<div key={pbi.id}>
|
<div key={pbi.id}>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggle(pbi.id)}
|
onClick={() => toggle(pbi.id)}
|
||||||
className="w-full flex items-center gap-2 px-4 py-1.5 hover:bg-surface-container transition-colors text-left"
|
className="w-full flex items-center gap-2 px-4 py-1.5 hover:bg-surface-container transition-colors text-left select-none"
|
||||||
>
|
>
|
||||||
<span className="text-xs">{collapsed.has(pbi.id) ? '▶' : '▼'}</span>
|
<span className="text-xs">{collapsed.has(pbi.id) ? '▶' : '▼'}</span>
|
||||||
<span className="text-sm font-medium truncate flex-1">{pbi.title}</span>
|
<span className="text-sm font-medium truncate flex-1">{pbi.title}</span>
|
||||||
{pbi.code && <CodeBadge code={pbi.code} />}
|
{pbi.code && <CodeBadge code={pbi.code} />}
|
||||||
<span className="text-xs text-muted-foreground">{pbi.stories.length}</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{pbi.stories.filter(s => s.status === 'DONE').length}/{pbi.stories.length} klaar
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!collapsed.has(pbi.id) && pbi.stories.map(story => {
|
{!collapsed.has(pbi.id) && pbi.stories.map(story => {
|
||||||
const inSprint = sprintStoryIds.has(story.id)
|
const inSprint = sprintStoryIds.has(story.id)
|
||||||
if (inSprint) {
|
if (inSprint) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={story.id} className="px-2 py-1 opacity-40">
|
||||||
key={story.id}
|
<div className={cn(
|
||||||
className="flex items-center gap-3 px-6 py-2 border-b border-border/50 opacity-40"
|
'flex items-center gap-2 rounded border border-border px-3 py-2 bg-surface-container',
|
||||||
>
|
PRIORITY_BORDER[story.priority]
|
||||||
<div className="w-[14px] shrink-0" />
|
)}>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="w-[14px] shrink-0" />
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm truncate flex-1">{story.title}</p>
|
<div className="flex items-start justify-between gap-2">
|
||||||
{story.code && <CodeBadge code={story.code} className="shrink-0 mt-0.5" />}
|
<p className="text-sm leading-snug line-clamp-2 flex-1">{story.title}</p>
|
||||||
|
{story.code && <CodeBadge code={story.code} className="shrink-0 mt-0.5" />}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<Badge className={cn('text-[10px] px-1.5 py-0 border', STATUS_COLORS[story.status])}>
|
||||||
|
{STATUS_LABELS[story.status]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge className={cn('text-[10px] px-1.5 py-0 border mt-0.5', STATUS_COLORS[story.status])}>
|
<span className="text-xs text-muted-foreground shrink-0">In Sprint</span>
|
||||||
{STATUS_LABELS[story.status]}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground shrink-0">In Sprint</span>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
|
||||||
|
|
||||||
{/* Complete sprint dialog */}
|
{/* Complete sprint dialog */}
|
||||||
<Dialog open={completeOpen} onOpenChange={setCompleteOpen}>
|
<Dialog open={completeOpen} onOpenChange={setCompleteOpen}>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Sprint afronden</DialogTitle>
|
<DialogTitle>Sprint afronden</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { Input } from '@/components/ui/input'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { CodeBadge } from '@/components/shared/code-badge'
|
import { CodeBadge } from '@/components/shared/code-badge'
|
||||||
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
|
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
|
||||||
|
import { PRIORITY_BORDER } from '@/components/backlog/backlog-card'
|
||||||
import { deriveTaskCode } from '@/lib/code'
|
import { deriveTaskCode } from '@/lib/code'
|
||||||
import { useSprintStore } from '@/stores/sprint-store'
|
import { useSprintStore } from '@/stores/sprint-store'
|
||||||
import {
|
import {
|
||||||
|
|
@ -37,7 +38,6 @@ const STATUS_COLORS: Record<string, string> = {
|
||||||
}
|
}
|
||||||
const STATUS_LABELS: Record<string, string> = { TO_DO: 'To Do', IN_PROGRESS: 'Bezig', DONE: 'Klaar' }
|
const STATUS_LABELS: Record<string, string> = { TO_DO: 'To Do', IN_PROGRESS: 'Bezig', DONE: 'Klaar' }
|
||||||
|
|
||||||
const PRIORITY_LABELS: Record<number, string> = { 1: 'Kritiek', 2: 'Hoog', 3: 'Gemiddeld', 4: 'Laag' }
|
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -76,45 +76,53 @@ function SortableTaskRow({
|
||||||
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} className="px-4 py-2 border-b border-border bg-surface-container">
|
<div ref={setNodeRef} style={style} className="px-2 py-1">
|
||||||
<form action={formAction} className="space-y-2">
|
<div className={cn('rounded border border-border px-3 py-2 bg-surface-container', PRIORITY_BORDER[task.priority])}>
|
||||||
<input type="hidden" name="id" value={task.id} />
|
<form action={formAction} className="space-y-2">
|
||||||
<input type="hidden" name="priority" value={task.priority} />
|
<input type="hidden" name="id" value={task.id} />
|
||||||
<Input name="title" defaultValue={task.title} className="h-7 text-sm" required autoFocus />
|
<input type="hidden" name="priority" value={task.priority} />
|
||||||
<div className="flex gap-2">
|
<Input name="title" defaultValue={task.title} className="h-7 text-sm" required autoFocus />
|
||||||
<EditSubmitButton />
|
<div className="flex gap-2">
|
||||||
<Button type="button" variant="ghost" size="sm" className="h-7" onClick={() => setEditing(false)}>Annuleren</Button>
|
<EditSubmitButton />
|
||||||
</div>
|
<Button type="button" variant="ghost" size="sm" className="h-7" onClick={() => setEditing(false)}>Annuleren</Button>
|
||||||
</form>
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} className="group flex items-start gap-3 px-4 py-2.5 border-b border-border hover:bg-surface-container/50 transition-colors">
|
<div ref={setNodeRef} style={style} className="group px-2 py-1">
|
||||||
{!isDemo && (
|
<div className={cn(
|
||||||
<span {...attributes} {...listeners} className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none mt-0.5">⠿</span>
|
'flex items-start gap-2 rounded border border-border px-3 py-2 transition-colors bg-surface-container hover:bg-surface-container-high',
|
||||||
)}
|
PRIORITY_BORDER[task.priority]
|
||||||
<div className="flex-1 min-w-0">
|
)}>
|
||||||
<div className="flex items-start justify-between gap-2">
|
{!isDemo && (
|
||||||
<p className={cn('text-sm truncate flex-1', task.status === 'DONE' && 'line-through text-muted-foreground')}>
|
<span {...attributes} {...listeners} className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none mt-0.5">⠿</span>
|
||||||
{task.title}
|
)}
|
||||||
</p>
|
<div className="flex-1 min-w-0">
|
||||||
{code && <CodeBadge code={code} className="shrink-0 mt-0.5" />}
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className={cn('text-sm leading-snug line-clamp-2 flex-1', task.status === 'DONE' && 'line-through text-muted-foreground')}>
|
||||||
|
{task.title}
|
||||||
|
</p>
|
||||||
|
{code && <CodeBadge code={code} className="shrink-0 mt-0.5" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2 mt-1.5">
|
||||||
|
<button onClick={onStatusToggle} disabled={isDemo} aria-label={`Status: ${STATUS_LABELS[task.status]}`}>
|
||||||
|
<Badge className={cn('text-[10px] px-1.5 py-0 border cursor-pointer hover:opacity-80 transition-opacity', STATUS_COLORS[task.status])}>
|
||||||
|
{STATUS_LABELS[task.status]}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
{!isDemo && (
|
||||||
|
<div className="opacity-0 group-hover:opacity-100 flex gap-1 shrink-0">
|
||||||
|
<button onClick={() => setEditing(true)} className="text-xs text-muted-foreground hover:text-foreground">Bewerk</button>
|
||||||
|
<button onClick={onDelete} aria-label="Verwijder taak" className="text-xs text-muted-foreground hover:text-error">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">{PRIORITY_LABELS[task.priority]}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onStatusToggle} disabled={isDemo} aria-label={`Status: ${STATUS_LABELS[task.status]}`} className="mt-0.5">
|
|
||||||
<Badge className={cn('text-xs border cursor-pointer hover:opacity-80 transition-opacity', STATUS_COLORS[task.status])}>
|
|
||||||
{STATUS_LABELS[task.status]}
|
|
||||||
</Badge>
|
|
||||||
</button>
|
|
||||||
{!isDemo && (
|
|
||||||
<div className="opacity-0 group-hover:opacity-100 flex gap-1 mt-0.5">
|
|
||||||
<button onClick={() => setEditing(true)} className="text-xs text-muted-foreground hover:text-foreground">Bewerk</button>
|
|
||||||
<button onClick={onDelete} aria-label="Verwijder taak" className="text-xs text-muted-foreground hover:text-error">×</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -251,7 +259,10 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId,
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
{activeDragId && taskMap[activeDragId] && (
|
{activeDragId && taskMap[activeDragId] && (
|
||||||
<div className="bg-surface-container-low border border-primary rounded px-4 py-2 text-sm shadow-lg opacity-90">
|
<div className={cn(
|
||||||
|
'rounded border border-primary px-3 py-2 bg-surface-container shadow-lg opacity-90 text-sm',
|
||||||
|
PRIORITY_BORDER[taskMap[activeDragId].priority]
|
||||||
|
)}>
|
||||||
{taskMap[activeDragId].title}
|
{taskMap[activeDragId].title}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
161
docs/plans/M9-active-product-backlog.md
Normal file
161
docs/plans/M9-active-product-backlog.md
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
# M9 — Actief Product Backlog
|
||||||
|
|
||||||
|
Eén "actief Product Backlog" per gebruiker, persistent op `User.active_product_id`. NavBar wordt: Producten | Product Backlog | Sprint | Solo | Todo's. Zonder actief PB zijn Backlog/Sprint/Solo disabled. Sprint is alleen klikbaar als er een sprint met status `ACTIVE` bestaat. Vervangt de bestaande `last_product`-cookieflow.
|
||||||
|
|
||||||
|
Backlog-entries: zie [scrum4me-backlog.md § M9](../scrum4me-backlog.md#m9-actief-product-backlog).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ST-901 — Database `user.active_product_id`
|
||||||
|
|
||||||
|
> Status: voltooid in commit `dad9a80`.
|
||||||
|
|
||||||
|
**Bestanden**
|
||||||
|
- `prisma/schema.prisma` — model `User` uitgebreid + named relation
|
||||||
|
- `prisma/migrations/20260427165329_add_user_active_product_id/migration.sql` — migratie
|
||||||
|
|
||||||
|
**Stappen**
|
||||||
|
1. Op `User`: `active_product_id String? @db.Uuid` + relatie `active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)` + `@@index([active_product_id])`.
|
||||||
|
2. Op `Product`: tegenrelatie `active_for_users User[] @relation("UserActiveProduct")` (anders conflicteert het met de bestaande `Product.user_id`-relatie).
|
||||||
|
3. `npx prisma migrate dev --name add_user_active_product_id`.
|
||||||
|
|
||||||
|
**Aandachtspunten**
|
||||||
|
- `vendor/scrum4me`-submodule in repo `scrum4me-mcp` heeft hetzelfde schema. Na merge moet daar `prisma generate && tsc --noEmit` slagen, anders breekt de wekelijkse drift-check (`trig_015FFUnxjz9WMuhhWNGBQKFD`).
|
||||||
|
- Geen seed-wijziging nodig — `null` is correcte initiële staat.
|
||||||
|
|
||||||
|
**Verificatie**
|
||||||
|
- `npx prisma migrate dev` slaagt
|
||||||
|
- `npx prisma validate` zonder fouten
|
||||||
|
- `prisma studio` toont kolom
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ST-902 — Server Actions: actief product zetten/wissen + auto-clear
|
||||||
|
|
||||||
|
**Bestanden**
|
||||||
|
- `actions/active-product.ts` — nieuw, twee Server Actions
|
||||||
|
- `actions/products.ts` — uitbreiden bij `archiveProductAction`
|
||||||
|
- `actions/product-members.ts` — uitbreiden bij `leaveProductAction` en `removeMemberAction` (locatie verifiëren met grep)
|
||||||
|
- `__tests__/actions/active-product.test.ts` — nieuw
|
||||||
|
|
||||||
|
**Stappen**
|
||||||
|
|
||||||
|
1. **`setActiveProductAction({ productId })`** in `actions/active-product.ts`:
|
||||||
|
- Volg `docs/patterns/server-action.md`
|
||||||
|
- Zod: `z.object({ productId: z.string().uuid() })`
|
||||||
|
- `getSession()` → 401 bij geen sessie
|
||||||
|
- **Demo-guard**: `if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus.' }`
|
||||||
|
- Toegangscheck: `prisma.product.findFirst({ where: { id: productId, archived: false, ...productAccessFilter(userId) } })` → `null` levert `{ ok: false, error: 'Product niet gevonden of geen toegang.' }`
|
||||||
|
- `prisma.user.update({ where: { id: userId }, data: { active_product_id: productId } })`
|
||||||
|
- `revalidatePath('/', 'layout')` — laat NavBar in alle routes opnieuw renderen
|
||||||
|
- Return `{ ok: true }`
|
||||||
|
|
||||||
|
2. **`clearActiveProductAction()`** in hetzelfde bestand:
|
||||||
|
- Geen input
|
||||||
|
- `getSession()` + demo-guard
|
||||||
|
- `prisma.user.update({ where: { id: userId }, data: { active_product_id: null } })`
|
||||||
|
- `revalidatePath('/', 'layout')`
|
||||||
|
|
||||||
|
3. **Auto-clear bij toegangsverlies** — drie call-sites uitbreiden ná de hoofdmutatie:
|
||||||
|
- `archiveProductAction(productId)`: `prisma.user.updateMany({ where: { active_product_id: productId }, data: { active_product_id: null } })`
|
||||||
|
- `leaveProductAction(productId)`: `prisma.user.updateMany({ where: { id: userId, active_product_id: productId }, data: { active_product_id: null } })`
|
||||||
|
- `removeMemberAction(productId, removedUserId)`: `prisma.user.updateMany({ where: { id: removedUserId, active_product_id: productId }, data: { active_product_id: null } })`
|
||||||
|
- Eigenaarsverwijdering van een product wordt door FK `onDelete: SetNull` automatisch geregeld — geen extra code
|
||||||
|
|
||||||
|
4. **Tests** — `__tests__/actions/active-product.test.ts`:
|
||||||
|
- setActive met onbekend product → `{ ok: false }`
|
||||||
|
- setActive met archived product → `{ ok: false }`
|
||||||
|
- setActive met product zonder access → `{ ok: false }`
|
||||||
|
- setActive happy path → `users.active_product_id` gezet
|
||||||
|
- Demo-user setActive → error + geen DB-mutatie
|
||||||
|
- archiveProductAction op actief product → `active_product_id` gecleared voor alle eigenaren/leden
|
||||||
|
|
||||||
|
**Aandachtspunten**
|
||||||
|
- Race-condition: setActive winnen ná auto-clear kan voorkomen. Layout-guard in ST-903 vangt dit op bij volgende request.
|
||||||
|
- `revalidatePath('/', 'layout')` is correct — niet `revalidatePath('/dashboard')` (NavBar zit in root layout van `(app)`).
|
||||||
|
- Geen `productAccessFilter` op `clearActiveProductAction` — eigen keuze wissen mag altijd.
|
||||||
|
|
||||||
|
**Verificatie**
|
||||||
|
- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen
|
||||||
|
- Handmatig: 2 users — A archiveert product, `users.active_product_id` van B wordt `null` in DB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ST-903 — App-layout actief product + redirects
|
||||||
|
|
||||||
|
**Bestanden**
|
||||||
|
- `app/(app)/layout.tsx` — uitbreiden met activeProduct-fetch + guard
|
||||||
|
- `app/(app)/solo/page.tsx` — cookie-flow vervangen
|
||||||
|
- `lib/cookies.ts` — `getLastProductCookie` / `setLastProductCookie` verwijderen
|
||||||
|
- `components/shared/nav-bar.tsx` — nieuwe prop `activeProduct` accepteren (verdere UI-uitwerking in ST-904)
|
||||||
|
- `components/solo/product-picker.tsx` — checken of nog gebruikt; anders weg
|
||||||
|
|
||||||
|
**Stappen**
|
||||||
|
|
||||||
|
1. **`app/(app)/layout.tsx`**:
|
||||||
|
- User-query uitbreiden:
|
||||||
|
```ts
|
||||||
|
prisma.user.findUnique({
|
||||||
|
where: { id: session.userId },
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
active_product_id: true,
|
||||||
|
active_product: { select: { id: true, name: true, archived: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
- **Guard**: als `user.active_product_id` is gezet maar (`active_product === null` of `active_product.archived === true` of geen toegang via `productAccessFilter`):
|
||||||
|
- `prisma.user.update(... active_product_id: null)` server-side
|
||||||
|
- `redirect('/dashboard?notice=active-cleared')`
|
||||||
|
- `<NavBar activeProduct={user.active_product ?? null} ... />` als nieuwe prop
|
||||||
|
|
||||||
|
2. **`app/(app)/solo/page.tsx`** — vervang volledig:
|
||||||
|
```ts
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session.userId) redirect('/login')
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.userId },
|
||||||
|
select: { active_product_id: true },
|
||||||
|
})
|
||||||
|
if (!user?.active_product_id) redirect('/dashboard?notice=no-active')
|
||||||
|
redirect(`/products/${user.active_product_id}/solo`)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **`lib/cookies.ts`**: verwijder `getLastProductCookie` en `setLastProductCookie`. Grep alle call-sites en pas aan/verwijder.
|
||||||
|
|
||||||
|
4. **Toast-handling** (server-redirect → client toast):
|
||||||
|
- Klein client-component `<NoticeToast />` dat `useSearchParams` leest, `toast()` aanroept, querystring strippt via `router.replace(pathname)`
|
||||||
|
- Plaats in `app/(app)/dashboard/page.tsx` (of layout) — alleen geactiveerde notices afhandelen
|
||||||
|
- Twee waarden: `active-cleared` → "Je actieve product is niet meer beschikbaar."; `no-active` → "Selecteer eerst een actief product."
|
||||||
|
|
||||||
|
**Aandachtspunten**
|
||||||
|
- Layout-guard draait per request (extra DB-query). Houd 'm in dezelfde Promise.all met de bestaande user/userRoles-fetch.
|
||||||
|
- ProductPicker-fallback verdwijnt — switcher gebeurt in ST-904 via NavBar-dropdown.
|
||||||
|
- `app/(app)/solo/page.tsx` blijft Server Component — alleen `redirect()` van `next/navigation`.
|
||||||
|
- Een vorm van de cookie-helper kan ook door andere code gebruikt worden — verifieer de grep zorgvuldig vóór je verwijdert.
|
||||||
|
|
||||||
|
**Verificatie**
|
||||||
|
- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen
|
||||||
|
- Login zonder active → NavBar krijgt `activeProduct={null}`
|
||||||
|
- Login met active → NavBar krijgt object met id/name
|
||||||
|
- Bezoek `/solo` met active → redirect naar `/products/[id]/solo` zonder cookie
|
||||||
|
- Archiveer actief product (script of via andere user) → bij volgende request layout cleart, toast op `/dashboard`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ST-904 — NavBar splits + disabled-states + switcher
|
||||||
|
|
||||||
|
> Plan nog te schrijven.
|
||||||
|
|
||||||
|
## ST-905 — Producten-scherm Activeer-knop
|
||||||
|
|
||||||
|
> Plan nog te schrijven.
|
||||||
|
|
||||||
|
## ST-906 — Edge cases — toegangsverlies en archivering
|
||||||
|
|
||||||
|
> Plan nog te schrijven.
|
||||||
|
|
||||||
|
## ST-907 — Documentatie en tests
|
||||||
|
|
||||||
|
> Plan nog te schrijven.
|
||||||
|
|
@ -25,7 +25,7 @@ De MVP is klaar wanneer Lars — de primaire persona — de volledige cyclus kan
|
||||||
| M6: Polish & Launch-ready | Foutafhandeling, toegankelijkheid, CI/CD, beveiliging | ST-601 – ST-612 |
|
| M6: Polish & Launch-ready | Foutafhandeling, toegankelijkheid, CI/CD, beveiliging | ST-601 – ST-612 |
|
||||||
| M7: MCP-server voor Claude Code | Native MCP-laag bovenop Scrum4Me-DB (aparte repo `scrum4me-mcp`) | ST-701 – ST-710 |
|
| M7: MCP-server voor Claude Code | Native MCP-laag bovenop Scrum4Me-DB (aparte repo `scrum4me-mcp`) | ST-701 – ST-710 |
|
||||||
| M8: Realtime Solo Paneel | Live updates voor stories/tasks via SSE + Postgres LISTEN/NOTIFY | ST-801 – ST-806 |
|
| M8: Realtime Solo Paneel | Live updates voor stories/tasks via SSE + Postgres LISTEN/NOTIFY | ST-801 – ST-806 |
|
||||||
|
| M9: Actief Product Backlog | Persistente actieve PB-keuze, gesplitste navigatie, disabled-states | ST-901 – ST-907 |
|
||||||
---
|
---
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
@ -524,32 +524,66 @@ Transport: Server-Sent Events (Vercel ondersteunt geen stateful WebSockets). Bro
|
||||||
|
|
||||||
Filtering server-side: alleen events binnen de actieve sprint van een product waar de gebruiker eigenaar of lid van is, plus `assignee_id == userId` (eigen kolommen) of `assignee_id IS NULL` (claim-lijst).
|
Filtering server-side: alleen events binnen de actieve sprint van een product waar de gebruiker eigenaar of lid van is, plus `assignee_id == userId` (eigen kolommen) of `assignee_id IS NULL` (claim-lijst).
|
||||||
|
|
||||||
- [ ] **ST-801** Postgres LISTEN/NOTIFY-infrastructuur
|
- [x] **ST-801** Postgres LISTEN/NOTIFY-infrastructuur
|
||||||
- Migratie met `notify_solo_change()`-functie + `AFTER INSERT/UPDATE/DELETE`-triggers op `tasks` en `stories`; payload bevat `op`, `entity`, `id`, `product_id`, `sprint_id`, `assignee_id`, `fields` (gewijzigde kolommen)
|
- Migratie met `notify_solo_change()`-functie + `AFTER INSERT/UPDATE/DELETE`-triggers op `tasks` en `stories`; payload bevat `op`, `entity`, `id`, `product_id`, `sprint_id`, `assignee_id`, `fields` (gewijzigde kolommen)
|
||||||
- Done when: `psql $DIRECT_URL -c "LISTEN scrum4me_solo;"` toont een payload bij een UI-mutatie
|
- Done when: `psql $DIRECT_URL -c "LISTEN scrum4me_solo;"` toont een payload bij een UI-mutatie
|
||||||
|
|
||||||
- [ ] **ST-802** SSE-route `/api/realtime/solo`
|
- [x] **ST-802** SSE-route `/api/realtime/solo`
|
||||||
- `app/api/realtime/solo/route.ts`, `runtime: 'nodejs'`, `maxDuration: 300`; auth via iron-session, query-param `product_id`, opent `pg.Client` op `DIRECT_URL` met `LISTEN`; heartbeat 25s; hard close 240s; in-handler filtering op product/sprint/assignee
|
- `app/api/realtime/solo/route.ts`, `runtime: 'nodejs'`, `maxDuration: 300`; auth via iron-session, query-param `product_id`, opent `pg.Client` op `DIRECT_URL` met `LISTEN`; heartbeat 25s; hard close 240s; in-handler filtering op product/sprint/assignee
|
||||||
- Done when: `curl -N` op localhost levert binnen 1s een event op na een task-mutatie via UI
|
- Done when: `curl -N` op localhost levert binnen 1s een event op na een task-mutatie via UI
|
||||||
|
|
||||||
- [ ] **ST-803** Client hook `useSoloRealtime(productId)`
|
- [x] **ST-803** Client hook `useSoloRealtime(productId)`
|
||||||
- `lib/realtime/use-solo-realtime.ts`; opent `EventSource`, exponential backoff reconnect (1s → 30s); Page Visibility API voor pauseren/hervatten; cleanup op unmount
|
- `lib/realtime/use-solo-realtime.ts`; opent `EventSource`, exponential backoff reconnect (1s → 30s); Page Visibility API voor pauseren/hervatten; cleanup op unmount
|
||||||
- Done when: tab wisselen sluit/opent connectie zichtbaar in DevTools Network
|
- Done when: tab wisselen sluit/opent connectie zichtbaar in DevTools Network
|
||||||
|
|
||||||
- [ ] **ST-804** Solo-store realtime-acties
|
- [x] **ST-804** Solo-store realtime-acties
|
||||||
- `applyTaskUpdate`, `applyTaskCreate`, `applyTaskDelete`, `applyStoryAssignment`, `markPending`/`clearPending` om eigen optimistic-echo te onderdrukken
|
- `applyTaskUpdate`, `applyTaskCreate`, `applyTaskDelete`, `applyStoryAssignment`, `markPending`/`clearPending` om eigen optimistic-echo te onderdrukken
|
||||||
- Done when: unit-test op solo-store met gesimuleerde events laat juiste eindstate zien
|
- Done when: unit-test op solo-store met gesimuleerde events laat juiste eindstate zien
|
||||||
|
|
||||||
- [ ] **ST-805** Wire-up in SoloBoard + UI-indicator
|
- [x] **ST-805** Wire-up in SoloBoard + UI-indicator
|
||||||
- `components/solo/solo-board.tsx` roept de hook aan; klein "live"/"verbinden..."-statusindicator; toast bij langer dan 5s disconnected
|
- `components/solo/solo-board.tsx` roept de hook aan; klein "live"/"verbinden..."-statusindicator; toast bij langer dan 5s disconnected
|
||||||
- Done when: twee tabs van Solo Paneel — mutatie in tab A komt binnen 1–2s in tab B zonder refresh
|
- Done when: twee tabs van Solo Paneel — mutatie in tab A komt binnen 1–2s in tab B zonder refresh
|
||||||
|
|
||||||
- [ ] **ST-806** Documentatie + acceptatietest
|
- [x] **ST-806** Documentatie + acceptatietest
|
||||||
- Sectie "Realtime updates" in `docs/scrum4me-architecture.md` met diagram en filtering-regels; vermelding in `CLAUDE.md`; korte note over `/api/realtime/solo` in `docs/API.md`; handmatig E2E-scenario's gedraaid (zelfde gebruiker twee tabs, MCP-write, REST-write, story-claim, network-flap)
|
- Sectie "Realtime updates" in `docs/scrum4me-architecture.md` met diagram en filtering-regels; vermelding in `CLAUDE.md`; korte note over `/api/realtime/solo` in `docs/API.md`; handmatig E2E-scenario's gedraaid (zelfde gebruiker twee tabs, MCP-write, REST-write, story-claim, network-flap)
|
||||||
- Done when: alle scenario's lopen door zonder onverwachte gedragingen
|
- Done when: alle scenario's lopen door zonder onverwachte gedragingen
|
||||||
|
|
||||||
Volledig plan in `.Plans/2026-04-27-m8-realtime-solo.md` (lokaal, niet gecommit).
|
Volledig plan in `.Plans/2026-04-27-m8-realtime-solo.md` (lokaal, niet gecommit).
|
||||||
|
|
||||||
|
### M9: Actief Product Backlog
|
||||||
|
|
||||||
|
**Implementatieplan:** [docs/plans/M9-active-product-backlog.md](plans/M9-active-product-backlog.md)
|
||||||
|
|
||||||
|
Eén "actief Product Backlog" per gebruiker — persistent in DB. De NavBar wordt gesplitst in **Producten** (lijst) en **Product Backlog** (PB-view van actief PB), met **Sprint** en **Solo** als aparte tabs die op het actieve PB werken. Geen actief PB → die drie tabs zijn disabled. Vervangt de bestaande `last_product`-cookieflow.
|
||||||
|
|
||||||
|
- [x] **ST-901** Database — `user.active_product_id`
|
||||||
|
- Voeg `active_product_id String? @db.Uuid` toe aan `User` met FK naar `Product.id` en `onDelete: SetNull`; migratie `add_user_active_product_id`; index op `active_product_id` voor join-performance
|
||||||
|
- Done when: `npx prisma migrate dev` slaagt; `prisma studio` toont kolom; `npx prisma validate` zonder fouten; submodule `vendor/scrum4me` in scrum4me-mcp draait `prisma generate` + `tsc --noEmit` zonder fouten
|
||||||
|
|
||||||
|
- [x] **ST-902** Server Actions — actief product zetten en wissen
|
||||||
|
- `actions/active-product.ts` met `setActiveProduct(productId)` en `clearActiveProduct()`; Zod + auth + `productAccessFilter`; demo-gebruikers mogen wisselen (sessie-effect alleen, geen DB-write); `archiveProduct` en `leaveProduct` zetten `active_product_id` op `null` als het hetzelfde product betreft
|
||||||
|
- Done when: setActive met onbekend/onbereikbaar product → 422; archiveren van actief product clearet de keuze; demo-flow geeft toast "Niet beschikbaar in demo-modus"
|
||||||
|
|
||||||
|
- [x] **ST-903** App-layout laadt actief product + redirects
|
||||||
|
- `app/(app)/layout.tsx` haalt `activeProduct` (id, name, archived) op naast user; geef door aan `NavBar`; `app/(app)/solo/page.tsx` gebruikt `user.active_product_id` i.p.v. `getLastProductCookie`; helper `lib/cookies.ts:getLastProductCookie` markeren deprecated of verwijderen plus call-sites opruimen
|
||||||
|
- Done when: ingelogd zonder actief PB toont NavBar zonder geactiveerde tabs; met actief PB redirect `/solo` → `/products/[active]/solo` zonder cookie te raadplegen
|
||||||
|
|
||||||
|
- [x] **ST-904** NavBar — splits + disabled-states + switcher
|
||||||
|
- Tabs worden: **Producten** (`/dashboard`) | **Product Backlog** (`/products/[active]`) | **Sprint** (`/products/[active]/sprint`) | **Solo** (`/products/[active]/solo`) | **Todo's** (`/todos`); zonder actief PB zijn de middelste drie disabled-spans (zelfde stijl als huidige Sprint-disabled); productnaam in midden wordt een dropdown-trigger (shadcn `DropdownMenu`) met je producten + "Producten beheren →"; Sprint krijgt `aria-disabled` + tooltip "Geen actieve sprint" als er geen sprint met status `ACTIVE` is
|
||||||
|
- Done when: handmatige test: zonder PB drie tabs grijs; activeer PB → tabs klikbaar; dropdown wisselt PB en redirect naar Product Backlog; Sprint-tab disabled tot sprint gestart
|
||||||
|
|
||||||
|
- [x] **ST-905** Producten-scherm — Activeer-knop per rij
|
||||||
|
- `components/dashboard/product-list.tsx`: per rij "Activeer"-knop (verborgen voor reeds actief PB); actieve rij krijgt badge "Actief" (MD3-token `bg-primary-container`); klik op Activeer → `setActiveProduct` + `router.push('/products/[id]')`; ook in `/products/[id]` header een Activeer-knop als dat product nog niet actief is
|
||||||
|
- Done when: activeer in dashboard markeert juiste rij + landt op Product Backlog; demo-gebruiker krijgt toast en geen DB-mutatie
|
||||||
|
|
||||||
|
- [x] **ST-906** Edge cases — toegangsverlies en archivering
|
||||||
|
- Wanneer een PB wordt gearchiveerd, ge-leaved, of een productmember wordt verwijderd: `active_product_id` automatisch `null` voor betroffen users (server actions van `archiveProduct`, `leaveProduct`, `removeMember`); guard in `app/(app)/layout.tsx`: als `active_product_id` is gezet maar product is archived/onbereikbaar, server-side clear + redirect naar `/dashboard` met toast "Je actieve product is niet meer beschikbaar"
|
||||||
|
- Done when: scenario test — eigenaar archiveert → membership-gebruikers landen op dashboard met toast en active is gecleared
|
||||||
|
|
||||||
|
- [x] **ST-907** Documentatie en tests
|
||||||
|
- Functional spec: nieuw hoofdstuk "Actief Product Backlog" (concept, menugedrag, edge cases); README: navigatie-screenshot bijwerken; `docs/patterns/` indien nieuwe patroon (n.v.t. tenzij dropdown-switcher een herbruikbaar component wordt); jest-tests in `__tests__/actions/active-product.test.ts` voor setActive (toegang, demo, archived); Playwright/manueel scenario: log in → activeer PB → wissel via dropdown → archiveer → verifieer auto-clear
|
||||||
|
- Done when: `npm run lint && npx tsc --noEmit && npm test && npm run build` groen; spec-secties geschreven; `vendor/scrum4me`-submodule in scrum4me-mcp gesynced
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v2 Backlog (na MVP)
|
## v2 Backlog (na MVP)
|
||||||
|
|
|
||||||
|
|
@ -529,3 +529,35 @@ De app is deployable op Vercel + Neon PostgreSQL en lokaal draaibaar met een Neo
|
||||||
6. Lars navigeert naar de Product Backlog → story staat in de juiste prioriteitsgroep
|
6. Lars navigeert naar de Product Backlog → story staat in de juiste prioriteitsgroep
|
||||||
|
|
||||||
**Resultaat:** Losse gedachte is in drie stappen onderdeel van de formele Product Backlog.
|
**Resultaat:** Losse gedachte is in drie stappen onderdeel van de formele Product Backlog.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Actief Product Backlog
|
||||||
|
|
||||||
|
### Concept
|
||||||
|
|
||||||
|
Een gebruiker kan één product als "actief" markeren. Dit actieve product wordt in de NavBar centraal getoond en bepaalt welke tabs (Product Backlog, Sprint, Solo) navigeerbaar zijn. Het actieve product wordt opgeslagen in `user.active_product_id` in de database — niet in een cookie.
|
||||||
|
|
||||||
|
### Menugedrag
|
||||||
|
|
||||||
|
- **Producten** — altijd bereikbaar, toont alle producten van de gebruiker
|
||||||
|
- **Product Backlog** — alleen klikbaar als er een actief product is
|
||||||
|
- **Sprint** — alleen klikbaar als er een actief product is én een actieve sprint bestaat; anders tooltip "Geen actieve sprint"
|
||||||
|
- **Solo** — alleen klikbaar als er een actief product is
|
||||||
|
- **Todo's** — altijd bereikbaar
|
||||||
|
|
||||||
|
In het midden van de NavBar staat een dropdown met de naam van het actieve product. Via deze dropdown kan de gebruiker wisselen tussen producten of naar "Producten beheren" navigeren.
|
||||||
|
|
||||||
|
### Activeren
|
||||||
|
|
||||||
|
- **Dashboard**: elke productrij toont een "Activeer"-knop (verborgen voor het al actieve product). Het actieve product krijgt een "Actief"-badge. Klikken → actief product instellen + navigeer naar Product Backlog.
|
||||||
|
- **Product Backlog header**: als dit product nog niet actief is, staat er een "Activeer"-knop in de header.
|
||||||
|
|
||||||
|
Demo-gebruikers zien de knoppen maar krijgen een toast "Niet beschikbaar in demo-modus" bij het klikken.
|
||||||
|
|
||||||
|
### Edge cases
|
||||||
|
|
||||||
|
- **Archiveren**: wanneer een eigenaar een product archiveert, wordt `active_product_id` voor alle leden die dit product actief hadden automatisch op `null` gezet (atomisch via `$transaction`).
|
||||||
|
- **Product verlaten**: wanneer een lid het product verlaat, wordt hun `active_product_id` gecleard.
|
||||||
|
- **Lid verwijderen**: wanneer een eigenaar een lid verwijdert, wordt dat lid's `active_product_id` gecleard.
|
||||||
|
- **Stale referentie**: als bij een request `active_product_id` verwijst naar een gearchiveerd of onbereikbaar product (bijv. toegang ingetrokken in een andere sessie), cleared de layout de referentie server-side en redirect naar `/dashboard` met de toast "Je actieve product is niet meer beschikbaar".
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"name": "scrum4me",
|
"name": "scrum4me",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "lsof -ti:3000 | xargs kill -9 2>/dev/null || true",
|
"predev": "npx --yes kill-port 3000 || exit 0",
|
||||||
"dev": "concurrently \"next dev -p 3000\" \"npm run db:erd:watch\"",
|
"dev": "next dev -p 3000",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "active_product_id" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "users_active_product_id_idx" ON "users"("active_product_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "users" ADD CONSTRAINT "users_active_product_id_fkey" FOREIGN KEY ("active_product_id") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
@ -47,23 +47,26 @@ enum SprintStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
username String @unique
|
username String @unique
|
||||||
email String? @unique
|
email String? @unique
|
||||||
password_hash String
|
password_hash String
|
||||||
is_demo Boolean @default(false)
|
is_demo Boolean @default(false)
|
||||||
bio String? @db.VarChar(160)
|
bio String? @db.VarChar(160)
|
||||||
bio_detail String? @db.VarChar(2000)
|
bio_detail String? @db.VarChar(2000)
|
||||||
avatar_data Bytes?
|
avatar_data Bytes?
|
||||||
created_at DateTime @default(now())
|
active_product_id String?
|
||||||
updated_at DateTime @updatedAt
|
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
|
||||||
roles UserRole[]
|
created_at DateTime @default(now())
|
||||||
api_tokens ApiToken[]
|
updated_at DateTime @updatedAt
|
||||||
products Product[]
|
roles UserRole[]
|
||||||
todos Todo[]
|
api_tokens ApiToken[]
|
||||||
product_members ProductMember[]
|
products Product[]
|
||||||
assigned_stories Story[] @relation("StoryAssignee")
|
todos Todo[]
|
||||||
|
product_members ProductMember[]
|
||||||
|
assigned_stories Story[] @relation("StoryAssignee")
|
||||||
|
|
||||||
|
@@index([active_product_id])
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,6 +110,7 @@ model Product {
|
||||||
stories Story[]
|
stories Story[]
|
||||||
todos Todo[]
|
todos Todo[]
|
||||||
members ProductMember[]
|
members ProductMember[]
|
||||||
|
active_for_users User[] @relation("UserActiveProduct")
|
||||||
|
|
||||||
@@unique([user_id, name])
|
@@unique([user_id, name])
|
||||||
@@unique([user_id, code])
|
@@unique([user_id, code])
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export type ParsedMilestone = {
|
||||||
stories: ParsedStory[]
|
stories: ParsedStory[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const MILESTONE_HEADER = /^### (M[\d.]+):\s*(.+?)\s*$/
|
const MILESTONE_HEADER = /^### (M[\d.]+|PBI-\d+):\s*(.+?)\s*$/
|
||||||
const TASK_BULLET = /^- \[(x| )\] \*\*(ST-\d+)\*\*\s+(.+?)\s*$/
|
const TASK_BULLET = /^- \[(x| )\] \*\*(ST-\d+)\*\*\s+(.+?)\s*$/
|
||||||
const SUB_BULLET = /^ {2}- (.+?)\s*$/
|
const SUB_BULLET = /^ {2}- (.+?)\s*$/
|
||||||
const NESTED_LINE = /^ {4,}\S/
|
const NESTED_LINE = /^ {4,}\S/
|
||||||
|
|
@ -72,7 +72,7 @@ const MILESTONE_SPRINT_STATUS: Record<string, ParsedMilestone['sprint_status']>
|
||||||
M8: 'COMPLETED',
|
M8: 'COMPLETED',
|
||||||
}
|
}
|
||||||
|
|
||||||
const MILESTONE_KEY = /^M[\d.]+$/
|
const MILESTONE_KEY = /^(?:M[\d.]+|PBI-\d+)$/
|
||||||
|
|
||||||
type SubBullet = {
|
type SubBullet = {
|
||||||
headLine: string
|
headLine: string
|
||||||
|
|
|
||||||
18
proxy.ts
18
proxy.ts
|
|
@ -5,9 +5,6 @@ import { sessionOptions } from '@/lib/session'
|
||||||
const protectedRoutes = ['/dashboard', '/products', '/todos', '/settings', '/solo']
|
const protectedRoutes = ['/dashboard', '/products', '/todos', '/settings', '/solo']
|
||||||
const authRoutes = ['/login', '/register']
|
const authRoutes = ['/login', '/register']
|
||||||
|
|
||||||
const SOLO_ROUTE = /^\/products\/([^/]+)\/solo$/
|
|
||||||
const THIRTY_DAYS_SECONDS = 60 * 60 * 24 * 30
|
|
||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
export function proxy(request: NextRequest) {
|
||||||
const path = request.nextUrl.pathname
|
const path = request.nextUrl.pathname
|
||||||
const isProtected = protectedRoutes.some(r => path.startsWith(r))
|
const isProtected = protectedRoutes.some(r => path.startsWith(r))
|
||||||
|
|
@ -24,20 +21,7 @@ export function proxy(request: NextRequest) {
|
||||||
return NextResponse.redirect(new URL('/dashboard', request.url))
|
return NextResponse.redirect(new URL('/dashboard', request.url))
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = NextResponse.next()
|
return NextResponse.next()
|
||||||
|
|
||||||
// Remember last visited product for /solo redirect
|
|
||||||
const soloMatch = path.match(SOLO_ROUTE)
|
|
||||||
if (soloMatch) {
|
|
||||||
response.cookies.set('lastProductId', soloMatch[1], {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: THIRTY_DAYS_SECONDS,
|
|
||||||
path: '/',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue