diff --git a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx index 4306219..992c217 100644 --- a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx +++ b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx @@ -11,7 +11,6 @@ import { import { SprintTaskDialogMount } from '@/components/sprint/sprint-task-dialog-mount' import { SprintUrlTaskSync } from '@/components/sprint/sprint-url-task-sync' import { SyncActiveSprintCookie } from '@/components/sprint/sync-active-sprint-cookie' -import { SprintSwitcher } from '@/components/shared/sprint-switcher' import { getSprintSwitcherData } from '@/lib/sprint-switcher-data' import { SprintHeader } from '@/components/sprint/sprint-header' import { SprintRunControls } from '@/components/sprint/sprint-run-controls' @@ -182,22 +181,17 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { } return ( -
+
-
- -
diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx index d51a838..896fa9f 100644 --- a/components/backlog/pbi-list.tsx +++ b/components/backlog/pbi-list.tsx @@ -24,8 +24,13 @@ import { toast } from 'sonner' import { CheckSquare, Square } from 'lucide-react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { + BacklogFilterPopover, + PRIORITY_LABELS, + type SortDir, +} from '@/components/shared/backlog-filter-popover' import { useShallow } from 'zustand/react/shallow' +import { readLocalStoragePref } from '@/lib/use-local-storage-pref' import { useProductWorkspaceStore } from '@/stores/product-workspace/store' import { selectVisiblePbis } from '@/stores/product-workspace/selectors' import type { BacklogPbi as WorkspacePbi } from '@/stores/product-workspace/types' @@ -42,14 +47,6 @@ import { PRIORITY_COLORS } from '@/components/shared/priority-select' import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select' import type { PbiStatusApi } from '@/lib/task-status' -const PRIORITY_LABELS: Record = { - 1: 'Kritiek', - 2: 'Hoog', - 3: 'Gemiddeld', - 4: 'Laag', -} - - type SortMode = 'priority' | 'code' | 'date' const SORT_OPTIONS: Array<{ value: SortMode; label: string }> = [ @@ -58,56 +55,15 @@ const SORT_OPTIONS: Array<{ value: SortMode; label: string }> = [ { value: 'date', label: 'Datum' }, ] -const PRIORITY_OPTIONS: Array<{ value: number | 'all'; label: string }> = [ - { value: 'all', label: 'Alle' }, - { value: 1, label: 'Kritiek' }, - { value: 2, label: 'Hoog' }, - { value: 3, label: 'Gemiddeld' }, - { value: 4, label: 'Laag' }, -] +type PbiStatusFilter = PbiStatusApi | 'all' -const STATUS_OPTIONS: Array<{ value: PbiStatusApi | 'all'; label: string }> = [ +const STATUS_OPTIONS: Array<{ value: PbiStatusFilter; label: string }> = [ { value: 'all', label: 'Alle' }, { value: 'ready', label: 'Klaar' }, { value: 'blocked', label: 'Geblokkeerd' }, { value: 'done', label: 'Afgerond' }, ] -function FilterPills({ - label, - options, - value, - onChange, -}: { - label: string - options: Array<{ value: T; label: string }> - value: T - onChange: (v: T) => void -}) { - return ( -
-

{label}

-
- {options.map((opt) => ( - - ))} -
-
- ) -} - interface Pbi { id: string code: string | null @@ -243,12 +199,11 @@ export function PbiList({ productId, isDemo }: PbiListProps) { // voorkomt re-render op ongerelateerde store-mutaties (G2). const pbis = useProductWorkspaceStore(useShallow(selectVisiblePbis)) as WorkspacePbi[] const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId) - // Defaults match SSR; persisted values applied post-mount in the loader effect below. - // This avoids hydration mismatch when localStorage holds non-default values. const [filterPriority, setFilterPriority] = useState('all') - const [filterStatus, setFilterStatus] = useState('all') + const [filterStatus, setFilterStatus] = useState('all') const [sortMode, setSortMode] = useState('priority') - const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc') + const [sortDir, setSortDir] = useState('asc') + const [filterPopoverOpen, setFilterPopoverOpen] = useState(false) const [prefsLoaded, setPrefsLoaded] = useState(false) const [dialogState, setDialogState] = useState(null) const [activeDragId, setActiveDragId] = useState(null) @@ -271,29 +226,39 @@ export function PbiList({ productId, isDemo }: PbiListProps) { }) } - // Load persisted preferences once after mount (client-only). - // setState calls here are intentional: hydrating from localStorage on first paint. + // Hydrate prefs post-mount; SSR + first client render use defaults so no + // hydration mismatch. Users with saved == default see no change; others see + // one filter update right after hydration. useEffect(() => { - const savedSort = localStorage.getItem('scrum4me:pbi_sort') - if (savedSort === 'priority' || savedSort === 'code' || savedSort === 'date') { - // eslint-disable-next-line react-hooks/set-state-in-effect - setSortMode(savedSort) - } - const savedPriority = localStorage.getItem('scrum4me:pbi_filter_priority') - if (savedPriority && savedPriority !== 'all') { - const n = parseInt(savedPriority, 10) - if (Number.isInteger(n) && n >= 1 && n <= 4) setFilterPriority(n) - } - const savedStatus = localStorage.getItem('scrum4me:pbi_filter_status') - if (savedStatus === 'ready' || savedStatus === 'blocked' || savedStatus === 'done') { - setFilterStatus(savedStatus) - } - const savedDir = localStorage.getItem('scrum4me:pbi_sort_dir') - if (savedDir === 'asc' || savedDir === 'desc') setSortDir(savedDir) + /* eslint-disable react-hooks/set-state-in-effect */ + setSortMode(readLocalStoragePref( + 'scrum4me:pbi_sort', + (raw) => (raw === 'priority' || raw === 'code' || raw === 'date') ? raw : null, + 'priority', + )) + setFilterPriority(readLocalStoragePref( + 'scrum4me:pbi_filter_priority', + (raw) => { + if (raw === 'all') return 'all' + const n = parseInt(raw, 10) + return Number.isInteger(n) && n >= 1 && n <= 4 ? n : null + }, + 'all', + )) + setFilterStatus(readLocalStoragePref( + 'scrum4me:pbi_filter_status', + (raw) => (raw === 'ready' || raw === 'blocked' || raw === 'done' || raw === 'all') ? raw : null, + 'all', + )) + setSortDir(readLocalStoragePref( + 'scrum4me:pbi_sort_dir', + (raw) => (raw === 'asc' || raw === 'desc') ? raw : null, + 'asc', + )) setPrefsLoaded(true) + /* eslint-enable react-hooks/set-state-in-effect */ }, []) - // Persist on change, but skip the initial render so we don't overwrite saved values with defaults. useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_sort', sortMode) }, [sortMode, prefsLoaded]) useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_filter_priority', String(filterPriority)) }, [filterPriority, prefsLoaded]) useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_filter_status', filterStatus) }, [filterStatus, prefsLoaded]) @@ -317,14 +282,15 @@ export function PbiList({ productId, isDemo }: PbiListProps) { (sortDir !== 'asc' ? 1 : 0) const filtered = [...base].sort((a, b) => { + let cmp = 0 if (sortMode === 'code') { - return (a.code ?? '').localeCompare(b.code ?? '', 'nl', { numeric: true }) + cmp = (a.code ?? '').localeCompare(b.code ?? '', 'nl', { numeric: true }) + } else if (sortMode === 'date') { + cmp = new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + } else { + cmp = a.priority !== b.priority ? a.priority - b.priority : 0 } - 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 + return sortDir === 'desc' ? -cmp : cmp }) const sensors = useSensors( @@ -439,96 +405,28 @@ export function PbiList({ productId, isDemo }: PbiListProps) { × )} - - - {`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`} - - } - /> - -
-
-

Sorteren op

-
- - -
-
-
- {SORT_OPTIONS.map((opt) => ( - - ))} -
-
- - -
- -
-
-
+ { + setFilterPriority('all') + setFilterStatus('all') + setSortMode('priority') + setSortDir('asc') + }} + /> + ))} +
+
+ ) +} + +interface BacklogFilterPopoverProps { + open: boolean + onOpenChange: (open: boolean) => void + + filterPriority: number | 'all' + onFilterPriorityChange: (v: number | 'all') => void + + filterStatus: S + onFilterStatusChange: (v: S) => void + statusOptions: Array<{ value: S; label: string }> + + sort: So + onSortChange: (v: So) => void + sortDir: SortDir + onSortDirChange: (v: SortDir) => void + sortOptions: Array<{ value: So; label: string }> + + activeFilterCount: number + onReset: () => void + resetDisabled: boolean +} + +export function BacklogFilterPopover({ + open, + onOpenChange, + filterPriority, + onFilterPriorityChange, + filterStatus, + onFilterStatusChange, + statusOptions, + sort, + onSortChange, + sortDir, + onSortDirChange, + sortOptions, + activeFilterCount, + onReset, + resetDisabled, +}: BacklogFilterPopoverProps) { + return ( + + + {`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`} + + } + /> + + + +
+

Sorteren op

+
+ {sortOptions.map((opt) => { + const active = sort === opt.value + return ( + + ) + })} +
+
+
+ +
+
+
+ ) +} diff --git a/components/shared/sprint-switcher.tsx b/components/shared/sprint-switcher.tsx index e960371..4377742 100644 --- a/components/shared/sprint-switcher.tsx +++ b/components/shared/sprint-switcher.tsx @@ -73,7 +73,7 @@ export function SprintSwitcher({ Geen sprints @@ -90,7 +90,7 @@ export function SprintSwitcher({ {activeSprint ? activeSprint.code : 'Selecteer sprint'} @@ -98,7 +98,7 @@ export function SprintSwitcher({ {activeSprint && ( @@ -114,7 +114,7 @@ export function SprintSwitcher({ e.preventDefault() setShowClosed(v => !v) }} - className="flex items-center gap-2 w-full px-2 py-1.5 text-xs text-muted-foreground hover:bg-surface-container rounded-md" + className="flex items-center gap-2 w-full px-2 py-1.5 text-sm text-muted-foreground hover:bg-surface-container rounded-md" > {visibleSprints.length === 0 ? ( -
+
Geen open sprints
) : ( @@ -141,11 +141,11 @@ export function SprintSwitcher({ s.id === activeSprint?.id && 'bg-primary-container text-primary-container-foreground font-medium', )} > - {s.code} - {s.sprint_goal} + {s.code} + {s.sprint_goal} diff --git a/components/sprint/sprint-backlog.tsx b/components/sprint/sprint-backlog.tsx index bc3bc51..f231344 100644 --- a/components/sprint/sprint-backlog.tsx +++ b/components/sprint/sprint-backlog.tsx @@ -1,16 +1,20 @@ 'use client' import { useState, useTransition, useEffect } from 'react' -import { Trash2, MoreHorizontal, ChevronsUp, ChevronsDown, ListFilter, Pencil } from 'lucide-react' +import { Trash2, MoreHorizontal, ChevronsUp, ChevronsDown, Pencil } from 'lucide-react' import { useDroppable, useDraggable } from '@dnd-kit/core' import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { toast } from 'sonner' import { useShallow } from 'zustand/react/shallow' import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { CodeBadge } from '@/components/shared/code-badge' +import { readLocalStoragePref } from '@/lib/use-local-storage-pref' +import { + BacklogFilterPopover, + PRIORITY_LABELS as SHARED_PRIORITY_LABELS, + type SortDir, +} from '@/components/shared/backlog-filter-popover' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, @@ -338,21 +342,6 @@ export function SprintBacklogLeft({ // --- Right panel: Product Backlog grouped by PBI --- -const PRIORITY_LABELS_SPRINT: Record = { - 1: 'Kritiek', - 2: 'Hoog', - 3: 'Gemiddeld', - 4: 'Laag', -} - -const PRIORITY_OPTIONS_SPRINT: Array<{ value: number | 'all'; label: string }> = [ - { value: 'all', label: 'Alle' }, - { value: 1, label: 'Kritiek' }, - { value: 2, label: 'Hoog' }, - { value: 3, label: 'Gemiddeld' }, - { value: 4, label: 'Laag' }, -] - type StoryStatusFilter = 'OPEN' | 'IN_SPRINT' | 'DONE' | 'all' const STATUS_OPTIONS_SPRINT: Array<{ value: StoryStatusFilter; label: string }> = [ @@ -362,39 +351,34 @@ const STATUS_OPTIONS_SPRINT: Array<{ value: StoryStatusFilter; label: string }> { value: 'DONE', label: 'Klaar' }, ] -function FilterPills({ - label, - options, - value, - onChange, -}: { - label: string - options: Array<{ value: T; label: string }> - value: T - onChange: (v: T) => void -}) { - return ( -
-

{label}

-
- {options.map((opt) => ( - - ))} -
-
- ) +type PbiSort = 'code' | 'priority' | 'status' + +const SORT_OPTIONS_SPRINT: Array<{ value: PbiSort; label: string }> = [ + { value: 'code', label: 'Code' }, + { value: 'priority', label: 'Prioriteit' }, + { value: 'status', label: 'Status' }, +] + +const PBI_STATUS_ORDER: Record = { + ready: 0, + blocked: 1, + failed: 2, + done: 3, +} + +function comparePbis(a: PbiWithStories, b: PbiWithStories, sort: PbiSort): number { + const codeCmp = (a.code ?? '').localeCompare(b.code ?? '', undefined, { numeric: true }) + if (sort === 'priority') { + if (a.priority !== b.priority) return a.priority - b.priority + return codeCmp + } + if (sort === 'status') { + const sa = PBI_STATUS_ORDER[a.status] ?? 99 + const sb = PBI_STATUS_ORDER[b.status] ?? 99 + if (sa !== sb) return sa - sb + return codeCmp + } + return codeCmp } function DraggablePbiStoryRow({ @@ -479,31 +463,64 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, pr return auto }) const [filterPriority, setFilterPriority] = useState('all') - const [filterStatus, setFilterStatus] = useState('all') + const [filterStatus, setFilterStatus] = useState('OPEN') + const [sort, setSort] = useState('code') + const [sortDir, setSortDir] = useState('asc') + const [filterPopoverOpen, setFilterPopoverOpen] = useState(false) const [prefsLoaded, setPrefsLoaded] = useState(false) const [pbiDialogState, setPbiDialogState] = useState(null) const { setNodeRef, isOver } = useDroppable({ id: 'backlog-zone' }) - // Hydrate filter prefs from localStorage post-mount (avoids SSR mismatch). - // setState calls here are intentional: hydrating from localStorage on first paint. + // Hydrate prefs from localStorage post-mount. SSR & first client render use + // defaults — matched HTML so no hydration error. After mount we apply saved + // values; users with saved == default see no visible change, others see one + // filter update during hydration. useEffect(() => { - const savedPriority = localStorage.getItem('scrum4me:sprint_pb_filter_priority') - if (savedPriority && savedPriority !== 'all') { - const n = parseInt(savedPriority, 10) - // eslint-disable-next-line react-hooks/set-state-in-effect - if (Number.isInteger(n) && n >= 1 && n <= 4) setFilterPriority(n) + /* eslint-disable react-hooks/set-state-in-effect */ + setFilterPriority(readLocalStoragePref( + 'scrum4me:sprint_pb_filter_priority', + (raw) => { + if (raw === 'all') return 'all' + const n = parseInt(raw, 10) + return Number.isInteger(n) && n >= 1 && n <= 4 ? n : null + }, + 'all', + )) + setFilterStatus(readLocalStoragePref( + 'scrum4me:sprint_pb_filter_status', + (raw) => (raw === 'OPEN' || raw === 'IN_SPRINT' || raw === 'DONE' || raw === 'all') ? raw : null, + 'OPEN', + )) + setSort(readLocalStoragePref( + 'scrum4me:sprint_pb_sort', + (raw) => (raw === 'priority' || raw === 'status' || raw === 'code') ? raw : null, + 'code', + )) + setSortDir(readLocalStoragePref( + 'scrum4me:sprint_pb_sort_dir', + (raw) => (raw === 'asc' || raw === 'desc') ? raw : null, + 'asc', + )) + const savedCollapsed = localStorage.getItem('scrum4me:sprint_pb_collapsed') + if (savedCollapsed) { + try { + const arr = JSON.parse(savedCollapsed) + if (Array.isArray(arr)) { + setCollapsed(new Set(arr.filter((x): x is string => typeof x === 'string'))) + } + } catch { /* ignore malformed JSON */ } } - const savedStatus = localStorage.getItem('scrum4me:sprint_pb_filter_status') - if (savedStatus === 'OPEN' || savedStatus === 'IN_SPRINT' || savedStatus === 'DONE') { - - setFilterStatus(savedStatus) - } - + setFilterPopoverOpen(localStorage.getItem('scrum4me:sprint_pb_filter_popover_open') === 'true') setPrefsLoaded(true) + /* eslint-enable react-hooks/set-state-in-effect */ }, []) useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_filter_priority', String(filterPriority)) }, [filterPriority, prefsLoaded]) useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_filter_status', filterStatus) }, [filterStatus, prefsLoaded]) + useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_sort', sort) }, [sort, prefsLoaded]) + useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_sort_dir', sortDir) }, [sortDir, prefsLoaded]) + useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_collapsed', JSON.stringify(Array.from(collapsed))) }, [collapsed, prefsLoaded]) + useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_filter_popover_open', String(filterPopoverOpen)) }, [filterPopoverOpen, prefsLoaded]) const filteredPbis = pbisWithStories .map(pbi => ({ @@ -514,10 +531,11 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, pr ), })) .filter(pbi => pbi.stories.length > 0) + .sort((a, b) => (sortDir === 'desc' ? -1 : 1) * comparePbis(a, b, sort)) const activeFilterCount = (filterPriority !== 'all' ? 1 : 0) + - (filterStatus !== 'all' ? 1 : 0) + (filterStatus !== 'OPEN' ? 1 : 0) function toggle(pbiId: string) { setCollapsed(prev => { @@ -535,16 +553,6 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, pr setCollapsed(new Set()) } - function onlyNotDone() { - const auto = new Set() - for (const pbi of filteredPbis) { - if (pbi.stories.length > 0 && pbi.stories.every(s => s.status === 'DONE')) { - auto.add(pbi.id) - } - } - setCollapsed(auto) - } - const headerActions = ( <> {filterPriority !== 'all' && ( @@ -554,61 +562,45 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, pr aria-label="Wis prioriteitsfilter" > - {PRIORITY_LABELS_SPRINT[filterPriority]} + {SHARED_PRIORITY_LABELS[filterPriority]} × )} - {filterStatus !== 'all' && ( + {filterStatus !== 'OPEN' && ( )} - - - {`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`} - - } - /> - - - -
- -
-
-
+ { + setFilterPriority('all') + setFilterStatus('OPEN') + setSort('code') + setSortDir('asc') + }} + /> @@ -622,12 +614,6 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, pr Alles uitklappen - - - - - Alleen niet klaar - ) diff --git a/components/sprint/sprint-header.tsx b/components/sprint/sprint-header.tsx index f4acef1..304dd88 100644 --- a/components/sprint/sprint-header.tsx +++ b/components/sprint/sprint-header.tsx @@ -33,6 +33,8 @@ import { import { updateSprintGoalAction, updateSprintDatesAction, completeSprintAction, setAllSprintTasksDoneAction } from '@/actions/sprints' import type { SprintStory } from './sprint-backlog' import { debugProps } from '@/lib/debug' +import { SprintSwitcher } from '@/components/shared/sprint-switcher' +import type { SprintSwitcherItem } from '@/lib/sprint-switcher-data' interface Sprint { id: string @@ -49,6 +51,9 @@ interface SprintHeaderProps { sprint: Sprint isDemo: boolean sprintStories: SprintStory[] + switcherSprints: SprintSwitcherItem[] + switcherActiveSprint: SprintSwitcherItem | null + switcherBuildingSprintIds: string[] } interface ActionResult { @@ -63,7 +68,7 @@ function toDateInputValue(d: Date | null) { return d.toISOString().slice(0, 10) } -export function SprintHeader({ productId: _productId, productName, sprint, isDemo, sprintStories }: SprintHeaderProps) { +export function SprintHeader({ productId, productName, sprint, isDemo, sprintStories, switcherSprints, switcherActiveSprint, switcherBuildingSprintIds }: SprintHeaderProps) { const [editingGoal, setEditingGoal] = useState(false) const [editingDates, setEditingDates] = useState(false) const [completeOpen, setCompleteOpen] = useState(false) @@ -132,7 +137,7 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem return (
-
+
{productName} @@ -162,7 +167,16 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem )}
-
+
+ +
+ +