Scrum4Me/components/jobs/jobs-column.tsx
Janpeter Visser d84cdf664f
feat(PBI-67): IDEA_REVIEW_PLAN — iterative multi-model plan review (#199)
* feat(ideas): upload-plan knop — short-circuit van Make-Plan AI-flow

Voegt een 'Upload plan' knop toe in idea-row-actions (verschijnt in zowel
list als idea-detail). Klik → file picker → kies .md → server-side parse +
opslaan; idea-status springt naar PLAN_READY. Vandaaruit de bestaande
'Maak PBI' knop voor materialize.

Server (uploadPlanMdAction):
- Toegestaan vanuit DRAFT, GRILLED, PLAN_FAILED, PLAN_READY
- DRAFT → skip-grill: status gaat direct naar PLAN_READY
- PLAN_READY overschrijft het bestaande plan (consistent met
  updatePlanMdAction, geen confirmation)
- Geblokkeerd in GRILLING/PLANNING (job loopt), PLANNED (al gematerialiseerd)
- Parse-failure → 422 + details (NIET opslaan, zodat een onparseerbaar plan
  nooit in de DB belandt)
- Empty / >100k chars → 422
- Schrijft IdeaLog NOTE met from_status + length
- Rate-limit + demo-guard + ownership-check via loadOwnedIdea (zelfde
  patroon als updatePlanMdAction)

UI (idea-row-actions.tsx):
- Hidden <input type=file accept=".md,.markdown,text/markdown,text/plain">
- FileReader → text → action
- Toast bij success + router.refresh()
- Blocked-tooltip in andere statussen

Tests: 10 nieuwe in __tests__/actions/ideas-crud.test.ts dekkend voor:
happy paths (DRAFT/GRILLED/PLAN_READY-overwrite/PLAN_FAILED), blocks
(PLANNED/GRILLING), validation (empty/oversized/parse-fail), 404.
Full suite groen: 849/849.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add reviews for Bootstrap-wizard plans v3.2 to v3.4

- Review v3.2: Addressed executor model, fire-and-forget issues, and PAT handling.
- Review v3.3: Improved transaction handling, stale recovery, and ID generation.
- Review v3.4: Finalized GitHub permissions, catalog versioning, and E2E verification queries.
- Updated recommendations for each version to enhance implementation readiness.

* docs(plans): M8 bootstrap-wizard upload-variant v1.4 — backtick-paden

Upload-variant van het volledige technische plan (docs/plans/M8-bootstrap-wizard.md),
bedoeld voor de "Upload plan"-functie. Genereert 1 PBI + 4 Stories + 22 Tasks
via materializeIdeaPlanAction.

v1.4-aanpassingen tov eerdere generatie-iteratie:
- Alle bestandspaden in implementation_plan in backticks (path-extractor matchen)
- Expliciete "Bestanden:" blok per task vóór de stappen
- Alle tasks op verify_required: ALIGNED_OR_PARTIAL (was deels ALIGNED — te strict
  voor ADR-stubs en multi-file edits)

Fixt forward-only: T-963 cancelled_by_self door DIVERGENT verifier-verdict.
Re-upload van dit bestand produceert tasks die door verify_task_against_plan
als ALIGNED of PARTIAL geclassificeerd kunnen worden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PBI-67: Add review-plan support to Idea model and job config

- Add plan_review_log and reviewed_at fields to Idea model
- Add REVIEWING_PLAN, PLAN_REVIEW_FAILED, PLAN_REVIEWED to IdeaStatus enum
- Add IDEA_REVIEW_PLAN to ClaudeJobKind enum
- Add IDEA_REVIEW_PLAN config to job-config.ts with model=opus, thinking_budget=6000
- Create migration record for schema changes (applied via db push)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* PBI-67 Phase 2: Add update-idea-plan-reviewed MCP tool

- Create src/tools/update-idea-plan-reviewed.ts: saves review-log and transitions idea status to PLAN_REVIEWED
- Add PLAN_REVIEW_RESULT to IdeaLogType enum (both repos)
- Register tool in src/index.ts
- Update Prisma schemas (both repos): add plan_review_log and reviewed_at fields to Idea model
- Add REVIEWING_PLAN, PLAN_REVIEW_FAILED, PLAN_REVIEWED to IdeaStatus enum (MCP schema)
- Add IDEA_REVIEW_PLAN to ClaudeJobKind enum (MCP schema)
- Tool includes transaction safety and convergence metrics logging

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* feat(PBI-67): IDEA_REVIEW_PLAN Phases 3-6 — server actions, UI components, prompt & tests

- Phase 3: startReviewPlanJobAction, cancelIdeaJobAction, status transitions
  (REVIEWING_PLAN / PLAN_REVIEWED / PLAN_REVIEW_FAILED), status colors,
  job-card/jobs-column filters, idea-list status tabs
- Phase 4: review-plan-job.md prompt (multi-model orchestration with codex
  injection + active plan revision via update_idea_plan_md after each round),
  runbook, 13 unit tests
- Phase 5: ReviewLogViewer component (rounds, convergence, approval, issues),
  idea-detail integration, proper ReviewLog TypeScript types exported from component
- Phase 6.1: wait-for-job discriminator wired (IDEA_REVIEW_PLAN), plan-revision
  step made mandatory in prompt (was previously optional/missing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:35:02 +02:00

260 lines
8.7 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 { 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 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 (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}
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>
)
}