Scrum4Me/components/jobs/jobs-column.tsx
Scrum4Me Agent 1de14d0417 feat(jobs): JobCard breadcrumb + datum-fallback per kind
Voeg productCode/pbiCode/storyCode/startedAt/finishedAt toe aan
JobCardProps; bouw breadcrumb per job-kind en toon finishedAt → startedAt
→ createdAt als datum. JobsColumn geeft de nieuwe velden door.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 01:07:34 +02:00

270 lines
9.2 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 { useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
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 { useUserSettingsStore } from '@/stores/user-settings/store'
import { isWithinTimeWindow, DEFAULT_JOBS_TIME_FILTER } from '@/lib/jobs-time-filter'
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',
IDEA_REVIEW_PLAN: 'REVIEW',
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: 'IDEA_REVIEW_PLAN', label: 'REVIEW' },
{ 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>
)
}
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 allowedStatuses = useMemo(
() => new Set<ClaudeJobStatusApi>(statusOptions.map((o) => o.value)),
[statusOptions],
)
const colPrefs = useUserSettingsStore(
useShallow((s) => s.entities.settings.views?.jobsColumns?.[storageKeyPrefix]),
)
const timeFilter = useUserSettingsStore(
useShallow((s) => s.entities.settings.views?.jobs?.timeFilter),
) ?? DEFAULT_JOBS_TIME_FILTER
const setPref = useUserSettingsStore((s) => s.setPref)
const filterKinds = useMemo<Set<ClaudeJobKind>>(() => {
const out = new Set<ClaudeJobKind>()
for (const v of colPrefs?.kinds ?? []) {
if (KIND_VALUES.has(v as ClaudeJobKind)) out.add(v as ClaudeJobKind)
}
return out
}, [colPrefs?.kinds])
const filterStatuses = useMemo<Set<ClaudeJobStatusApi>>(() => {
const out = new Set<ClaudeJobStatusApi>()
for (const v of colPrefs?.statuses ?? []) {
if (allowedStatuses.has(v as ClaudeJobStatusApi)) out.add(v as ClaudeJobStatusApi)
}
return out
}, [colPrefs?.statuses, allowedStatuses])
function persist(kinds: Set<ClaudeJobKind>, statuses: Set<ClaudeJobStatusApi>) {
void setPref(['views', 'jobsColumns', storageKeyPrefix], {
kinds: Array.from(kinds),
statuses: Array.from(statuses),
})
}
function toggleKind(v: ClaudeJobKind) {
const next = new Set(filterKinds)
if (next.has(v)) next.delete(v)
else next.add(v)
persist(next, filterStatuses)
}
function toggleStatus(v: ClaudeJobStatusApi) {
const next = new Set(filterStatuses)
if (next.has(v)) next.delete(v)
else next.add(v)
persist(filterKinds, next)
}
const filtered = jobs.filter((j) => {
if (!isWithinTimeWindow(j.createdAt, timeFilter)) 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 = 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={() => persist(new Set(), filterStatuses)}
/>
<MultiFilterPills
label="Status"
options={statusOptions}
selected={filterStatuses}
onToggle={toggleStatus}
onClear={() => persist(filterKinds, 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={() => persist(new Set(), 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}
productCode={j.productCode}
pbiCode={j.pbiCode}
storyCode={j.storyCode}
branch={j.branch}
error={j.error}
summary={j.summary}
createdAt={j.createdAt}
startedAt={j.startedAt}
finishedAt={j.finishedAt}
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>
)
}