From 10bf25dadd7a3dc5075ea4b2cb8c242675b8e8af Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 22:21:09 +0200 Subject: [PATCH] feat(PBI-61): multi-select op kind- en status-filter (#159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- components/jobs/jobs-board.tsx | 6 +- components/jobs/jobs-column.tsx | 189 ++++++++++++++++++++------------ 2 files changed, 119 insertions(+), 76 deletions(-) diff --git a/components/jobs/jobs-board.tsx b/components/jobs/jobs-board.tsx index 5fcb3c0..6fd3024 100644 --- a/components/jobs/jobs-board.tsx +++ b/components/jobs/jobs-board.tsx @@ -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' }, diff --git a/components/jobs/jobs-column.tsx b/components/jobs/jobs-column.tsx index b01e708..43c3965 100644 --- a/components/jobs/jobs-column.tsx +++ b/components/jobs/jobs-column.tsx @@ -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 = { TASK_IMPLEMENTATION: 'TAAK', SPRINT_IMPLEMENTATION: 'SPRINT', @@ -21,8 +18,7 @@ const KIND_LABELS: Record = { 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([ - 'TASK_IMPLEMENTATION', - 'SPRINT_IMPLEMENTATION', - 'IDEA_GRILL', - 'IDEA_MAKE_PLAN', - 'PLAN_CHAT', -]) +const KIND_VALUES = new Set(KIND_OPTIONS.map((o) => o.value)) -function FilterPills({ +function MultiFilterPills({ label, options, - value, - onChange, + selected, + onToggle, + onClear, }: { label: string options: Array<{ value: T; label: string }> - value: T - onChange: (v: T) => void + selected: Set + onToggle: (v: T) => void + onClear: () => void }) { + const allActive = selected.size === 0 return (

{label}

- {options.map((opt) => ( - - ))} + + {options.map((opt) => { + const active = selected.has(opt.value) + return ( + + ) + })}
) } +function parseCsv(raw: string | null, allowed: Set): Set { + if (!raw) return new Set() + const out = new Set() + 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(s: Set): 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('all') - const [filterStatus, setFilterStatus] = useState('all') + const [filterKinds, setFilterKinds] = useState>(() => new Set()) + const [filterStatuses, setFilterStatuses] = useState>(() => new Set()) const [prefsLoaded, setPrefsLoaded] = useState(false) const kindKey = `${storageKeyPrefix}_filter_kind` const statusKey = `${storageKeyPrefix}_filter_status` + const statusValues = useMemo( + () => new Set(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(localStorage.getItem(kindKey), KIND_VALUES)) + setFilterStatuses(parseCsv(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 (
{title} -
- {filterKind !== 'all' && ( +
+ {Array.from(filterKinds).map((k) => ( - )} - {filterStatus !== 'all' && ( + ))} + {Array.from(filterStatuses).map((s) => ( - )} + ))} - setFilterKinds(new Set())} /> - setFilterStatuses(new Set())} />