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:
parent
e8371b9f95
commit
10bf25dadd
2 changed files with 119 additions and 76 deletions
|
|
@ -19,15 +19,13 @@ interface JobsBoardProps {
|
|||
|
||||
type View = 'detail' | 'usage'
|
||||
|
||||
const ACTIVE_STATUS_OPTIONS: Array<{ value: ClaudeJobStatusApi | 'all'; label: string }> = [
|
||||
{ value: 'all', label: 'Alle' },
|
||||
const ACTIVE_STATUS_OPTIONS: Array<{ value: ClaudeJobStatusApi; label: string }> = [
|
||||
{ value: 'queued', label: 'Wacht…' },
|
||||
{ value: 'claimed', label: 'Geclaimd…' },
|
||||
{ value: 'running', label: 'Bezig…' },
|
||||
]
|
||||
|
||||
const DONE_STATUS_OPTIONS: Array<{ value: ClaudeJobStatusApi | 'all'; label: string }> = [
|
||||
{ value: 'all', label: 'Alle' },
|
||||
const DONE_STATUS_OPTIONS: Array<{ value: ClaudeJobStatusApi; label: string }> = [
|
||||
{ value: 'done', label: 'Klaar' },
|
||||
{ value: 'failed', label: 'Mislukt' },
|
||||
{ value: 'cancelled', label: 'Geannuleerd' },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
||||
import JobCard from './job-card'
|
||||
|
|
@ -10,9 +10,6 @@ import { cn } from '@/lib/utils'
|
|||
import type { JobWithRelations } from '@/actions/jobs-page'
|
||||
import type { ClaudeJobKind } from '@prisma/client'
|
||||
|
||||
type KindFilter = ClaudeJobKind | 'all'
|
||||
type StatusFilter = ClaudeJobStatusApi | 'all'
|
||||
|
||||
const KIND_LABELS: Record<ClaudeJobKind, string> = {
|
||||
TASK_IMPLEMENTATION: 'TAAK',
|
||||
SPRINT_IMPLEMENTATION: 'SPRINT',
|
||||
|
|
@ -21,8 +18,7 @@ const KIND_LABELS: Record<ClaudeJobKind, string> = {
|
|||
PLAN_CHAT: 'CHAT',
|
||||
}
|
||||
|
||||
const KIND_OPTIONS: Array<{ value: KindFilter; label: string }> = [
|
||||
{ value: 'all', label: 'Alle' },
|
||||
const KIND_OPTIONS: Array<{ value: ClaudeJobKind; label: string }> = [
|
||||
{ value: 'TASK_IMPLEMENTATION', label: 'TAAK' },
|
||||
{ value: 'SPRINT_IMPLEMENTATION', label: 'SPRINT' },
|
||||
{ value: 'IDEA_GRILL', label: 'GRILL' },
|
||||
|
|
@ -30,56 +26,82 @@ const KIND_OPTIONS: Array<{ value: KindFilter; label: string }> = [
|
|||
{ value: 'PLAN_CHAT', label: 'CHAT' },
|
||||
]
|
||||
|
||||
const KIND_VALUES = new Set<ClaudeJobKind>([
|
||||
'TASK_IMPLEMENTATION',
|
||||
'SPRINT_IMPLEMENTATION',
|
||||
'IDEA_GRILL',
|
||||
'IDEA_MAKE_PLAN',
|
||||
'PLAN_CHAT',
|
||||
])
|
||||
const KIND_VALUES = new Set<ClaudeJobKind>(KIND_OPTIONS.map((o) => o.value))
|
||||
|
||||
function FilterPills<T extends string>({
|
||||
function MultiFilterPills<T extends string>({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
selected,
|
||||
onToggle,
|
||||
onClear,
|
||||
}: {
|
||||
label: string
|
||||
options: Array<{ value: T; label: string }>
|
||||
value: T
|
||||
onChange: (v: T) => void
|
||||
selected: Set<T>
|
||||
onToggle: (v: T) => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
const allActive = selected.size === 0
|
||||
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
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className={cn(
|
||||
'text-xs px-2.5 py-1 rounded-full border transition-colors',
|
||||
allActive
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-transparent border-border hover:bg-surface-container'
|
||||
)}
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
{options.map((opt) => {
|
||||
const active = selected.has(opt.value)
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.value)}
|
||||
onClick={() => onToggle(opt.value)}
|
||||
className={cn(
|
||||
'text-xs px-2.5 py-1 rounded-full border transition-colors',
|
||||
value === opt.value
|
||||
active
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-transparent border-border hover:bg-surface-container'
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</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 {
|
||||
title: string
|
||||
jobs: JobWithRelations[]
|
||||
selectedJobId: string | null
|
||||
onSelect: (id: string) => void
|
||||
storageKeyPrefix: string
|
||||
statusOptions: Array<{ value: StatusFilter; label: string }>
|
||||
statusOptions: Array<{ value: ClaudeJobStatusApi; label: string }>
|
||||
emptyText: string
|
||||
}
|
||||
|
||||
|
|
@ -92,69 +114,90 @@ export default function JobsColumn({
|
|||
statusOptions,
|
||||
emptyText,
|
||||
}: JobsColumnProps) {
|
||||
const [filterKind, setFilterKind] = useState<KindFilter>('all')
|
||||
const [filterStatus, setFilterStatus] = useState<StatusFilter>('all')
|
||||
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`
|
||||
|
||||
const statusValues = useMemo(
|
||||
() => new Set<ClaudeJobStatusApi>(statusOptions.map((o) => o.value)),
|
||||
[statusOptions]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const savedKind = localStorage.getItem(kindKey)
|
||||
if (savedKind && (savedKind === 'all' || KIND_VALUES.has(savedKind as ClaudeJobKind))) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setFilterKind(savedKind as KindFilter)
|
||||
}
|
||||
const savedStatus = localStorage.getItem(statusKey)
|
||||
if (savedStatus && statusOptions.some((o) => o.value === savedStatus)) {
|
||||
setFilterStatus(savedStatus as StatusFilter)
|
||||
}
|
||||
// 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))
|
||||
setPrefsLoaded(true)
|
||||
}, [kindKey, statusKey, statusOptions])
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
}, [kindKey, statusKey, statusValues])
|
||||
|
||||
useEffect(() => {
|
||||
if (prefsLoaded) localStorage.setItem(kindKey, filterKind)
|
||||
}, [filterKind, prefsLoaded, kindKey])
|
||||
if (prefsLoaded) localStorage.setItem(kindKey, setToCsv(filterKinds))
|
||||
}, [filterKinds, prefsLoaded, kindKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (prefsLoaded) localStorage.setItem(statusKey, filterStatus)
|
||||
}, [filterStatus, prefsLoaded, statusKey])
|
||||
if (prefsLoaded) localStorage.setItem(statusKey, setToCsv(filterStatuses))
|
||||
}, [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) => {
|
||||
if (filterKind !== 'all' && j.kind !== filterKind) return false
|
||||
if (filterStatus !== 'all' && jobStatusToApi(j.status) !== filterStatus) return false
|
||||
if (filterKinds.size > 0 && !filterKinds.has(j.kind)) return false
|
||||
if (filterStatuses.size > 0 && !filterStatuses.has(jobStatusToApi(j.status))) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const activeFilterCount = (filterKind !== 'all' ? 1 : 0) + (filterStatus !== 'all' ? 1 : 0)
|
||||
const activeFilterCount = filterKinds.size + filterStatuses.size
|
||||
|
||||
return (
|
||||
<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">
|
||||
<span className="text-xs font-medium text-muted-foreground px-1">{title}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{filterKind !== 'all' && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap justify-end">
|
||||
{Array.from(filterKinds).map((k) => (
|
||||
<button
|
||||
key={`k-${k}`}
|
||||
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"
|
||||
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>
|
||||
</button>
|
||||
)}
|
||||
{filterStatus !== 'all' && (
|
||||
))}
|
||||
{Array.from(filterStatuses).map((s) => (
|
||||
<button
|
||||
key={`s-${s}`}
|
||||
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"
|
||||
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>
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
|
|
@ -164,17 +207,19 @@ export default function JobsColumn({
|
|||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-72 space-y-4">
|
||||
<FilterPills
|
||||
<MultiFilterPills
|
||||
label="Soort"
|
||||
options={KIND_OPTIONS}
|
||||
value={filterKind}
|
||||
onChange={setFilterKind}
|
||||
selected={filterKinds}
|
||||
onToggle={toggleKind}
|
||||
onClear={() => setFilterKinds(new Set())}
|
||||
/>
|
||||
<FilterPills
|
||||
<MultiFilterPills
|
||||
label="Status"
|
||||
options={statusOptions}
|
||||
value={filterStatus}
|
||||
onChange={setFilterStatus}
|
||||
selected={filterStatuses}
|
||||
onToggle={toggleStatus}
|
||||
onClear={() => setFilterStatuses(new Set())}
|
||||
/>
|
||||
<div className="flex justify-end pt-1 border-t border-border">
|
||||
<Button
|
||||
|
|
@ -184,8 +229,8 @@ export default function JobsColumn({
|
|||
className="h-7 text-xs"
|
||||
disabled={activeFilterCount === 0}
|
||||
onClick={() => {
|
||||
setFilterKind('all')
|
||||
setFilterStatus('all')
|
||||
setFilterKinds(new Set())
|
||||
setFilterStatuses(new Set())
|
||||
}}
|
||||
>
|
||||
Wis filters
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue