feat(T-562): IdeaSyncTab component met StoryLog-hergebruik
Toont per Story onder de gekoppelde PBI: status-badge, taak-rij met job-status (incl. SKIPPED), branch, pushed_at, pr_url, en bestaande <StoryLog>-component voor activity-log. PBI-header met PR-link en gemerged-badge. Realtime: subscribed op /api/realtime/notifications. Bij story_log- event waar story_id matcht, of claude_job_status voor dit idea → router.refresh() (server-render levert nieuwe data). MD3-tokens overal: bg-status-todo/in-progress/done, bg-surface- container, bg-muted/60. Geen bg-blue-500. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f4f02bd0d2
commit
dbf30a2fcb
1 changed files with 233 additions and 0 deletions
233
components/ideas/idea-sync-tab.tsx
Normal file
233
components/ideas/idea-sync-tab.tsx
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
'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 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">
|
||||
{/* Header: PBI-link + PR-status */}
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-md border border-border bg-surface-container p-3">
|
||||
<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 */}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue