* 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>
260 lines
8.7 KiB
TypeScript
260 lines
8.7 KiB
TypeScript
'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>
|
||
)
|
||
}
|