feat(PBI-61): multi-select op kind- en status-filter (#159)

- Filter-pills zijn nu toggle-knoppen; meerdere waardes per dimensie selecteerbaar
- "Alle"-pill wist de selectie binnen die dimensie
- Eén active-badge per geselecteerde waarde, klikbaar om losse selectie te wissen
- localStorage formaat is nu CSV met whitelist-validatie (oude 'all'-waarde valt vanzelf weg → leeg = geen filter)
- Filtercount in trigger toont som van actieve selecties

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-07 22:21:09 +02:00 committed by GitHub
parent e8371b9f95
commit 10bf25dadd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 119 additions and 76 deletions

View file

@ -19,15 +19,13 @@ interface JobsBoardProps {
type View = 'detail' | 'usage' type View = 'detail' | 'usage'
const ACTIVE_STATUS_OPTIONS: Array<{ value: ClaudeJobStatusApi | 'all'; label: string }> = [ const ACTIVE_STATUS_OPTIONS: Array<{ value: ClaudeJobStatusApi; label: string }> = [
{ value: 'all', label: 'Alle' },
{ value: 'queued', label: 'Wacht…' }, { value: 'queued', label: 'Wacht…' },
{ value: 'claimed', label: 'Geclaimd…' }, { value: 'claimed', label: 'Geclaimd…' },
{ value: 'running', label: 'Bezig…' }, { value: 'running', label: 'Bezig…' },
] ]
const DONE_STATUS_OPTIONS: Array<{ value: ClaudeJobStatusApi | 'all'; label: string }> = [ const DONE_STATUS_OPTIONS: Array<{ value: ClaudeJobStatusApi; label: string }> = [
{ value: 'all', label: 'Alle' },
{ value: 'done', label: 'Klaar' }, { value: 'done', label: 'Klaar' },
{ value: 'failed', label: 'Mislukt' }, { value: 'failed', label: 'Mislukt' },
{ value: 'cancelled', label: 'Geannuleerd' }, { value: 'cancelled', label: 'Geannuleerd' },

View file

@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import JobCard from './job-card' import JobCard from './job-card'
@ -10,9 +10,6 @@ import { cn } from '@/lib/utils'
import type { JobWithRelations } from '@/actions/jobs-page' import type { JobWithRelations } from '@/actions/jobs-page'
import type { ClaudeJobKind } from '@prisma/client' import type { ClaudeJobKind } from '@prisma/client'
type KindFilter = ClaudeJobKind | 'all'
type StatusFilter = ClaudeJobStatusApi | 'all'
const KIND_LABELS: Record<ClaudeJobKind, string> = { const KIND_LABELS: Record<ClaudeJobKind, string> = {
TASK_IMPLEMENTATION: 'TAAK', TASK_IMPLEMENTATION: 'TAAK',
SPRINT_IMPLEMENTATION: 'SPRINT', SPRINT_IMPLEMENTATION: 'SPRINT',
@ -21,8 +18,7 @@ const KIND_LABELS: Record<ClaudeJobKind, string> = {
PLAN_CHAT: 'CHAT', PLAN_CHAT: 'CHAT',
} }
const KIND_OPTIONS: Array<{ value: KindFilter; label: string }> = [ const KIND_OPTIONS: Array<{ value: ClaudeJobKind; label: string }> = [
{ value: 'all', label: 'Alle' },
{ value: 'TASK_IMPLEMENTATION', label: 'TAAK' }, { value: 'TASK_IMPLEMENTATION', label: 'TAAK' },
{ value: 'SPRINT_IMPLEMENTATION', label: 'SPRINT' }, { value: 'SPRINT_IMPLEMENTATION', label: 'SPRINT' },
{ value: 'IDEA_GRILL', label: 'GRILL' }, { value: 'IDEA_GRILL', label: 'GRILL' },
@ -30,56 +26,82 @@ const KIND_OPTIONS: Array<{ value: KindFilter; label: string }> = [
{ value: 'PLAN_CHAT', label: 'CHAT' }, { value: 'PLAN_CHAT', label: 'CHAT' },
] ]
const KIND_VALUES = new Set<ClaudeJobKind>([ const KIND_VALUES = new Set<ClaudeJobKind>(KIND_OPTIONS.map((o) => o.value))
'TASK_IMPLEMENTATION',
'SPRINT_IMPLEMENTATION',
'IDEA_GRILL',
'IDEA_MAKE_PLAN',
'PLAN_CHAT',
])
function FilterPills<T extends string>({ function MultiFilterPills<T extends string>({
label, label,
options, options,
value, selected,
onChange, onToggle,
onClear,
}: { }: {
label: string label: string
options: Array<{ value: T; label: string }> options: Array<{ value: T; label: string }>
value: T selected: Set<T>
onChange: (v: T) => void onToggle: (v: T) => void
onClear: () => void
}) { }) {
const allActive = selected.size === 0
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">{label}</p> <p className="text-xs font-medium text-muted-foreground">{label}</p>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{options.map((opt) => ( <button
<button type="button"
key={opt.value} onClick={onClear}
type="button" className={cn(
onClick={() => onChange(opt.value)} 'text-xs px-2.5 py-1 rounded-full border transition-colors',
className={cn( allActive
'text-xs px-2.5 py-1 rounded-full border transition-colors', ? 'bg-primary text-primary-foreground border-primary'
value === opt.value : 'bg-transparent border-border hover:bg-surface-container'
? 'bg-primary text-primary-foreground border-primary' )}
: 'bg-transparent border-border hover:bg-surface-container' >
)} Alle
> </button>
{opt.label} {options.map((opt) => {
</button> const active = selected.has(opt.value)
))} return (
<button
key={opt.value}
type="button"
onClick={() => onToggle(opt.value)}
className={cn(
'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'
)}
>
{opt.label}
</button>
)
})}
</div> </div>
</div> </div>
) )
} }
function parseCsv<T extends string>(raw: string | null, allowed: Set<T>): Set<T> {
if (!raw) return new Set()
const out = new Set<T>()
for (const part of raw.split(',')) {
const v = part.trim()
if (v && allowed.has(v as T)) out.add(v as T)
}
return out
}
function setToCsv<T extends string>(s: Set<T>): string {
return Array.from(s).join(',')
}
interface JobsColumnProps { interface JobsColumnProps {
title: string title: string
jobs: JobWithRelations[] jobs: JobWithRelations[]
selectedJobId: string | null selectedJobId: string | null
onSelect: (id: string) => void onSelect: (id: string) => void
storageKeyPrefix: string storageKeyPrefix: string
statusOptions: Array<{ value: StatusFilter; label: string }> statusOptions: Array<{ value: ClaudeJobStatusApi; label: string }>
emptyText: string emptyText: string
} }
@ -92,69 +114,90 @@ export default function JobsColumn({
statusOptions, statusOptions,
emptyText, emptyText,
}: JobsColumnProps) { }: JobsColumnProps) {
const [filterKind, setFilterKind] = useState<KindFilter>('all') const [filterKinds, setFilterKinds] = useState<Set<ClaudeJobKind>>(() => new Set())
const [filterStatus, setFilterStatus] = useState<StatusFilter>('all') const [filterStatuses, setFilterStatuses] = useState<Set<ClaudeJobStatusApi>>(() => new Set())
const [prefsLoaded, setPrefsLoaded] = useState(false) 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`
const statusValues = useMemo(
() => new Set<ClaudeJobStatusApi>(statusOptions.map((o) => o.value)),
[statusOptions]
)
useEffect(() => { useEffect(() => {
const savedKind = localStorage.getItem(kindKey) // Hydratie van localStorage post-mount: zetters intentioneel, voorkomt SSR-mismatch.
if (savedKind && (savedKind === 'all' || KIND_VALUES.has(savedKind as ClaudeJobKind))) { /* eslint-disable react-hooks/set-state-in-effect */
// eslint-disable-next-line react-hooks/set-state-in-effect setFilterKinds(parseCsv<ClaudeJobKind>(localStorage.getItem(kindKey), KIND_VALUES))
setFilterKind(savedKind as KindFilter) setFilterStatuses(parseCsv<ClaudeJobStatusApi>(localStorage.getItem(statusKey), statusValues))
}
const savedStatus = localStorage.getItem(statusKey)
if (savedStatus && statusOptions.some((o) => o.value === savedStatus)) {
setFilterStatus(savedStatus as StatusFilter)
}
setPrefsLoaded(true) setPrefsLoaded(true)
}, [kindKey, statusKey, statusOptions]) /* eslint-enable react-hooks/set-state-in-effect */
}, [kindKey, statusKey, statusValues])
useEffect(() => { useEffect(() => {
if (prefsLoaded) localStorage.setItem(kindKey, filterKind) if (prefsLoaded) localStorage.setItem(kindKey, setToCsv(filterKinds))
}, [filterKind, prefsLoaded, kindKey]) }, [filterKinds, prefsLoaded, kindKey])
useEffect(() => { useEffect(() => {
if (prefsLoaded) localStorage.setItem(statusKey, filterStatus) if (prefsLoaded) localStorage.setItem(statusKey, setToCsv(filterStatuses))
}, [filterStatus, prefsLoaded, statusKey]) }, [filterStatuses, prefsLoaded, statusKey])
function toggleKind(v: ClaudeJobKind) {
setFilterKinds((prev) => {
const next = new Set(prev)
if (next.has(v)) next.delete(v)
else next.add(v)
return next
})
}
function toggleStatus(v: ClaudeJobStatusApi) {
setFilterStatuses((prev) => {
const next = new Set(prev)
if (next.has(v)) next.delete(v)
else next.add(v)
return next
})
}
const filtered = jobs.filter((j) => { const filtered = jobs.filter((j) => {
if (filterKind !== 'all' && j.kind !== filterKind) return false if (filterKinds.size > 0 && !filterKinds.has(j.kind)) return false
if (filterStatus !== 'all' && jobStatusToApi(j.status) !== filterStatus) return false if (filterStatuses.size > 0 && !filterStatuses.has(jobStatusToApi(j.status))) return false
return true return true
}) })
const activeFilterCount = (filterKind !== 'all' ? 1 : 0) + (filterStatus !== 'all' ? 1 : 0) const activeFilterCount = filterKinds.size + filterStatuses.size
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex items-center justify-between gap-2 px-2 py-1.5 border-b border-border bg-surface-container-low shrink-0"> <div className="flex items-center justify-between gap-2 px-2 py-1.5 border-b border-border bg-surface-container-low shrink-0">
<span className="text-xs font-medium text-muted-foreground px-1">{title}</span> <span className="text-xs font-medium text-muted-foreground px-1">{title}</span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 flex-wrap justify-end">
{filterKind !== 'all' && ( {Array.from(filterKinds).map((k) => (
<button <button
key={`k-${k}`}
type="button" type="button"
onClick={() => setFilterKind('all')} onClick={() => toggleKind(k)}
className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded border bg-muted text-muted-foreground hover:bg-surface-container font-mono" className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded border bg-muted text-muted-foreground hover:bg-surface-container font-mono"
aria-label="Wis kind-filter" aria-label={`Wis filter ${KIND_LABELS[k]}`}
> >
<span>{KIND_LABELS[filterKind]}</span> <span>{KIND_LABELS[k]}</span>
<span aria-hidden>×</span> <span aria-hidden>×</span>
</button> </button>
)} ))}
{filterStatus !== 'all' && ( {Array.from(filterStatuses).map((s) => (
<button <button
key={`s-${s}`}
type="button" type="button"
onClick={() => setFilterStatus('all')} onClick={() => toggleStatus(s)}
className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full border bg-muted text-muted-foreground hover:bg-surface-container" className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full border bg-muted text-muted-foreground hover:bg-surface-container"
aria-label="Wis status-filter" aria-label={`Wis filter ${JOB_STATUS_LABELS[s]}`}
> >
<span>{JOB_STATUS_LABELS[filterStatus as ClaudeJobStatusApi]}</span> <span>{JOB_STATUS_LABELS[s]}</span>
<span aria-hidden>×</span> <span aria-hidden>×</span>
</button> </button>
)} ))}
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger
render={ render={
@ -164,17 +207,19 @@ export default function JobsColumn({
} }
/> />
<PopoverContent align="end" className="w-72 space-y-4"> <PopoverContent align="end" className="w-72 space-y-4">
<FilterPills <MultiFilterPills
label="Soort" label="Soort"
options={KIND_OPTIONS} options={KIND_OPTIONS}
value={filterKind} selected={filterKinds}
onChange={setFilterKind} onToggle={toggleKind}
onClear={() => setFilterKinds(new Set())}
/> />
<FilterPills <MultiFilterPills
label="Status" label="Status"
options={statusOptions} options={statusOptions}
value={filterStatus} selected={filterStatuses}
onChange={setFilterStatus} onToggle={toggleStatus}
onClear={() => setFilterStatuses(new Set())}
/> />
<div className="flex justify-end pt-1 border-t border-border"> <div className="flex justify-end pt-1 border-t border-border">
<Button <Button
@ -184,8 +229,8 @@ export default function JobsColumn({
className="h-7 text-xs" className="h-7 text-xs"
disabled={activeFilterCount === 0} disabled={activeFilterCount === 0}
onClick={() => { onClick={() => {
setFilterKind('all') setFilterKinds(new Set())
setFilterStatus('all') setFilterStatuses(new Set())
}} }}
> >
Wis filters Wis filters