Scrum4Me/components/jobs/jobs-column.tsx
Scrum4Me Agent 29e05d831e feat(PBI-49): add BEM sub-element data-debug-id to components/jobs/*
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:18:32 +02:00

274 lines
9.1 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, useMemo, 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 { debugProps } from '@/lib/debug'
import type { JobWithRelations } from '@/actions/jobs-page'
import type { ClaudeJobKind } from '@prisma/client'
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: ClaudeJobKind; label: string }> = [
{ 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>(KIND_OPTIONS.map((o) => o.value))
function MultiFilterPills<T extends string>({
label,
options,
selected,
onToggle,
onClear,
}: {
label: string
options: Array<{ value: T; label: string }>
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">
<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={() => 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>
)
}
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: ClaudeJobStatusApi; label: string }>
emptyText: string
}
export default function JobsColumn({
title,
jobs,
selectedJobId,
onSelect,
storageKeyPrefix,
statusOptions,
emptyText,
}: JobsColumnProps) {
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(() => {
// 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)
/* eslint-enable react-hooks/set-state-in-effect */
}, [kindKey, statusKey, statusValues])
useEffect(() => {
if (prefsLoaded) localStorage.setItem(kindKey, setToCsv(filterKinds))
}, [filterKinds, prefsLoaded, kindKey])
useEffect(() => {
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 (filterKinds.size > 0 && !filterKinds.has(j.kind)) return false
if (filterStatuses.size > 0 && !filterStatuses.has(jobStatusToApi(j.status))) return false
return true
})
const activeFilterCount = filterKinds.size + filterStatuses.size
return (
<div className="flex flex-col h-full" {...debugProps('jobs-column', 'JobsColumn', 'components/jobs/jobs-column.tsx')}>
<div className="flex items-center justify-between gap-2 px-2 py-1.5 border-b border-border bg-surface-container-low shrink-0" data-debug-id="jobs-column__header">
<span className="text-xs font-medium text-muted-foreground px-1">{title}</span>
<div className="flex items-center gap-1.5 flex-wrap justify-end">
{Array.from(filterKinds).map((k) => (
<button
key={`k-${k}`}
type="button"
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 filter ${KIND_LABELS[k]}`}
>
<span>{KIND_LABELS[k]}</span>
<span aria-hidden>×</span>
</button>
))}
{Array.from(filterStatuses).map((s) => (
<button
key={`s-${s}`}
type="button"
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 filter ${JOB_STATUS_LABELS[s]}`}
>
<span>{JOB_STATUS_LABELS[s]}</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">
<MultiFilterPills
label="Soort"
options={KIND_OPTIONS}
selected={filterKinds}
onToggle={toggleKind}
onClear={() => setFilterKinds(new Set())}
/>
<MultiFilterPills
label="Status"
options={statusOptions}
selected={filterStatuses}
onToggle={toggleStatus}
onClear={() => setFilterStatuses(new Set())}
/>
<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={() => {
setFilterKinds(new Set())
setFilterStatuses(new Set())
}}
>
Wis filters
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</div>
<div className="overflow-y-auto flex-1 p-2 space-y-2" data-debug-id="jobs-column__items">
{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>
)
}