236 lines
8.5 KiB
TypeScript
236 lines
8.5 KiB
TypeScript
'use client'
|
|
|
|
// Sync-tab op /ideas/[id] (PBI-36 ST-1219). Toont per Story onder de
|
|
// gekoppelde PBI: status, job-rij (ClaudeJobs incl. branch/pushed_at/pr_url),
|
|
// en de bestaande activity-log via <StoryLog>. Realtime refresh via
|
|
// notifications-SSE: bij elk story_log of relevant claude_job-event triggeren
|
|
// we router.refresh() (server-render verzorgt nieuwe data).
|
|
|
|
import { useEffect } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { StoryLog } from '@/components/shared/story-log'
|
|
import { JOB_STATUS_LABELS, JOB_STATUS_COLORS } from '@/components/shared/job-status'
|
|
import type { ClaudeJobStatusApi } from '@/lib/job-status'
|
|
import { debugProps } from '@/lib/debug'
|
|
import type { IdeaSyncData } from '@/app/(app)/ideas/[id]/sync-tab-server'
|
|
|
|
interface Props {
|
|
data: IdeaSyncData
|
|
}
|
|
|
|
const TASK_STATUS_LABEL: Record<string, string> = {
|
|
TO_DO: 'TO-DO',
|
|
IN_PROGRESS: 'Bezig',
|
|
REVIEW: 'Review',
|
|
DONE: 'Klaar',
|
|
}
|
|
|
|
const TASK_STATUS_COLOR: Record<string, string> = {
|
|
TO_DO: 'bg-status-todo/15 text-status-todo border-status-todo/30',
|
|
IN_PROGRESS: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
|
|
REVIEW: 'bg-warning/15 text-warning border-warning/30',
|
|
DONE: 'bg-status-done/15 text-status-done border-status-done/30',
|
|
}
|
|
|
|
function formatRelative(iso: string | Date | null): string {
|
|
if (!iso) return '—'
|
|
const d = typeof iso === 'string' ? new Date(iso) : iso
|
|
const diffMs = Date.now() - d.getTime()
|
|
const min = Math.round(diffMs / 60_000)
|
|
if (min < 1) return 'zojuist'
|
|
if (min < 60) return `${min} min geleden`
|
|
const h = Math.round(min / 60)
|
|
if (h < 24) return `${h} u geleden`
|
|
return d.toLocaleDateString('nl-NL', { day: 'numeric', month: 'short' })
|
|
}
|
|
|
|
function jobStatusKey(dbStatus: string): ClaudeJobStatusApi {
|
|
return dbStatus.toLowerCase() as ClaudeJobStatusApi
|
|
}
|
|
|
|
export function IdeaSyncTab({ data }: Props) {
|
|
const router = useRouter()
|
|
const pbi = data.pbi
|
|
const storyIdsKey = pbi ? pbi.stories.map((s) => s.id).join(',') : ''
|
|
|
|
// Realtime refresh op story_log inserts en idea-job updates.
|
|
// Listen op de bestaande user-scoped notifications stream (SSE-route filtert
|
|
// al op accessibleIdeaIds + accessibleProductIds).
|
|
useEffect(() => {
|
|
if (!storyIdsKey) return
|
|
const storyIds = new Set(storyIdsKey.split(','))
|
|
const es = new EventSource('/api/realtime/notifications')
|
|
|
|
es.addEventListener('message', (ev) => {
|
|
try {
|
|
const payload = JSON.parse(ev.data)
|
|
if (payload.entity === 'story_log' && storyIds.has(payload.story_id)) {
|
|
router.refresh()
|
|
return
|
|
}
|
|
if (payload.type === 'claude_job_status' && payload.idea_id === data.id) {
|
|
router.refresh()
|
|
}
|
|
} catch {
|
|
// niet-JSON of niet-relevant — negeren
|
|
}
|
|
})
|
|
|
|
es.addEventListener('error', () => {
|
|
// EventSource probeert zelf opnieuw te verbinden; geen actie nodig.
|
|
})
|
|
|
|
return () => {
|
|
es.close()
|
|
}
|
|
}, [data.id, storyIdsKey, router])
|
|
|
|
if (!pbi) return null
|
|
|
|
return (
|
|
<div className="space-y-4" {...debugProps('idea-sync-tab', 'IdeaSyncTab', 'components/ideas/idea-sync-tab.tsx')}>
|
|
{/* Header: PBI-link + PR-status */}
|
|
<div className="flex flex-wrap items-center gap-3 rounded-md border border-border bg-surface-container p-3" data-debug-id="idea-sync-tab__header">
|
|
<a
|
|
href={`/backlog/${pbi.id}`}
|
|
className="font-mono text-sm text-primary hover:underline"
|
|
>
|
|
{pbi.code}
|
|
</a>
|
|
<span className="text-sm font-medium">{pbi.title}</span>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
{pbi.pr_url && (
|
|
<a
|
|
href={pbi.pr_url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-xs text-primary underline"
|
|
>
|
|
PR open
|
|
</a>
|
|
)}
|
|
{pbi.pr_merged_at && (
|
|
<Badge className="bg-status-done/15 text-status-done border-status-done/30">
|
|
Gemerged {formatRelative(pbi.pr_merged_at)}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stories */}
|
|
<div data-debug-id="idea-sync-tab__items">
|
|
{pbi.stories.length === 0 && (
|
|
<p className="text-sm text-muted-foreground italic">
|
|
Deze PBI heeft nog geen stories.
|
|
</p>
|
|
)}
|
|
{pbi.stories.map((story) => (
|
|
<details
|
|
key={story.id}
|
|
open
|
|
className="rounded-md border border-border bg-card"
|
|
>
|
|
<summary className="flex cursor-pointer flex-wrap items-center gap-2 px-3 py-2">
|
|
<span className="font-mono text-xs text-muted-foreground">
|
|
{story.code}
|
|
</span>
|
|
<span className="text-sm font-medium">{story.title}</span>
|
|
<Badge
|
|
className={`ml-auto ${TASK_STATUS_COLOR[story.status] ?? 'bg-muted'}`}
|
|
>
|
|
{TASK_STATUS_LABEL[story.status] ?? story.status}
|
|
</Badge>
|
|
</summary>
|
|
|
|
<div className="space-y-3 border-t border-border px-3 py-3">
|
|
{/* Tasks + jobs */}
|
|
{story.tasks.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">Geen taken.</p>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{story.tasks.map((task) => {
|
|
const latestJob = task.claude_jobs[0]
|
|
return (
|
|
<li
|
|
key={task.id}
|
|
className="flex flex-wrap items-center gap-2 rounded border border-border/60 bg-surface-container/40 px-2 py-1.5 text-xs"
|
|
>
|
|
<span className="font-mono text-muted-foreground">
|
|
{task.code}
|
|
</span>
|
|
<span className="flex-1 truncate">{task.title}</span>
|
|
<Badge
|
|
className={`${TASK_STATUS_COLOR[task.status] ?? 'bg-muted'}`}
|
|
>
|
|
{TASK_STATUS_LABEL[task.status] ?? task.status}
|
|
</Badge>
|
|
{latestJob ? (
|
|
<Badge
|
|
className={
|
|
JOB_STATUS_COLORS[jobStatusKey(latestJob.status)] ??
|
|
'bg-muted'
|
|
}
|
|
>
|
|
{JOB_STATUS_LABELS[jobStatusKey(latestJob.status)] ??
|
|
latestJob.status}
|
|
</Badge>
|
|
) : (
|
|
<Badge className="bg-muted/60 text-muted-foreground italic">
|
|
Geen job
|
|
</Badge>
|
|
)}
|
|
{latestJob?.branch && (
|
|
<span className="font-mono text-muted-foreground">
|
|
{latestJob.branch}
|
|
</span>
|
|
)}
|
|
{latestJob?.pushed_at && (
|
|
<span className="text-muted-foreground">
|
|
gepusht {formatRelative(latestJob.pushed_at)}
|
|
</span>
|
|
)}
|
|
{latestJob?.pr_url && (
|
|
<a
|
|
href={latestJob.pr_url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-primary underline"
|
|
>
|
|
PR
|
|
</a>
|
|
)}
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)}
|
|
|
|
{/* Activity log (StoryLog hergebruik) */}
|
|
<div>
|
|
<h4 className="mb-1 text-xs font-medium text-muted-foreground">
|
|
Activiteit
|
|
</h4>
|
|
<StoryLog
|
|
logs={story.logs.map((l) => ({
|
|
id: l.id,
|
|
type: l.type,
|
|
content: l.content,
|
|
status: l.status,
|
|
commit_hash: l.commit_hash,
|
|
commit_message: l.commit_message,
|
|
created_at:
|
|
typeof l.created_at === 'string'
|
|
? l.created_at
|
|
: l.created_at.toISOString(),
|
|
}))}
|
|
repoUrl={data.product?.repo_url ?? null}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|