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
|
|
@ -11,7 +11,6 @@ import {
|
||||||
import { SprintTaskDialogMount } from '@/components/sprint/sprint-task-dialog-mount'
|
import { SprintTaskDialogMount } from '@/components/sprint/sprint-task-dialog-mount'
|
||||||
import { SprintUrlTaskSync } from '@/components/sprint/sprint-url-task-sync'
|
import { SprintUrlTaskSync } from '@/components/sprint/sprint-url-task-sync'
|
||||||
import { SyncActiveSprintCookie } from '@/components/sprint/sync-active-sprint-cookie'
|
import { SyncActiveSprintCookie } from '@/components/sprint/sync-active-sprint-cookie'
|
||||||
import { SprintSwitcher } from '@/components/shared/sprint-switcher'
|
|
||||||
import { getSprintSwitcherData } from '@/lib/sprint-switcher-data'
|
import { getSprintSwitcherData } from '@/lib/sprint-switcher-data'
|
||||||
import { SprintHeader } from '@/components/sprint/sprint-header'
|
import { SprintHeader } from '@/components/sprint/sprint-header'
|
||||||
import { SprintRunControls } from '@/components/sprint/sprint-run-controls'
|
import { SprintRunControls } from '@/components/sprint/sprint-run-controls'
|
||||||
|
|
@ -182,22 +181,17 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div id="wrapper2" className="flex flex-col h-full">
|
||||||
<SyncActiveSprintCookie productId={id} sprintId={sprint.id} />
|
<SyncActiveSprintCookie productId={id} sprintId={sprint.id} />
|
||||||
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-center">
|
|
||||||
<SprintSwitcher
|
|
||||||
productId={id}
|
|
||||||
sprints={switcherData.sprintItems}
|
|
||||||
activeSprint={switcherData.activeSprintItem}
|
|
||||||
buildingSprintIds={switcherData.buildingSprintIds}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<SprintHeader
|
<SprintHeader
|
||||||
productId={id}
|
productId={id}
|
||||||
productName={product.name}
|
productName={product.name}
|
||||||
sprint={sprint}
|
sprint={sprint}
|
||||||
isDemo={isDemo}
|
isDemo={isDemo}
|
||||||
sprintStories={sprintStoryItems}
|
sprintStories={sprintStoryItems}
|
||||||
|
switcherSprints={switcherData.sprintItems}
|
||||||
|
switcherActiveSprint={switcherData.activeSprintItem}
|
||||||
|
switcherBuildingSprintIds={switcherData.buildingSprintIds}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="border-b border-border bg-surface-container-low px-4 py-2 shrink-0">
|
<div className="border-b border-border bg-surface-container-low px-4 py-2 shrink-0">
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,13 @@ import { toast } from 'sonner'
|
||||||
import { CheckSquare, Square } from 'lucide-react'
|
import { CheckSquare, Square } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
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 { useShallow } from 'zustand/react/shallow'
|
||||||
|
import { readLocalStoragePref } from '@/lib/use-local-storage-pref'
|
||||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||||
import { selectVisiblePbis } from '@/stores/product-workspace/selectors'
|
import { selectVisiblePbis } from '@/stores/product-workspace/selectors'
|
||||||
import type { BacklogPbi as WorkspacePbi } from '@/stores/product-workspace/types'
|
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 { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select'
|
||||||
import type { PbiStatusApi } from '@/lib/task-status'
|
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'
|
type SortMode = 'priority' | 'code' | 'date'
|
||||||
|
|
||||||
const SORT_OPTIONS: Array<{ value: SortMode; label: string }> = [
|
const SORT_OPTIONS: Array<{ value: SortMode; label: string }> = [
|
||||||
|
|
@ -58,56 +55,15 @@ const SORT_OPTIONS: Array<{ value: SortMode; label: string }> = [
|
||||||
{ value: 'date', label: 'Datum' },
|
{ value: 'date', label: 'Datum' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const PRIORITY_OPTIONS: Array<{ value: number | 'all'; label: string }> = [
|
type PbiStatusFilter = PbiStatusApi | 'all'
|
||||||
{ value: 'all', label: 'Alle' },
|
|
||||||
{ value: 1, label: 'Kritiek' },
|
|
||||||
{ value: 2, label: 'Hoog' },
|
|
||||||
{ value: 3, label: 'Gemiddeld' },
|
|
||||||
{ value: 4, label: 'Laag' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const STATUS_OPTIONS: Array<{ value: PbiStatusApi | 'all'; label: string }> = [
|
const STATUS_OPTIONS: Array<{ value: PbiStatusFilter; label: string }> = [
|
||||||
{ value: 'all', label: 'Alle' },
|
{ value: 'all', label: 'Alle' },
|
||||||
{ value: 'ready', label: 'Klaar' },
|
{ value: 'ready', label: 'Klaar' },
|
||||||
{ value: 'blocked', label: 'Geblokkeerd' },
|
{ value: 'blocked', label: 'Geblokkeerd' },
|
||||||
{ value: 'done', label: 'Afgerond' },
|
{ 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 {
|
interface Pbi {
|
||||||
id: string
|
id: string
|
||||||
code: string | null
|
code: string | null
|
||||||
|
|
@ -243,12 +199,11 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
||||||
// voorkomt re-render op ongerelateerde store-mutaties (G2).
|
// voorkomt re-render op ongerelateerde store-mutaties (G2).
|
||||||
const pbis = useProductWorkspaceStore(useShallow(selectVisiblePbis)) as WorkspacePbi[]
|
const pbis = useProductWorkspaceStore(useShallow(selectVisiblePbis)) as WorkspacePbi[]
|
||||||
const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId)
|
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 [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 [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 [prefsLoaded, setPrefsLoaded] = useState(false)
|
||||||
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)
|
||||||
|
|
@ -271,29 +226,39 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load persisted preferences once after mount (client-only).
|
// Hydrate prefs post-mount; SSR + first client render use defaults so no
|
||||||
// setState calls here are intentional: hydrating from localStorage on first paint.
|
// hydration mismatch. Users with saved == default see no change; others see
|
||||||
|
// one filter update right after hydration.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedSort = localStorage.getItem('scrum4me:pbi_sort')
|
/* eslint-disable react-hooks/set-state-in-effect */
|
||||||
if (savedSort === 'priority' || savedSort === 'code' || savedSort === 'date') {
|
setSortMode(readLocalStoragePref<SortMode>(
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
'scrum4me:pbi_sort',
|
||||||
setSortMode(savedSort)
|
(raw) => (raw === 'priority' || raw === 'code' || raw === 'date') ? raw : null,
|
||||||
}
|
'priority',
|
||||||
const savedPriority = localStorage.getItem('scrum4me:pbi_filter_priority')
|
))
|
||||||
if (savedPriority && savedPriority !== 'all') {
|
setFilterPriority(readLocalStoragePref<number | 'all'>(
|
||||||
const n = parseInt(savedPriority, 10)
|
'scrum4me:pbi_filter_priority',
|
||||||
if (Number.isInteger(n) && n >= 1 && n <= 4) setFilterPriority(n)
|
(raw) => {
|
||||||
}
|
if (raw === 'all') return 'all'
|
||||||
const savedStatus = localStorage.getItem('scrum4me:pbi_filter_status')
|
const n = parseInt(raw, 10)
|
||||||
if (savedStatus === 'ready' || savedStatus === 'blocked' || savedStatus === 'done') {
|
return Number.isInteger(n) && n >= 1 && n <= 4 ? n : null
|
||||||
setFilterStatus(savedStatus)
|
},
|
||||||
}
|
'all',
|
||||||
const savedDir = localStorage.getItem('scrum4me:pbi_sort_dir')
|
))
|
||||||
if (savedDir === 'asc' || savedDir === 'desc') setSortDir(savedDir)
|
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)
|
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_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_priority', String(filterPriority)) }, [filterPriority, prefsLoaded])
|
||||||
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_filter_status', filterStatus) }, [filterStatus, 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)
|
(sortDir !== 'asc' ? 1 : 0)
|
||||||
|
|
||||||
const filtered = [...base].sort((a, b) => {
|
const filtered = [...base].sort((a, b) => {
|
||||||
|
let cmp = 0
|
||||||
if (sortMode === 'code') {
|
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 sortDir === 'desc' ? -cmp : cmp
|
||||||
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(
|
||||||
|
|
@ -439,96 +405,28 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
||||||
<span>×</span>
|
<span>×</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<Popover>
|
<BacklogFilterPopover
|
||||||
<PopoverTrigger
|
open={filterPopoverOpen}
|
||||||
render={
|
onOpenChange={setFilterPopoverOpen}
|
||||||
<Button variant="outline" size="sm" className="h-7 text-xs">
|
filterPriority={filterPriority}
|
||||||
{`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`}
|
onFilterPriorityChange={setFilterPriority}
|
||||||
</Button>
|
filterStatus={filterStatus}
|
||||||
}
|
onFilterStatusChange={setFilterStatus}
|
||||||
/>
|
statusOptions={STATUS_OPTIONS}
|
||||||
<PopoverContent align="end" className="w-72 space-y-4">
|
sort={sortMode}
|
||||||
<div className="space-y-1.5">
|
onSortChange={setSortMode}
|
||||||
<div className="flex items-center justify-between">
|
sortDir={sortDir}
|
||||||
<p className="text-xs font-medium text-muted-foreground">Sorteren op</p>
|
onSortDirChange={setSortDir}
|
||||||
<div className="flex gap-1">
|
sortOptions={SORT_OPTIONS}
|
||||||
<button
|
activeFilterCount={activeFilterCount}
|
||||||
type="button"
|
resetDisabled={activeFilterCount === 0}
|
||||||
onClick={() => setSortDir('asc')}
|
onReset={() => {
|
||||||
className={cn(
|
setFilterPriority('all')
|
||||||
'text-xs px-2 py-0.5 rounded border transition-colors',
|
setFilterStatus('all')
|
||||||
sortDir === 'asc'
|
setSortMode('priority')
|
||||||
? 'bg-primary text-primary-foreground border-primary'
|
setSortDir('asc')
|
||||||
: '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>
|
|
||||||
<DemoTooltip show={isDemo}>
|
<DemoTooltip show={isDemo}>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -115,10 +115,6 @@ export default function JobsColumn({
|
||||||
statusOptions,
|
statusOptions,
|
||||||
emptyText,
|
emptyText,
|
||||||
}: JobsColumnProps) {
|
}: JobsColumnProps) {
|
||||||
const [filterKinds, setFilterKinds] = useState<Set<ClaudeJobKind>>(() => new Set())
|
|
||||||
const [filterStatuses, setFilterStatuses] = useState<Set<ClaudeJobStatusApi>>(() => new Set())
|
|
||||||
const [prefsLoaded, setPrefsLoaded] = useState(false)
|
|
||||||
|
|
||||||
const kindKey = `${storageKeyPrefix}_filter_kind`
|
const kindKey = `${storageKeyPrefix}_filter_kind`
|
||||||
const statusKey = `${storageKeyPrefix}_filter_status`
|
const statusKey = `${storageKeyPrefix}_filter_status`
|
||||||
|
|
||||||
|
|
@ -127,8 +123,11 @@ export default function JobsColumn({
|
||||||
[statusOptions]
|
[statusOptions]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const [filterKinds, setFilterKinds] = useState<Set<ClaudeJobKind>>(() => new Set())
|
||||||
|
const [filterStatuses, setFilterStatuses] = useState<Set<ClaudeJobStatusApi>>(() => new Set())
|
||||||
|
const [prefsLoaded, setPrefsLoaded] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Hydratie van localStorage post-mount: zetters intentioneel, voorkomt SSR-mismatch.
|
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
/* eslint-disable react-hooks/set-state-in-effect */
|
||||||
setFilterKinds(parseCsv<ClaudeJobKind>(localStorage.getItem(kindKey), KIND_VALUES))
|
setFilterKinds(parseCsv<ClaudeJobKind>(localStorage.getItem(kindKey), KIND_VALUES))
|
||||||
setFilterStatuses(parseCsv<ClaudeJobStatusApi>(localStorage.getItem(statusKey), statusValues))
|
setFilterStatuses(parseCsv<ClaudeJobStatusApi>(localStorage.getItem(statusKey), statusValues))
|
||||||
|
|
@ -136,13 +135,8 @@ export default function JobsColumn({
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
/* eslint-enable react-hooks/set-state-in-effect */
|
||||||
}, [kindKey, statusKey, statusValues])
|
}, [kindKey, statusKey, statusValues])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { if (prefsLoaded) localStorage.setItem(kindKey, setToCsv(filterKinds)) }, [filterKinds, prefsLoaded, kindKey])
|
||||||
if (prefsLoaded) localStorage.setItem(kindKey, setToCsv(filterKinds))
|
useEffect(() => { if (prefsLoaded) localStorage.setItem(statusKey, setToCsv(filterStatuses)) }, [filterStatuses, prefsLoaded, statusKey])
|
||||||
}, [filterKinds, prefsLoaded, kindKey])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (prefsLoaded) localStorage.setItem(statusKey, setToCsv(filterStatuses))
|
|
||||||
}, [filterStatuses, prefsLoaded, statusKey])
|
|
||||||
|
|
||||||
function toggleKind(v: ClaudeJobKind) {
|
function toggleKind(v: ClaudeJobKind) {
|
||||||
setFilterKinds((prev) => {
|
setFilterKinds((prev) => {
|
||||||
|
|
|
||||||
186
components/shared/backlog-filter-popover.tsx
Normal file
186
components/shared/backlog-filter-popover.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ArrowDown, ArrowUp } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { debugProps } from '@/lib/debug'
|
||||||
|
|
||||||
|
export const PRIORITY_LABELS: Record<number, string> = {
|
||||||
|
1: 'Kritiek',
|
||||||
|
2: 'Hoog',
|
||||||
|
3: 'Gemiddeld',
|
||||||
|
4: 'Laag',
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export type SortDir = 'asc' | 'desc'
|
||||||
|
|
||||||
|
export 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 BacklogFilterPopoverProps<S extends string, So extends string> {
|
||||||
|
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<S extends string, So extends string>({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
filterPriority,
|
||||||
|
onFilterPriorityChange,
|
||||||
|
filterStatus,
|
||||||
|
onFilterStatusChange,
|
||||||
|
statusOptions,
|
||||||
|
sort,
|
||||||
|
onSortChange,
|
||||||
|
sortDir,
|
||||||
|
onSortDirChange,
|
||||||
|
sortOptions,
|
||||||
|
activeFilterCount,
|
||||||
|
onReset,
|
||||||
|
resetDisabled,
|
||||||
|
}: BacklogFilterPopoverProps<S, So>) {
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={onOpenChange}>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
{...debugProps('backlog-filter-popover__trigger')}
|
||||||
|
>
|
||||||
|
{`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
align="end"
|
||||||
|
className="w-72 space-y-4"
|
||||||
|
{...debugProps('backlog-filter-popover', 'BacklogFilterPopover', 'components/shared/backlog-filter-popover.tsx')}
|
||||||
|
>
|
||||||
|
<FilterPills
|
||||||
|
label="Prioriteit"
|
||||||
|
options={PRIORITY_OPTIONS}
|
||||||
|
value={filterPriority}
|
||||||
|
onChange={onFilterPriorityChange}
|
||||||
|
/>
|
||||||
|
<FilterPills
|
||||||
|
label="Status"
|
||||||
|
options={statusOptions}
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={onFilterStatusChange}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Sorteren op</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{sortOptions.map((opt) => {
|
||||||
|
const active = sort === opt.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (active) {
|
||||||
|
onSortDirChange(sortDir === 'asc' ? 'desc' : 'asc')
|
||||||
|
} else {
|
||||||
|
onSortChange(opt.value)
|
||||||
|
onSortDirChange('asc')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={
|
||||||
|
active
|
||||||
|
? `Sorteren op ${opt.label} ${sortDir === 'asc' ? 'oplopend' : 'aflopend'} — klik om te wisselen`
|
||||||
|
: `Sorteren op ${opt.label}`
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-full border transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-transparent border-border hover:bg-surface-container'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
{active && (
|
||||||
|
sortDir === 'asc'
|
||||||
|
? <ArrowDown size={12} aria-hidden />
|
||||||
|
: <ArrowUp size={12} aria-hidden />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end pt-1 border-t border-border">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={resetDisabled}
|
||||||
|
onClick={onReset}
|
||||||
|
>
|
||||||
|
Wis filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -73,7 +73,7 @@ export function SprintSwitcher({
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
className="text-xs text-muted-foreground/50 px-2 cursor-not-allowed select-none"
|
className="text-sm text-muted-foreground/50 px-2 cursor-not-allowed select-none"
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
>
|
>
|
||||||
Geen sprints
|
Geen sprints
|
||||||
|
|
@ -90,7 +90,7 @@ export function SprintSwitcher({
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded-md hover:bg-surface-container focus:outline-none"
|
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded-md hover:bg-surface-container focus:outline-none"
|
||||||
>
|
>
|
||||||
<span className="truncate max-w-[160px]">
|
<span className="truncate max-w-[160px]">
|
||||||
{activeSprint ? activeSprint.code : 'Selecteer sprint'}
|
{activeSprint ? activeSprint.code : 'Selecteer sprint'}
|
||||||
|
|
@ -98,7 +98,7 @@ export function SprintSwitcher({
|
||||||
{activeSprint && (
|
{activeSprint && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[10px]',
|
'text-sm',
|
||||||
buildingSet.has(activeSprint.id) ? 'text-warning' : 'text-muted-foreground',
|
buildingSet.has(activeSprint.id) ? 'text-warning' : 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -114,7 +114,7 @@ export function SprintSwitcher({
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setShowClosed(v => !v)
|
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"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -128,7 +128,7 @@ export function SprintSwitcher({
|
||||||
</button>
|
</button>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{visibleSprints.length === 0 ? (
|
{visibleSprints.length === 0 ? (
|
||||||
<div className="px-2 py-2 text-xs text-muted-foreground/70 italic">
|
<div className="px-2 py-2 text-sm text-muted-foreground/70 italic">
|
||||||
Geen open sprints
|
Geen open sprints
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -141,11 +141,11 @@ export function SprintSwitcher({
|
||||||
s.id === activeSprint?.id && 'bg-primary-container text-primary-container-foreground font-medium',
|
s.id === activeSprint?.id && 'bg-primary-container text-primary-container-foreground font-medium',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-medium shrink-0">{s.code}</span>
|
<span className="text-sm font-medium shrink-0">{s.code}</span>
|
||||||
<span className="text-xs truncate flex-1">{s.sprint_goal}</span>
|
<span className="text-sm truncate flex-1">{s.sprint_goal}</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[10px] shrink-0',
|
'text-sm shrink-0',
|
||||||
buildingSet.has(s.id) ? 'text-warning' : 'text-muted-foreground',
|
buildingSet.has(s.id) ? 'text-warning' : 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useTransition, useEffect } from 'react'
|
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 { 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'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { Badge } from '@/components/ui/badge'
|
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 { 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 {
|
import {
|
||||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||||
DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent,
|
DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent,
|
||||||
|
|
@ -338,21 +342,6 @@ export function SprintBacklogLeft({
|
||||||
|
|
||||||
// --- Right panel: Product Backlog grouped by PBI ---
|
// --- Right panel: Product Backlog grouped by PBI ---
|
||||||
|
|
||||||
const PRIORITY_LABELS_SPRINT: Record<number, string> = {
|
|
||||||
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'
|
type StoryStatusFilter = 'OPEN' | 'IN_SPRINT' | 'DONE' | 'all'
|
||||||
|
|
||||||
const STATUS_OPTIONS_SPRINT: Array<{ value: StoryStatusFilter; label: string }> = [
|
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' },
|
{ value: 'DONE', label: 'Klaar' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function FilterPills<T extends string | number>({
|
type PbiSort = 'code' | 'priority' | 'status'
|
||||||
label,
|
|
||||||
options,
|
const SORT_OPTIONS_SPRINT: Array<{ value: PbiSort; label: string }> = [
|
||||||
value,
|
{ value: 'code', label: 'Code' },
|
||||||
onChange,
|
{ value: 'priority', label: 'Prioriteit' },
|
||||||
}: {
|
{ value: 'status', label: 'Status' },
|
||||||
label: string
|
]
|
||||||
options: Array<{ value: T; label: string }>
|
|
||||||
value: T
|
const PBI_STATUS_ORDER: Record<PbiStatusApi, number> = {
|
||||||
onChange: (v: T) => void
|
ready: 0,
|
||||||
}) {
|
blocked: 1,
|
||||||
return (
|
failed: 2,
|
||||||
<div className="space-y-1.5">
|
done: 3,
|
||||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{options.map((opt) => (
|
function comparePbis(a: PbiWithStories, b: PbiWithStories, sort: PbiSort): number {
|
||||||
<button
|
const codeCmp = (a.code ?? '').localeCompare(b.code ?? '', undefined, { numeric: true })
|
||||||
key={String(opt.value)}
|
if (sort === 'priority') {
|
||||||
type="button"
|
if (a.priority !== b.priority) return a.priority - b.priority
|
||||||
onClick={() => onChange(opt.value)}
|
return codeCmp
|
||||||
className={cn(
|
}
|
||||||
'text-xs px-2.5 py-1 rounded-full border transition-colors',
|
if (sort === 'status') {
|
||||||
value === opt.value
|
const sa = PBI_STATUS_ORDER[a.status] ?? 99
|
||||||
? 'bg-primary text-primary-foreground border-primary'
|
const sb = PBI_STATUS_ORDER[b.status] ?? 99
|
||||||
: 'bg-transparent border-border hover:bg-surface-container'
|
if (sa !== sb) return sa - sb
|
||||||
)}
|
return codeCmp
|
||||||
>
|
}
|
||||||
{opt.label}
|
return codeCmp
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DraggablePbiStoryRow({
|
function DraggablePbiStoryRow({
|
||||||
|
|
@ -479,31 +463,64 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, pr
|
||||||
return auto
|
return auto
|
||||||
})
|
})
|
||||||
const [filterPriority, setFilterPriority] = useState<number | 'all'>('all')
|
const [filterPriority, setFilterPriority] = useState<number | 'all'>('all')
|
||||||
const [filterStatus, setFilterStatus] = useState<StoryStatusFilter>('all')
|
const [filterStatus, setFilterStatus] = useState<StoryStatusFilter>('OPEN')
|
||||||
|
const [sort, setSort] = useState<PbiSort>('code')
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||||
|
const [filterPopoverOpen, setFilterPopoverOpen] = useState(false)
|
||||||
const [prefsLoaded, setPrefsLoaded] = useState(false)
|
const [prefsLoaded, setPrefsLoaded] = useState(false)
|
||||||
const [pbiDialogState, setPbiDialogState] = useState<PbiDialogState | null>(null)
|
const [pbiDialogState, setPbiDialogState] = useState<PbiDialogState | null>(null)
|
||||||
const { setNodeRef, isOver } = useDroppable({ id: 'backlog-zone' })
|
const { setNodeRef, isOver } = useDroppable({ id: 'backlog-zone' })
|
||||||
|
|
||||||
// Hydrate filter prefs from localStorage post-mount (avoids SSR mismatch).
|
// Hydrate prefs from localStorage post-mount. SSR & first client render use
|
||||||
// setState calls here are intentional: hydrating from localStorage on first paint.
|
// 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(() => {
|
useEffect(() => {
|
||||||
const savedPriority = localStorage.getItem('scrum4me:sprint_pb_filter_priority')
|
/* eslint-disable react-hooks/set-state-in-effect */
|
||||||
if (savedPriority && savedPriority !== 'all') {
|
setFilterPriority(readLocalStoragePref<number | 'all'>(
|
||||||
const n = parseInt(savedPriority, 10)
|
'scrum4me:sprint_pb_filter_priority',
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
(raw) => {
|
||||||
if (Number.isInteger(n) && n >= 1 && n <= 4) setFilterPriority(n)
|
if (raw === 'all') return 'all'
|
||||||
|
const n = parseInt(raw, 10)
|
||||||
|
return Number.isInteger(n) && n >= 1 && n <= 4 ? n : null
|
||||||
|
},
|
||||||
|
'all',
|
||||||
|
))
|
||||||
|
setFilterStatus(readLocalStoragePref<StoryStatusFilter>(
|
||||||
|
'scrum4me:sprint_pb_filter_status',
|
||||||
|
(raw) => (raw === 'OPEN' || raw === 'IN_SPRINT' || raw === 'DONE' || raw === 'all') ? raw : null,
|
||||||
|
'OPEN',
|
||||||
|
))
|
||||||
|
setSort(readLocalStoragePref<PbiSort>(
|
||||||
|
'scrum4me:sprint_pb_sort',
|
||||||
|
(raw) => (raw === 'priority' || raw === 'status' || raw === 'code') ? raw : null,
|
||||||
|
'code',
|
||||||
|
))
|
||||||
|
setSortDir(readLocalStoragePref<SortDir>(
|
||||||
|
'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')
|
setFilterPopoverOpen(localStorage.getItem('scrum4me:sprint_pb_filter_popover_open') === 'true')
|
||||||
if (savedStatus === 'OPEN' || savedStatus === 'IN_SPRINT' || savedStatus === 'DONE') {
|
|
||||||
|
|
||||||
setFilterStatus(savedStatus)
|
|
||||||
}
|
|
||||||
|
|
||||||
setPrefsLoaded(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_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_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
|
const filteredPbis = pbisWithStories
|
||||||
.map(pbi => ({
|
.map(pbi => ({
|
||||||
|
|
@ -514,10 +531,11 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, pr
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
.filter(pbi => pbi.stories.length > 0)
|
.filter(pbi => pbi.stories.length > 0)
|
||||||
|
.sort((a, b) => (sortDir === 'desc' ? -1 : 1) * comparePbis(a, b, sort))
|
||||||
|
|
||||||
const activeFilterCount =
|
const activeFilterCount =
|
||||||
(filterPriority !== 'all' ? 1 : 0) +
|
(filterPriority !== 'all' ? 1 : 0) +
|
||||||
(filterStatus !== 'all' ? 1 : 0)
|
(filterStatus !== 'OPEN' ? 1 : 0)
|
||||||
|
|
||||||
function toggle(pbiId: string) {
|
function toggle(pbiId: string) {
|
||||||
setCollapsed(prev => {
|
setCollapsed(prev => {
|
||||||
|
|
@ -535,16 +553,6 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, pr
|
||||||
setCollapsed(new Set())
|
setCollapsed(new Set())
|
||||||
}
|
}
|
||||||
|
|
||||||
function onlyNotDone() {
|
|
||||||
const auto = new Set<string>()
|
|
||||||
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 = (
|
const headerActions = (
|
||||||
<>
|
<>
|
||||||
{filterPriority !== 'all' && (
|
{filterPriority !== 'all' && (
|
||||||
|
|
@ -554,61 +562,45 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, pr
|
||||||
aria-label="Wis prioriteitsfilter"
|
aria-label="Wis prioriteitsfilter"
|
||||||
>
|
>
|
||||||
<Badge className={cn('text-xs', PRIORITY_COLORS[filterPriority])}>
|
<Badge className={cn('text-xs', PRIORITY_COLORS[filterPriority])}>
|
||||||
{PRIORITY_LABELS_SPRINT[filterPriority]}
|
{SHARED_PRIORITY_LABELS[filterPriority]}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span>×</span>
|
<span>×</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{filterStatus !== 'all' && (
|
{filterStatus !== 'OPEN' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilterStatus('all')}
|
onClick={() => setFilterStatus('OPEN')}
|
||||||
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
||||||
aria-label="Wis statusfilter"
|
aria-label="Wis statusfilter"
|
||||||
>
|
>
|
||||||
<Badge className={cn('text-[10px] px-1.5 py-0 border', STATUS_COLORS[filterStatus])}>
|
<Badge className={cn('text-[10px] px-1.5 py-0 border', filterStatus === 'all' ? '' : STATUS_COLORS[filterStatus])}>
|
||||||
{STATUS_LABELS[filterStatus]}
|
{filterStatus === 'all' ? 'Alle' : STATUS_LABELS[filterStatus]}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span>×</span>
|
<span>×</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<Popover>
|
<BacklogFilterPopover
|
||||||
<PopoverTrigger
|
open={filterPopoverOpen}
|
||||||
render={
|
onOpenChange={setFilterPopoverOpen}
|
||||||
<Button variant="outline" size="sm" className="h-7 text-xs">
|
filterPriority={filterPriority}
|
||||||
{`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`}
|
onFilterPriorityChange={setFilterPriority}
|
||||||
</Button>
|
filterStatus={filterStatus}
|
||||||
}
|
onFilterStatusChange={setFilterStatus}
|
||||||
/>
|
statusOptions={STATUS_OPTIONS_SPRINT}
|
||||||
<PopoverContent align="end" className="w-72 space-y-4">
|
sort={sort}
|
||||||
<FilterPills
|
onSortChange={setSort}
|
||||||
label="Prioriteit"
|
sortDir={sortDir}
|
||||||
options={PRIORITY_OPTIONS_SPRINT}
|
onSortDirChange={setSortDir}
|
||||||
value={filterPriority}
|
sortOptions={SORT_OPTIONS_SPRINT}
|
||||||
onChange={setFilterPriority}
|
activeFilterCount={activeFilterCount}
|
||||||
/>
|
resetDisabled={filterPriority === 'all' && filterStatus === 'OPEN' && sort === 'code' && sortDir === 'asc'}
|
||||||
<FilterPills
|
onReset={() => {
|
||||||
label="Status"
|
setFilterPriority('all')
|
||||||
options={STATUS_OPTIONS_SPRINT}
|
setFilterStatus('OPEN')
|
||||||
value={filterStatus}
|
setSort('code')
|
||||||
onChange={setFilterStatus}
|
setSortDir('asc')
|
||||||
/>
|
}}
|
||||||
<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')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Wis filters
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger onClick={collapseAll} className="text-muted-foreground hover:text-foreground p-0.5 rounded" aria-label="Alles inklappen">
|
<TooltipTrigger onClick={collapseAll} className="text-muted-foreground hover:text-foreground p-0.5 rounded" aria-label="Alles inklappen">
|
||||||
|
|
@ -622,12 +614,6 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, pr
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Alles uitklappen</TooltipContent>
|
<TooltipContent>Alles uitklappen</TooltipContent>
|
||||||
</Tooltip>
|
</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>
|
</TooltipProvider>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ import {
|
||||||
import { updateSprintGoalAction, updateSprintDatesAction, completeSprintAction, setAllSprintTasksDoneAction } from '@/actions/sprints'
|
import { updateSprintGoalAction, updateSprintDatesAction, completeSprintAction, setAllSprintTasksDoneAction } from '@/actions/sprints'
|
||||||
import type { SprintStory } from './sprint-backlog'
|
import type { SprintStory } from './sprint-backlog'
|
||||||
import { debugProps } from '@/lib/debug'
|
import { debugProps } from '@/lib/debug'
|
||||||
|
import { SprintSwitcher } from '@/components/shared/sprint-switcher'
|
||||||
|
import type { SprintSwitcherItem } from '@/lib/sprint-switcher-data'
|
||||||
|
|
||||||
interface Sprint {
|
interface Sprint {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -49,6 +51,9 @@ interface SprintHeaderProps {
|
||||||
sprint: Sprint
|
sprint: Sprint
|
||||||
isDemo: boolean
|
isDemo: boolean
|
||||||
sprintStories: SprintStory[]
|
sprintStories: SprintStory[]
|
||||||
|
switcherSprints: SprintSwitcherItem[]
|
||||||
|
switcherActiveSprint: SprintSwitcherItem | null
|
||||||
|
switcherBuildingSprintIds: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActionResult {
|
interface ActionResult {
|
||||||
|
|
@ -63,7 +68,7 @@ function toDateInputValue(d: Date | null) {
|
||||||
return d.toISOString().slice(0, 10)
|
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 [editingGoal, setEditingGoal] = useState(false)
|
||||||
const [editingDates, setEditingDates] = useState(false)
|
const [editingDates, setEditingDates] = useState(false)
|
||||||
const [completeOpen, setCompleteOpen] = useState(false)
|
const [completeOpen, setCompleteOpen] = useState(false)
|
||||||
|
|
@ -132,7 +137,7 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0" {...debugProps('sprint-header', 'SprintHeader', 'components/sprint/sprint-header.tsx')}>
|
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0" {...debugProps('sprint-header', 'SprintHeader', 'components/sprint/sprint-header.tsx')}>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground">{productName}</span>
|
<span className="text-xs text-muted-foreground">{productName}</span>
|
||||||
|
|
@ -162,7 +167,16 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0" data-debug-id="sprint-header__actions">
|
<div className="shrink-0">
|
||||||
|
<SprintSwitcher
|
||||||
|
productId={productId}
|
||||||
|
sprints={switcherSprints}
|
||||||
|
activeSprint={switcherActiveSprint}
|
||||||
|
buildingSprintIds={switcherBuildingSprintIds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 flex-1 shrink-0" data-debug-id="sprint-header__actions">
|
||||||
<DemoTooltip show={isDemo}>
|
<DemoTooltip show={isDemo}>
|
||||||
<Button size="sm" variant="ghost" disabled={isDemo} className="text-muted-foreground" data-debug-id="sprint-header__dates" onClick={() => !isDemo && setEditingDates(true)}>
|
<Button size="sm" variant="ghost" disabled={isDemo} className="text-muted-foreground" data-debug-id="sprint-header__dates" onClick={() => !isDemo && setEditingDates(true)}>
|
||||||
{sprint.start_date && sprint.end_date
|
{sprint.start_date && sprint.end_date
|
||||||
|
|
|
||||||
22
lib/use-local-storage-pref.ts
Normal file
22
lib/use-local-storage-pref.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
/**
|
||||||
|
* SSR-safe synchronous read of a localStorage value with a typed parser.
|
||||||
|
*
|
||||||
|
* Use inside `useState(() => readLocalStoragePref(...))` so the first render
|
||||||
|
* already has the persisted value — no useEffect-driven re-render flicker.
|
||||||
|
*
|
||||||
|
* On the server `window` is undefined → returns `fallback`. On the client the
|
||||||
|
* raw value is parsed; if the parser returns `null` the fallback is used.
|
||||||
|
* Hydration mismatches between server-rendered HTML (default) and the
|
||||||
|
* client-rendered tree (persisted) are accepted: React adapts the DOM in the
|
||||||
|
* same hydration pass without a visible flicker for matching values.
|
||||||
|
*/
|
||||||
|
export function readLocalStoragePref<T>(
|
||||||
|
key: string,
|
||||||
|
parse: (raw: string) => T | null,
|
||||||
|
fallback: T,
|
||||||
|
): T {
|
||||||
|
if (typeof window === 'undefined') return fallback
|
||||||
|
const raw = window.localStorage.getItem(key)
|
||||||
|
if (raw === null) return fallback
|
||||||
|
return parse(raw) ?? fallback
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "scrum4me",
|
"name": "scrum4me",
|
||||||
"version": "1.3.2",
|
"version": "1.3.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "npx --yes kill-port 3000 || exit 0",
|
"predev": "npx --yes kill-port 3000 || exit 0",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue