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:
Janpeter Visser 2026-04-27 20:25:13 +02:00 committed by GitHub
parent c1c219639a
commit 88dca4102c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1184 additions and 481 deletions

View file

@ -17,6 +17,7 @@ import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { CodeBadge } from '@/components/shared/code-badge'
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
import { PRIORITY_BORDER } from '@/components/backlog/backlog-card'
import { deriveTaskCode } from '@/lib/code'
import { useSprintStore } from '@/stores/sprint-store'
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 PRIORITY_LABELS: Record<number, string> = { 1: 'Kritiek', 2: 'Hoog', 3: 'Gemiddeld', 4: 'Laag' }
export interface Task {
id: string
@ -76,45 +76,53 @@ function SortableTaskRow({
if (editing) {
return (
<div ref={setNodeRef} style={style} className="px-4 py-2 border-b border-border bg-surface-container">
<form action={formAction} className="space-y-2">
<input type="hidden" name="id" value={task.id} />
<input type="hidden" name="priority" value={task.priority} />
<Input name="title" defaultValue={task.title} className="h-7 text-sm" required autoFocus />
<div className="flex gap-2">
<EditSubmitButton />
<Button type="button" variant="ghost" size="sm" className="h-7" onClick={() => setEditing(false)}>Annuleren</Button>
</div>
</form>
<div ref={setNodeRef} style={style} className="px-2 py-1">
<div className={cn('rounded border border-border px-3 py-2 bg-surface-container', PRIORITY_BORDER[task.priority])}>
<form action={formAction} className="space-y-2">
<input type="hidden" name="id" value={task.id} />
<input type="hidden" name="priority" value={task.priority} />
<Input name="title" defaultValue={task.title} className="h-7 text-sm" required autoFocus />
<div className="flex gap-2">
<EditSubmitButton />
<Button type="button" variant="ghost" size="sm" className="h-7" onClick={() => setEditing(false)}>Annuleren</Button>
</div>
</form>
</div>
</div>
)
}
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">
{!isDemo && (
<span {...attributes} {...listeners} className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none mt-0.5"></span>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className={cn('text-sm truncate flex-1', task.status === 'DONE' && 'line-through text-muted-foreground')}>
{task.title}
</p>
{code && <CodeBadge code={code} className="shrink-0 mt-0.5" />}
<div ref={setNodeRef} style={style} className="group px-2 py-1">
<div className={cn(
'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]
)}>
{!isDemo && (
<span {...attributes} {...listeners} className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none mt-0.5"></span>
)}
<div className="flex-1 min-w-0">
<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>
<span className="text-xs text-muted-foreground">{PRIORITY_LABELS[task.priority]}</span>
</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>
)
}
@ -251,7 +259,10 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId,
</SortableContext>
<DragOverlay>
{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}
</div>
)}