feat: shared backlog filter popover + sprint header polish (v1.3.3) (#184)
- Move sprint switcher into sprint header, centered between title and actions - Extract BacklogFilterPopover as shared component used by sprint and product backlog - Add sort options (code/priority/status) with single-pill asc/desc toggle - Default sprint backlog status filter to OPEN, remove "alleen niet klaar" button - Persist collapsed state and filter popover open in localStorage - Fix hydration flicker: defer localStorage read to useEffect with prefsLoaded gate for writes - Increase sprint switcher text size for readability Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a9b53dedf0
commit
1f8cbacb0a
9 changed files with 424 additions and 330 deletions
|
|
@ -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<number, string> = {
|
||||
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<T extends string | number>({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
options: Array<{ value: T; label: string }>
|
||||
value: T
|
||||
onChange: (v: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={cn(
|
||||
'text-xs px-2.5 py-1 rounded-full border transition-colors',
|
||||
value === opt.value
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-transparent border-border hover:bg-surface-container'
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<number | 'all'>('all')
|
||||
const [filterStatus, setFilterStatus] = useState<PbiStatusApi | 'all'>('all')
|
||||
const [filterStatus, setFilterStatus] = useState<PbiStatusFilter>('all')
|
||||
const [sortMode, setSortMode] = useState<SortMode>('priority')
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
const [filterPopoverOpen, setFilterPopoverOpen] = useState(false)
|
||||
const [prefsLoaded, setPrefsLoaded] = useState(false)
|
||||
const [dialogState, setDialogState] = useState<PbiDialogState | null>(null)
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(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<SortMode>(
|
||||
'scrum4me:pbi_sort',
|
||||
(raw) => (raw === 'priority' || raw === 'code' || raw === 'date') ? raw : null,
|
||||
'priority',
|
||||
))
|
||||
setFilterPriority(readLocalStoragePref<number | 'all'>(
|
||||
'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<PbiStatusFilter>(
|
||||
'scrum4me:pbi_filter_status',
|
||||
(raw) => (raw === 'ready' || raw === 'blocked' || raw === 'done' || raw === 'all') ? raw : null,
|
||||
'all',
|
||||
))
|
||||
setSortDir(readLocalStoragePref<SortDir>(
|
||||
'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) {
|
|||
<span>×</span>
|
||||
</button>
|
||||
)}
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs">
|
||||
{`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-72 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-muted-foreground">Sorteren op</p>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSortDir('asc')}
|
||||
className={cn(
|
||||
'text-xs px-2 py-0.5 rounded border transition-colors',
|
||||
sortDir === 'asc'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-transparent border-border hover:bg-surface-container'
|
||||
)}
|
||||
aria-label="Oplopend sorteren"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSortDir('desc')}
|
||||
className={cn(
|
||||
'text-xs px-2 py-0.5 rounded border transition-colors',
|
||||
sortDir === 'desc'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-transparent border-border hover:bg-surface-container'
|
||||
)}
|
||||
aria-label="Aflopend sorteren"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setSortMode(opt.value)}
|
||||
className={cn(
|
||||
'text-xs px-2.5 py-1 rounded-full border transition-colors',
|
||||
sortMode === opt.value
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-transparent border-border hover:bg-surface-container'
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<FilterPills
|
||||
label="Prioriteit"
|
||||
options={PRIORITY_OPTIONS}
|
||||
value={filterPriority}
|
||||
onChange={setFilterPriority}
|
||||
/>
|
||||
<FilterPills
|
||||
label="Status"
|
||||
options={STATUS_OPTIONS}
|
||||
value={filterStatus}
|
||||
onChange={setFilterStatus}
|
||||
/>
|
||||
<div className="flex justify-end pt-1 border-t border-border">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={activeFilterCount === 0}
|
||||
onClick={() => {
|
||||
setFilterPriority('all')
|
||||
setFilterStatus('all')
|
||||
setSortMode('priority')
|
||||
setSortDir('asc')
|
||||
}}
|
||||
>
|
||||
Wis filters
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<BacklogFilterPopover
|
||||
open={filterPopoverOpen}
|
||||
onOpenChange={setFilterPopoverOpen}
|
||||
filterPriority={filterPriority}
|
||||
onFilterPriorityChange={setFilterPriority}
|
||||
filterStatus={filterStatus}
|
||||
onFilterStatusChange={setFilterStatus}
|
||||
statusOptions={STATUS_OPTIONS}
|
||||
sort={sortMode}
|
||||
onSortChange={setSortMode}
|
||||
sortDir={sortDir}
|
||||
onSortDirChange={setSortDir}
|
||||
sortOptions={SORT_OPTIONS}
|
||||
activeFilterCount={activeFilterCount}
|
||||
resetDisabled={activeFilterCount === 0}
|
||||
onReset={() => {
|
||||
setFilterPriority('all')
|
||||
setFilterStatus('all')
|
||||
setSortMode('priority')
|
||||
setSortDir('asc')
|
||||
}}
|
||||
/>
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue