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
|
|
@ -32,6 +32,7 @@ import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories'
|
|||
import { cn } from '@/lib/utils'
|
||||
import { PbiDialog, type PbiDialogState } from './pbi-dialog'
|
||||
import { BacklogCard } from './backlog-card'
|
||||
import { PRIORITY_COLORS } from '@/components/shared/priority-select'
|
||||
|
||||
const PRIORITY_LABELS: Record<number, string> = {
|
||||
1: 'Kritiek',
|
||||
|
|
@ -40,12 +41,8 @@ const PRIORITY_LABELS: Record<number, string> = {
|
|||
4: 'Laag',
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
type SortMode = 'priority' | 'code' | 'date'
|
||||
|
||||
interface Pbi {
|
||||
id: string
|
||||
|
|
@ -53,6 +50,7 @@ interface Pbi {
|
|||
title: string
|
||||
priority: number
|
||||
description?: string | null
|
||||
created_at: Date
|
||||
}
|
||||
|
||||
interface PbiListProps {
|
||||
|
|
@ -129,10 +127,16 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
|
|||
const { selectedPbiId, selectPbi } = useSelectionStore()
|
||||
const { pbiOrder, pbiPriority, initPbis, reorderPbis, rollbackPbis, updatePbiPriority } = usePlannerStore()
|
||||
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 [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||
const [, startTransition] = useTransition()
|
||||
|
||||
useEffect(() => { localStorage.setItem('scrum4me:pbi_sort', sortMode) }, [sortMode])
|
||||
|
||||
// Sync server data into store — use stable string dep to avoid infinite loop
|
||||
const pbiIdKey = pbis.map(p => p.id).join(',')
|
||||
useEffect(() => {
|
||||
|
|
@ -150,14 +154,18 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
|
|||
.filter(Boolean)
|
||||
.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) => {
|
||||
acc[p] = filtered.filter(pbi => pbi.priority === p)
|
||||
return acc
|
||||
}, {} as Record<number, Pbi[]>)
|
||||
|
||||
const visiblePriorities = [1, 2, 3, 4].filter(p => grouped[p].length > 0)
|
||||
const filtered = [...base].sort((a, b) => {
|
||||
if (sortMode === 'code') {
|
||||
return (a.code ?? '').localeCompare(b.code ?? '', 'nl', { numeric: true })
|
||||
}
|
||||
if (sortMode === 'date') {
|
||||
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(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
|
|
@ -231,6 +239,19 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
|
|||
<span>×</span>
|
||||
</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
|
||||
value={filterPriority?.toString() ?? 'all'}
|
||||
onValueChange={(v) => setFilterPriority(!v || v === 'all' ? null : parseInt(v))}
|
||||
|
|
@ -277,46 +298,24 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
|
|||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="p-3 space-y-4">
|
||||
{visiblePriorities.map(priority => (
|
||||
<div key={priority}>
|
||||
<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])}>
|
||||
{PRIORITY_LABELS[priority]}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
{!isDemo && (
|
||||
<button
|
||||
onClick={() => setDialogState({ mode: 'create', productId, defaultPriority: priority })}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
aria-label={`Nieuw PBI aanmaken (${PRIORITY_LABELS[priority]})`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<SortableContext
|
||||
items={filtered.map(p => p.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="p-3 flex flex-col gap-2">
|
||||
{filtered.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>
|
||||
|
||||
<DragOverlay>
|
||||
{activePbi && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue