From f79aef077d488022a48377b927515f13ddb03fd1 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 7 May 2026 21:40:31 +0200 Subject: [PATCH] feat(PBI-61): filter popover + created_at op job-kaart - Nieuwe JobsColumn met Kind/Status filter-popover per kolom (Actief/Klaar) - Filterstate persistent in localStorage (whitelist-validatie tegen corrupte waardes) - Active-filter badges in kolomheader, klikbaar om te wissen - Aanmaakdatum + tijd rechtsonder op elke JobCard (nl-NL short formaat) Co-Authored-By: Claude Opus 4.7 (1M context) --- components/jobs/job-card.tsx | 10 +- components/jobs/jobs-board.tsx | 78 +++++------ components/jobs/jobs-column.tsx | 228 ++++++++++++++++++++++++++++++++ 3 files changed, 270 insertions(+), 46 deletions(-) create mode 100644 components/jobs/jobs-column.tsx diff --git a/components/jobs/job-card.tsx b/components/jobs/job-card.tsx index 0d888a6..5dc5e9d 100644 --- a/components/jobs/job-card.tsx +++ b/components/jobs/job-card.tsx @@ -19,6 +19,7 @@ interface JobCardProps { branch?: string | null error?: string | null summary?: string | null + createdAt: Date | string isSelected?: boolean onClick?: () => void } @@ -33,7 +34,7 @@ const KIND_LABELS: Record = { export default function JobCard({ kind, status, taskCode, taskTitle, ideaCode, ideaTitle, - sprintGoal, sprintCode, productName, branch, error, isSelected, onClick, + sprintGoal, sprintCode, productName, branch, error, createdAt, isSelected, onClick, }: JobCardProps) { let titleText: string if (kind === 'TASK_IMPLEMENTATION') { @@ -70,7 +71,12 @@ export default function JobCard({

{titleText}

-

{detailText}

+
+

{detailText}

+ + {new Date(createdAt).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })} + +
) } diff --git a/components/jobs/jobs-board.tsx b/components/jobs/jobs-board.tsx index 498ba60..5fcb3c0 100644 --- a/components/jobs/jobs-board.tsx +++ b/components/jobs/jobs-board.tsx @@ -3,12 +3,13 @@ import { useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { SplitPane } from '@/components/split-pane/split-pane' -import JobCard from './job-card' +import JobsColumn from './jobs-column' import JobDetailPane from './job-detail-pane' import JobUsagePane from './job-usage-pane' import SprintSubTasksPane from './sprint-sub-tasks-pane' import { useJobsStore } from '@/stores/jobs-store' import useJobsRealtime from '@/hooks/use-jobs-realtime' +import type { ClaudeJobStatusApi } from '@/lib/job-status' import type { JobWithRelations } from '@/actions/jobs-page' interface JobsBoardProps { @@ -18,23 +19,20 @@ interface JobsBoardProps { type View = 'detail' | 'usage' -function jobToCardProps(j: JobWithRelations) { - return { - id: j.id, - kind: j.kind, - status: j.status, - taskCode: j.taskCode, - taskTitle: j.taskTitle, - ideaCode: j.ideaCode, - ideaTitle: j.ideaTitle, - sprintGoal: j.sprintGoal, - sprintCode: j.sprintCode, - productName: j.productName, - branch: j.branch, - error: j.error, - summary: j.summary, - } -} +const ACTIVE_STATUS_OPTIONS: Array<{ value: ClaudeJobStatusApi | 'all'; label: string }> = [ + { value: 'all', label: 'Alle' }, + { 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' }, + { value: 'done', label: 'Klaar' }, + { value: 'failed', label: 'Mislukt' }, + { value: 'cancelled', label: 'Geannuleerd' }, + { value: 'skipped', label: 'Overgeslagen' }, +] export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBoardProps) { const { activeJobs, doneJobs, selectedJobId, initJobs, setSelectedJobId } = useJobsStore() @@ -47,19 +45,15 @@ export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBo const selectedJob = [...activeJobs, ...doneJobs].find(j => j.id === selectedJobId) ?? null const leftPane = ( -
- {activeJobs.map(j => ( - setSelectedJobId(j.id)} - /> - ))} - {activeJobs.length === 0 && ( -

Geen actieve jobs

- )} -
+ ) const middlePane = ( @@ -91,19 +85,15 @@ export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBo ) const rightPane = ( -
- {doneJobs.map(j => ( - setSelectedJobId(j.id)} - /> - ))} - {doneJobs.length === 0 && ( -

Nog geen afgeronde jobs

- )} -
+ ) return ( diff --git a/components/jobs/jobs-column.tsx b/components/jobs/jobs-column.tsx new file mode 100644 index 0000000..b01e708 --- /dev/null +++ b/components/jobs/jobs-column.tsx @@ -0,0 +1,228 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Button } from '@/components/ui/button' +import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' +import JobCard from './job-card' +import { JOB_STATUS_LABELS } from '@/components/shared/job-status' +import { jobStatusToApi, type ClaudeJobStatusApi } from '@/lib/job-status' +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', + IDEA_GRILL: 'GRILL', + IDEA_MAKE_PLAN: 'PLAN', + PLAN_CHAT: 'CHAT', +} + +const KIND_OPTIONS: Array<{ value: KindFilter; label: string }> = [ + { value: 'all', label: 'Alle' }, + { value: 'TASK_IMPLEMENTATION', label: 'TAAK' }, + { value: 'SPRINT_IMPLEMENTATION', label: 'SPRINT' }, + { value: 'IDEA_GRILL', label: 'GRILL' }, + { value: 'IDEA_MAKE_PLAN', label: 'PLAN' }, + { value: 'PLAN_CHAT', label: 'CHAT' }, +] + +const KIND_VALUES = new Set([ + 'TASK_IMPLEMENTATION', + 'SPRINT_IMPLEMENTATION', + 'IDEA_GRILL', + 'IDEA_MAKE_PLAN', + 'PLAN_CHAT', +]) + +function FilterPills({ + label, + options, + value, + onChange, +}: { + label: string + options: Array<{ value: T; label: string }> + value: T + onChange: (v: T) => void +}) { + return ( +
+

{label}

+
+ {options.map((opt) => ( + + ))} +
+
+ ) +} + +interface JobsColumnProps { + title: string + jobs: JobWithRelations[] + selectedJobId: string | null + onSelect: (id: string) => void + storageKeyPrefix: string + statusOptions: Array<{ value: StatusFilter; label: string }> + emptyText: string +} + +export default function JobsColumn({ + title, + jobs, + selectedJobId, + onSelect, + storageKeyPrefix, + statusOptions, + emptyText, +}: JobsColumnProps) { + const [filterKind, setFilterKind] = useState('all') + const [filterStatus, setFilterStatus] = useState('all') + const [prefsLoaded, setPrefsLoaded] = useState(false) + + const kindKey = `${storageKeyPrefix}_filter_kind` + const statusKey = `${storageKeyPrefix}_filter_status` + + 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) + } + setPrefsLoaded(true) + }, [kindKey, statusKey, statusOptions]) + + useEffect(() => { + if (prefsLoaded) localStorage.setItem(kindKey, filterKind) + }, [filterKind, prefsLoaded, kindKey]) + + useEffect(() => { + if (prefsLoaded) localStorage.setItem(statusKey, filterStatus) + }, [filterStatus, prefsLoaded, statusKey]) + + const filtered = jobs.filter((j) => { + if (filterKind !== 'all' && j.kind !== filterKind) return false + if (filterStatus !== 'all' && jobStatusToApi(j.status) !== filterStatus) return false + return true + }) + + const activeFilterCount = (filterKind !== 'all' ? 1 : 0) + (filterStatus !== 'all' ? 1 : 0) + + return ( +
+
+ {title} +
+ {filterKind !== 'all' && ( + + )} + {filterStatus !== 'all' && ( + + )} + + + {`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`} + + } + /> + + + +
+ +
+
+
+
+
+
+ {filtered.map((j) => ( + onSelect(j.id)} + /> + ))} + {filtered.length === 0 && ( +

+ {jobs.length === 0 ? emptyText : 'Geen jobs voldoen aan filter'} +

+ )} +
+
+ ) +}