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>
This commit is contained in:
parent
7ae8a24372
commit
e8371b9f95
3 changed files with 270 additions and 46 deletions
|
|
@ -19,6 +19,7 @@ interface JobCardProps {
|
||||||
branch?: string | null
|
branch?: string | null
|
||||||
error?: string | null
|
error?: string | null
|
||||||
summary?: string | null
|
summary?: string | null
|
||||||
|
createdAt: Date | string
|
||||||
isSelected?: boolean
|
isSelected?: boolean
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
@ -33,7 +34,7 @@ const KIND_LABELS: Record<ClaudeJobKind, string> = {
|
||||||
|
|
||||||
export default function JobCard({
|
export default function JobCard({
|
||||||
kind, status, taskCode, taskTitle, ideaCode, ideaTitle,
|
kind, status, taskCode, taskTitle, ideaCode, ideaTitle,
|
||||||
sprintGoal, sprintCode, productName, branch, error, isSelected, onClick,
|
sprintGoal, sprintCode, productName, branch, error, createdAt, isSelected, onClick,
|
||||||
}: JobCardProps) {
|
}: JobCardProps) {
|
||||||
let titleText: string
|
let titleText: string
|
||||||
if (kind === 'TASK_IMPLEMENTATION') {
|
if (kind === 'TASK_IMPLEMENTATION') {
|
||||||
|
|
@ -70,7 +71,12 @@ export default function JobCard({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-medium truncate mt-1">{titleText}</p>
|
<p className="font-medium truncate mt-1">{titleText}</p>
|
||||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{detailText}</p>
|
<div className="flex items-end justify-between gap-2 mt-0.5">
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{detailText}</p>
|
||||||
|
<span className="text-[10px] text-muted-foreground shrink-0 tabular-nums">
|
||||||
|
{new Date(createdAt).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { SplitPane } from '@/components/split-pane/split-pane'
|
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 JobDetailPane from './job-detail-pane'
|
||||||
import JobUsagePane from './job-usage-pane'
|
import JobUsagePane from './job-usage-pane'
|
||||||
import SprintSubTasksPane from './sprint-sub-tasks-pane'
|
import SprintSubTasksPane from './sprint-sub-tasks-pane'
|
||||||
import { useJobsStore } from '@/stores/jobs-store'
|
import { useJobsStore } from '@/stores/jobs-store'
|
||||||
import useJobsRealtime from '@/hooks/use-jobs-realtime'
|
import useJobsRealtime from '@/hooks/use-jobs-realtime'
|
||||||
|
import type { ClaudeJobStatusApi } from '@/lib/job-status'
|
||||||
import type { JobWithRelations } from '@/actions/jobs-page'
|
import type { JobWithRelations } from '@/actions/jobs-page'
|
||||||
|
|
||||||
interface JobsBoardProps {
|
interface JobsBoardProps {
|
||||||
|
|
@ -18,23 +19,20 @@ interface JobsBoardProps {
|
||||||
|
|
||||||
type View = 'detail' | 'usage'
|
type View = 'detail' | 'usage'
|
||||||
|
|
||||||
function jobToCardProps(j: JobWithRelations) {
|
const ACTIVE_STATUS_OPTIONS: Array<{ value: ClaudeJobStatusApi | 'all'; label: string }> = [
|
||||||
return {
|
{ value: 'all', label: 'Alle' },
|
||||||
id: j.id,
|
{ value: 'queued', label: 'Wacht…' },
|
||||||
kind: j.kind,
|
{ value: 'claimed', label: 'Geclaimd…' },
|
||||||
status: j.status,
|
{ value: 'running', label: 'Bezig…' },
|
||||||
taskCode: j.taskCode,
|
]
|
||||||
taskTitle: j.taskTitle,
|
|
||||||
ideaCode: j.ideaCode,
|
const DONE_STATUS_OPTIONS: Array<{ value: ClaudeJobStatusApi | 'all'; label: string }> = [
|
||||||
ideaTitle: j.ideaTitle,
|
{ value: 'all', label: 'Alle' },
|
||||||
sprintGoal: j.sprintGoal,
|
{ value: 'done', label: 'Klaar' },
|
||||||
sprintCode: j.sprintCode,
|
{ value: 'failed', label: 'Mislukt' },
|
||||||
productName: j.productName,
|
{ value: 'cancelled', label: 'Geannuleerd' },
|
||||||
branch: j.branch,
|
{ value: 'skipped', label: 'Overgeslagen' },
|
||||||
error: j.error,
|
]
|
||||||
summary: j.summary,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBoardProps) {
|
export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBoardProps) {
|
||||||
const { activeJobs, doneJobs, selectedJobId, initJobs, setSelectedJobId } = useJobsStore()
|
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 selectedJob = [...activeJobs, ...doneJobs].find(j => j.id === selectedJobId) ?? null
|
||||||
|
|
||||||
const leftPane = (
|
const leftPane = (
|
||||||
<div className="overflow-y-auto h-full p-2 space-y-2">
|
<JobsColumn
|
||||||
{activeJobs.map(j => (
|
title="Actief"
|
||||||
<JobCard
|
jobs={activeJobs}
|
||||||
key={j.id}
|
selectedJobId={selectedJobId}
|
||||||
{...jobToCardProps(j)}
|
onSelect={setSelectedJobId}
|
||||||
isSelected={j.id === selectedJobId}
|
storageKeyPrefix="scrum4me:jobs_active"
|
||||||
onClick={() => setSelectedJobId(j.id)}
|
statusOptions={ACTIVE_STATUS_OPTIONS}
|
||||||
/>
|
emptyText="Geen actieve jobs"
|
||||||
))}
|
/>
|
||||||
{activeJobs.length === 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-8">Geen actieve jobs</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const middlePane = (
|
const middlePane = (
|
||||||
|
|
@ -91,19 +85,15 @@ export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBo
|
||||||
)
|
)
|
||||||
|
|
||||||
const rightPane = (
|
const rightPane = (
|
||||||
<div className="overflow-y-auto h-full p-2 space-y-2">
|
<JobsColumn
|
||||||
{doneJobs.map(j => (
|
title="Klaar"
|
||||||
<JobCard
|
jobs={doneJobs}
|
||||||
key={j.id}
|
selectedJobId={selectedJobId}
|
||||||
{...jobToCardProps(j)}
|
onSelect={setSelectedJobId}
|
||||||
isSelected={j.id === selectedJobId}
|
storageKeyPrefix="scrum4me:jobs_done"
|
||||||
onClick={() => setSelectedJobId(j.id)}
|
statusOptions={DONE_STATUS_OPTIONS}
|
||||||
/>
|
emptyText="Nog geen afgeronde jobs"
|
||||||
))}
|
/>
|
||||||
{doneJobs.length === 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-8">Nog geen afgeronde jobs</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
228
components/jobs/jobs-column.tsx
Normal file
228
components/jobs/jobs-column.tsx
Normal file
|
|
@ -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<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue