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 { 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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<div id="wrapper2" className="flex flex-col h-full">
|
||||
<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
|
||||
productId={id}
|
||||
productName={product.name}
|
||||
sprint={sprint}
|
||||
isDemo={isDemo}
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
<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')
|
||||
}}
|
||||
>
|
||||
Wis filters
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
/>
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -115,10 +115,6 @@ export default function JobsColumn({
|
|||
statusOptions,
|
||||
emptyText,
|
||||
}: 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 statusKey = `${storageKeyPrefix}_filter_status`
|
||||
|
||||
|
|
@ -127,8 +123,11 @@ export default function JobsColumn({
|
|||
[statusOptions]
|
||||
)
|
||||
|
||||
const [filterKinds, setFilterKinds] = useState<Set<ClaudeJobKind>>(() => new Set())
|
||||
const [filterStatuses, setFilterStatuses] = useState<Set<ClaudeJobStatusApi>>(() => new Set())
|
||||
const [prefsLoaded, setPrefsLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Hydratie van localStorage post-mount: zetters intentioneel, voorkomt SSR-mismatch.
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
setFilterKinds(parseCsv<ClaudeJobKind>(localStorage.getItem(kindKey), KIND_VALUES))
|
||||
setFilterStatuses(parseCsv<ClaudeJobStatusApi>(localStorage.getItem(statusKey), statusValues))
|
||||
|
|
@ -136,13 +135,8 @@ export default function JobsColumn({
|
|||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
}, [kindKey, statusKey, statusValues])
|
||||
|
||||
useEffect(() => {
|
||||
if (prefsLoaded) localStorage.setItem(kindKey, setToCsv(filterKinds))
|
||||
}, [filterKinds, prefsLoaded, kindKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (prefsLoaded) localStorage.setItem(statusKey, setToCsv(filterStatuses))
|
||||
}, [filterStatuses, prefsLoaded, statusKey])
|
||||
useEffect(() => { if (prefsLoaded) localStorage.setItem(kindKey, setToCsv(filterKinds)) }, [filterKinds, prefsLoaded, kindKey])
|
||||
useEffect(() => { if (prefsLoaded) localStorage.setItem(statusKey, setToCsv(filterStatuses)) }, [filterStatuses, prefsLoaded, statusKey])
|
||||
|
||||
function toggleKind(v: ClaudeJobKind) {
|
||||
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>
|
||||
<Tooltip>
|
||||
<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"
|
||||
>
|
||||
Geen sprints
|
||||
|
|
@ -90,7 +90,7 @@ export function SprintSwitcher({
|
|||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
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]">
|
||||
{activeSprint ? activeSprint.code : 'Selecteer sprint'}
|
||||
|
|
@ -98,7 +98,7 @@ export function SprintSwitcher({
|
|||
{activeSprint && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px]',
|
||||
'text-sm',
|
||||
buildingSet.has(activeSprint.id) ? 'text-warning' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
|
|
@ -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"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -128,7 +128,7 @@ export function SprintSwitcher({
|
|||
</button>
|
||||
<DropdownMenuSeparator />
|
||||
{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
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -141,11 +141,11 @@ export function SprintSwitcher({
|
|||
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-xs truncate flex-1">{s.sprint_goal}</span>
|
||||
<span className="text-sm font-medium shrink-0">{s.code}</span>
|
||||
<span className="text-sm truncate flex-1">{s.sprint_goal}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] shrink-0',
|
||||
'text-sm shrink-0',
|
||||
buildingSet.has(s.id) ? 'text-warning' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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<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'
|
||||
|
||||
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<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>
|
||||
)
|
||||
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<PbiStatusApi, number> = {
|
||||
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<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 [pbiDialogState, setPbiDialogState] = useState<PbiDialogState | null>(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<number | 'all'>(
|
||||
'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<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')))
|
||||
}
|
||||
const savedStatus = localStorage.getItem('scrum4me:sprint_pb_filter_status')
|
||||
if (savedStatus === 'OPEN' || savedStatus === 'IN_SPRINT' || savedStatus === 'DONE') {
|
||||
|
||||
setFilterStatus(savedStatus)
|
||||
} catch { /* ignore malformed JSON */ }
|
||||
}
|
||||
|
||||
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<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 = (
|
||||
<>
|
||||
{filterPriority !== 'all' && (
|
||||
|
|
@ -554,61 +562,45 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, pr
|
|||
aria-label="Wis prioriteitsfilter"
|
||||
>
|
||||
<Badge className={cn('text-xs', PRIORITY_COLORS[filterPriority])}>
|
||||
{PRIORITY_LABELS_SPRINT[filterPriority]}
|
||||
{SHARED_PRIORITY_LABELS[filterPriority]}
|
||||
</Badge>
|
||||
<span>×</span>
|
||||
</button>
|
||||
)}
|
||||
{filterStatus !== 'all' && (
|
||||
{filterStatus !== 'OPEN' && (
|
||||
<button
|
||||
onClick={() => setFilterStatus('all')}
|
||||
onClick={() => setFilterStatus('OPEN')}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
aria-label="Wis statusfilter"
|
||||
>
|
||||
<Badge className={cn('text-[10px] px-1.5 py-0 border', STATUS_COLORS[filterStatus])}>
|
||||
{STATUS_LABELS[filterStatus]}
|
||||
<Badge className={cn('text-[10px] px-1.5 py-0 border', filterStatus === 'all' ? '' : STATUS_COLORS[filterStatus])}>
|
||||
{filterStatus === 'all' ? 'Alle' : STATUS_LABELS[filterStatus]}
|
||||
</Badge>
|
||||
<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">
|
||||
<FilterPills
|
||||
label="Prioriteit"
|
||||
options={PRIORITY_OPTIONS_SPRINT}
|
||||
value={filterPriority}
|
||||
onChange={setFilterPriority}
|
||||
/>
|
||||
<FilterPills
|
||||
label="Status"
|
||||
options={STATUS_OPTIONS_SPRINT}
|
||||
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={() => {
|
||||
<BacklogFilterPopover
|
||||
open={filterPopoverOpen}
|
||||
onOpenChange={setFilterPopoverOpen}
|
||||
filterPriority={filterPriority}
|
||||
onFilterPriorityChange={setFilterPriority}
|
||||
filterStatus={filterStatus}
|
||||
onFilterStatusChange={setFilterStatus}
|
||||
statusOptions={STATUS_OPTIONS_SPRINT}
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
sortDir={sortDir}
|
||||
onSortDirChange={setSortDir}
|
||||
sortOptions={SORT_OPTIONS_SPRINT}
|
||||
activeFilterCount={activeFilterCount}
|
||||
resetDisabled={filterPriority === 'all' && filterStatus === 'OPEN' && sort === 'code' && sortDir === 'asc'}
|
||||
onReset={() => {
|
||||
setFilterPriority('all')
|
||||
setFilterStatus('all')
|
||||
setFilterStatus('OPEN')
|
||||
setSort('code')
|
||||
setSortDir('asc')
|
||||
}}
|
||||
>
|
||||
Wis filters
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<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>
|
||||
<TooltipContent>Alles uitklappen</TooltipContent>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{productName}</span>
|
||||
|
|
@ -162,7 +167,16 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
|
|||
)}
|
||||
</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}>
|
||||
<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
|
||||
|
|
|
|||
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",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"predev": "npx --yes kill-port 3000 || exit 0",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue