Scrum4Me/components/jobs/jobs-column.tsx
Janpeter Visser e8371b9f95
feat(PBI-61): filter popover + created_at op job-kaart (#158)
- 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) <noreply@anthropic.com>
2026-05-07 21:52:27 +02:00

228 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<ClaudeJobKind, string> = {
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<ClaudeJobKind>([
'TASK_IMPLEMENTATION',
'SPRINT_IMPLEMENTATION',
'IDEA_GRILL',
'IDEA_MAKE_PLAN',
'PLAN_CHAT',
])
function FilterPills<T extends string>({
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={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 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<KindFilter>('all')
const [filterStatus, setFilterStatus] = useState<StatusFilter>('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 (
<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' && (
<button
type="button"
onClick={() => setFilterKind('all')}
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"
>
<span>{KIND_LABELS[filterKind]}</span>
<span aria-hidden>×</span>
</button>
)}
{filterStatus !== 'all' && (
<button
type="button"
onClick={() => setFilterStatus('all')}
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"
>
<span>{JOB_STATUS_LABELS[filterStatus as ClaudeJobStatusApi]}</span>
<span aria-hidden>×</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="Soort"
options={KIND_OPTIONS}
value={filterKind}
onChange={setFilterKind}
/>
<FilterPills
label="Status"
options={statusOptions}
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={() => {
setFilterKind('all')
setFilterStatus('all')
}}
>
Wis filters
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</div>
<div className="overflow-y-auto flex-1 p-2 space-y-2">
{filtered.map((j) => (
<JobCard
key={j.id}
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}
createdAt={j.createdAt}
isSelected={j.id === selectedJobId}
onClick={() => onSelect(j.id)}
/>
))}
{filtered.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
{jobs.length === 0 ? emptyText : 'Geen jobs voldoen aan filter'}
</p>
)}
</div>
</div>
)
}