feat(pitch-deck): full pitch versioning with git-style history (#4)
Some checks failed
Build pitch-deck / build-and-push (push) Failing after 1m8s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 32s
CI / test-python-voice (push) Successful in 32s
CI / test-bqas (push) Successful in 32s
CI / Deploy (push) Failing after 4s

Full pitch versioning: 12 data tables versioned as JSONB snapshots,
git-style parent chain (draft→commit→fork), per-investor assignment,
side-by-side diff engine, version-aware /api/data + /api/financial-model.

Bug fixes: FM editor [object Object] for JSONB arrays, admin scroll.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #4.
This commit is contained in:
2026-04-10 07:37:33 +00:00
parent 746daaef6d
commit 1c3cec2c06
22 changed files with 1564 additions and 42 deletions

View File

@@ -130,7 +130,7 @@ export default function EditScenarioPage() {
const isEdited = edits[a.id] !== undefined
const currentValue = isEdited
? edits[a.id]
: a.value_type === 'timeseries'
: typeof a.value === 'object'
? JSON.stringify(a.value)
: String(a.value)

View File

@@ -16,6 +16,9 @@ interface InvestorDetail {
last_login_at: string | null
login_count: number
created_at: string
assigned_version_id: string | null
version_name: string | null
version_status: string | null
}
sessions: Array<{
id: string
@@ -60,6 +63,11 @@ export default function InvestorDetailPage() {
const [company, setCompany] = useState('')
const [busy, setBusy] = useState(false)
const [toast, setToast] = useState<string | null>(null)
const [versions, setVersions] = useState<Array<{ id: string; name: string; status: string }>>([])
useEffect(() => {
fetch('/api/admin/versions').then(r => r.json()).then(d => setVersions((d.versions || []).filter((v: { status: string }) => v.status === 'committed')))
}, [])
function flashToast(msg: string) {
setToast(msg)
@@ -236,6 +244,40 @@ export default function InvestorDetailPage() {
</div>
</div>
{/* Version assignment */}
<section className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
<h2 className="text-sm font-semibold text-white mb-3">Pitch Version</h2>
<div className="flex items-center gap-3">
<select
value={inv.assigned_version_id || ''}
onChange={async (e) => {
const versionId = e.target.value || null
setBusy(true)
const res = await fetch(`/api/admin/investors/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assigned_version_id: versionId }),
})
setBusy(false)
if (res.ok) { flashToast('Version updated'); load() }
else { flashToast('Update failed') }
}}
disabled={busy}
className="bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
>
<option value="">Default (base tables)</option>
{versions.map(v => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
<span className="text-xs text-white/40">
{inv.assigned_version_id
? `Investor sees version "${inv.version_name || ''}"`
: 'Investor sees default pitch data'}
</span>
</div>
</section>
{/* Audit log for this investor */}
<section className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
<h2 className="text-sm font-semibold text-white mb-4">Activity</h2>

View File

@@ -15,6 +15,8 @@ interface Investor {
created_at: string
slides_viewed: number
last_activity: string | null
assigned_version_id: string | null
version_name: string | null
}
const STATUS_STYLES: Record<string, string> = {
@@ -139,6 +141,7 @@ export default function InvestorsPage() {
<th className="py-3 px-4 font-medium">Status</th>
<th className="py-3 px-4 font-medium text-right">Logins</th>
<th className="py-3 px-4 font-medium text-right">Slides</th>
<th className="py-3 px-4 font-medium">Version</th>
<th className="py-3 px-4 font-medium">Last login</th>
<th className="py-3 px-4 font-medium text-right">Actions</th>
</tr>
@@ -166,6 +169,13 @@ export default function InvestorsPage() {
</td>
<td className="py-3 px-4 text-right text-white/70 font-mono">{inv.login_count}</td>
<td className="py-3 px-4 text-right text-white/70 font-mono">{inv.slides_viewed}</td>
<td className="py-3 px-4">
{inv.version_name ? (
<span className="text-[10px] px-2 py-0.5 rounded bg-purple-500/15 text-purple-300 border border-purple-500/30">{inv.version_name}</span>
) : (
<span className="text-xs text-white/30">Default</span>
)}
</td>
<td className="py-3 px-4 text-white/50 text-xs whitespace-nowrap">
{inv.last_login_at ? new Date(inv.last_login_at).toLocaleDateString() : '—'}
</td>

View File

@@ -0,0 +1,116 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
interface FieldDiff {
key: string
before: unknown
after: unknown
}
interface RowDiff {
status: 'added' | 'removed' | 'changed' | 'unchanged'
fields: FieldDiff[]
}
interface TableDiff {
tableName: string
rows: RowDiff[]
hasChanges: boolean
}
interface DiffData {
versionA: { id: string; name: string }
versionB: { id: string; name: string }
diffs: TableDiff[]
total_changes: number
}
const STATUS_COLORS: Record<string, string> = {
added: 'bg-green-500/10 border-green-500/20',
removed: 'bg-rose-500/10 border-rose-500/20',
changed: 'bg-amber-500/10 border-amber-500/20',
}
export default function DiffPage() {
const { id, otherId } = useParams<{ id: string; otherId: string }>()
const [data, setData] = useState<DiffData | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!id || !otherId) return
setLoading(true)
fetch(`/api/admin/versions/${id}/diff/${otherId}`)
.then(r => r.json())
.then(setData)
.finally(() => setLoading(false))
}, [id, otherId])
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
if (!data) return <div className="text-rose-400">Failed to load diff</div>
return (
<div className="space-y-6">
<Link href={`/pitch-admin/versions/${id}`} className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80">
<ArrowLeft className="w-4 h-4" /> Back to version
</Link>
<div>
<h1 className="text-2xl font-semibold text-white mb-1">Diff</h1>
<p className="text-sm text-white/50">
<span className="text-indigo-300">{data.versionA.name}</span>
{' → '}
<span className="text-purple-300">{data.versionB.name}</span>
{' — '}{data.total_changes} change{data.total_changes !== 1 ? 's' : ''} across {data.diffs.length} table{data.diffs.length !== 1 ? 's' : ''}
</p>
</div>
{data.diffs.length === 0 ? (
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-12 text-center text-white/50">
No differences found
</div>
) : (
<div className="space-y-4">
{data.diffs.map(table => (
<details key={table.tableName} open className="bg-white/[0.04] border border-white/[0.06] rounded-2xl overflow-hidden">
<summary className="px-5 py-3 cursor-pointer flex items-center justify-between hover:bg-white/[0.02]">
<span className="text-sm font-semibold text-white capitalize">{table.tableName.replace(/_/g, ' ')}</span>
<span className="text-xs text-white/40">
{table.rows.filter(r => r.status !== 'unchanged').length} change{table.rows.filter(r => r.status !== 'unchanged').length !== 1 ? 's' : ''}
</span>
</summary>
<div className="px-5 pb-4 space-y-2">
{table.rows.filter(r => r.status !== 'unchanged').map((row, i) => (
<div key={i} className={`rounded-lg border p-3 ${STATUS_COLORS[row.status] || ''}`}>
<div className="flex items-center gap-2 mb-2">
<span className={`text-[9px] px-1.5 py-0.5 rounded uppercase font-semibold ${
row.status === 'added' ? 'text-green-300' :
row.status === 'removed' ? 'text-rose-300' :
'text-amber-300'
}`}>{row.status}</span>
</div>
{row.fields.length > 0 && (
<div className="space-y-1">
{row.fields.map(f => (
<div key={f.key} className="text-xs font-mono grid grid-cols-12 gap-2">
<span className="col-span-3 text-white/60 truncate">{f.key}</span>
<span className="col-span-4 text-rose-300/80 truncate">{JSON.stringify(f.before)}</span>
<span className="col-span-1 text-white/30 text-center"></span>
<span className="col-span-4 text-green-300/80 truncate">{JSON.stringify(f.after)}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
</details>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,222 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, Lock, Save, GitFork } from 'lucide-react'
const TABLE_LABELS: Record<string, string> = {
company: 'Company',
team: 'Team',
financials: 'Financials',
market: 'Market',
competitors: 'Competitors',
features: 'Features',
milestones: 'Milestones',
metrics: 'Metrics',
funding: 'Funding',
products: 'Products',
fm_scenarios: 'FM Scenarios',
fm_assumptions: 'FM Assumptions',
}
const TABLE_NAMES = Object.keys(TABLE_LABELS)
interface Version {
id: string
name: string
description: string | null
status: 'draft' | 'committed'
parent_id: string | null
committed_at: string | null
}
export default function VersionEditorPage() {
const { id } = useParams<{ id: string }>()
const router = useRouter()
const [version, setVersion] = useState<Version | null>(null)
const [allData, setAllData] = useState<Record<string, unknown[]>>({})
const [activeTab, setActiveTab] = useState('company')
const [loading, setLoading] = useState(true)
const [editorValue, setEditorValue] = useState('')
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
const [toast, setToast] = useState<string | null>(null)
function flashToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 3000) }
const load = useCallback(async () => {
setLoading(true)
const res = await fetch(`/api/admin/versions/${id}`)
if (res.ok) {
const d = await res.json()
setVersion(d.version)
setAllData(d.data)
}
setLoading(false)
}, [id])
useEffect(() => { if (id) load() }, [id, load])
// When tab changes, set editor value
useEffect(() => {
const data = allData[activeTab]
if (data !== undefined) {
setEditorValue(JSON.stringify(data, null, 2))
setDirty(false)
}
}, [activeTab, allData])
async function saveTable() {
let parsed: unknown
try {
parsed = JSON.parse(editorValue)
} catch {
flashToast('Invalid JSON')
return
}
setSaving(true)
const res = await fetch(`/api/admin/versions/${id}/data/${activeTab}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: parsed }),
})
setSaving(false)
if (res.ok) {
setDirty(false)
setAllData(prev => ({ ...prev, [activeTab]: Array.isArray(parsed) ? parsed : [parsed] }))
flashToast('Saved')
} else {
const d = await res.json().catch(() => ({}))
flashToast(d.error || 'Save failed')
}
}
async function commitVersion() {
if (!confirm('Commit this version? It becomes immutable and available for investor assignment.')) return
const res = await fetch(`/api/admin/versions/${id}/commit`, { method: 'POST' })
if (res.ok) { flashToast('Committed'); load() }
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
}
async function forkVersion() {
const name = prompt('Name for the new draft:')
if (!name) return
const res = await fetch(`/api/admin/versions/${id}/fork`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
if (res.ok) {
const d = await res.json()
router.push(`/pitch-admin/versions/${d.version.id}`)
} else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
}
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
if (!version) return <div className="text-rose-400">Version not found</div>
const isDraft = version.status === 'draft'
return (
<div className="space-y-6">
<Link href="/pitch-admin/versions" className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80">
<ArrowLeft className="w-4 h-4" /> Back to versions
</Link>
{/* Header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<div className="flex items-center gap-2 mb-1">
<h1 className="text-2xl font-semibold text-white">{version.name}</h1>
<span className={`text-[9px] px-2 py-0.5 rounded-full border uppercase font-semibold ${
isDraft ? 'bg-amber-500/15 text-amber-300 border-amber-500/30' : 'bg-green-500/15 text-green-300 border-green-500/30'
}`}>{version.status}</span>
</div>
{version.description && <p className="text-sm text-white/50">{version.description}</p>}
</div>
<div className="flex items-center gap-2">
{isDraft && (
<button
onClick={commitVersion}
className="bg-green-500/15 hover:bg-green-500/25 text-green-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2"
>
<Lock className="w-4 h-4" /> Commit
</button>
)}
<button
onClick={forkVersion}
className="bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2"
>
<GitFork className="w-4 h-4" /> Fork
</button>
{version.parent_id && (
<Link
href={`/pitch-admin/versions/${id}/diff/${version.parent_id}`}
className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg"
>
Diff with parent
</Link>
)}
</div>
</div>
{/* Tab navigation */}
<div className="flex gap-1 overflow-x-auto pb-1">
{TABLE_NAMES.map(t => (
<button
key={t}
onClick={() => { if (dirty && !confirm('Discard unsaved changes?')) return; setActiveTab(t) }}
className={`px-3 py-1.5 rounded-lg text-xs whitespace-nowrap transition-colors ${
activeTab === t
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
: 'bg-white/[0.04] text-white/40 border border-transparent hover:text-white/60'
}`}
>
{TABLE_LABELS[t]}
</button>
))}
</div>
{/* JSON editor */}
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-white/[0.06]">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-white">{TABLE_LABELS[activeTab]}</span>
{dirty && <span className="text-[9px] px-2 py-0.5 rounded bg-amber-500/20 text-amber-300">Unsaved</span>}
</div>
{isDraft && (
<button
onClick={saveTable}
disabled={saving || !dirty}
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-1.5 rounded-lg flex items-center gap-2 disabled:opacity-30"
>
<Save className="w-3.5 h-3.5" /> {saving ? 'Saving…' : 'Save'}
</button>
)}
</div>
<textarea
value={editorValue}
onChange={e => { setEditorValue(e.target.value); setDirty(true) }}
readOnly={!isDraft}
className="w-full bg-transparent text-white/90 font-mono text-xs p-4 focus:outline-none resize-none"
style={{ minHeight: '400px' }}
spellCheck={false}
/>
</div>
{!isDraft && (
<p className="text-xs text-white/30 text-center">
This version is committed and read-only. Fork it to make changes.
</p>
)}
{toast && (
<div className="fixed bottom-6 right-6 bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 text-sm px-4 py-2 rounded-lg backdrop-blur-sm z-50">
{toast}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,120 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
interface VersionOption {
id: string
name: string
status: string
}
export default function NewVersionPage() {
const router = useRouter()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [parentId, setParentId] = useState<string>('')
const [versions, setVersions] = useState<VersionOption[]>([])
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
fetch('/api/admin/versions')
.then(r => r.json())
.then(d => setVersions(d.versions || []))
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setSubmitting(true)
const res = await fetch('/api/admin/versions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
description: description || undefined,
parent_id: parentId || undefined,
}),
})
setSubmitting(false)
if (res.ok) {
const d = await res.json()
router.push(`/pitch-admin/versions/${d.version.id}`)
} else {
const d = await res.json().catch(() => ({}))
setError(d.error || 'Creation failed')
}
}
return (
<div className="max-w-xl">
<Link href="/pitch-admin/versions" className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80 mb-6">
<ArrowLeft className="w-4 h-4" /> Back to versions
</Link>
<h1 className="text-2xl font-semibold text-white mb-2">Create Version</h1>
<p className="text-sm text-white/50 mb-6">
A new draft will be created with a full copy of all pitch data.
Choose a parent to fork from, or leave empty to snapshot the current base tables.
</p>
<form onSubmit={handleSubmit} className="bg-white/[0.04] border border-white/[0.08] rounded-2xl p-6 space-y-4">
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
Name <span className="text-rose-400">*</span>
</label>
<input
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="e.g. Conservative Q4, Series A Ready"
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
/>
</div>
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Description</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
placeholder="Optional notes about this version"
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 resize-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Fork from</label>
<select
value={parentId}
onChange={e => setParentId(e.target.value)}
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
>
<option value="">Base tables (current pitch data)</option>
{versions.map(v => (
<option key={v.id} value={v.id}>{v.name} ({v.status})</option>
))}
</select>
</div>
{error && (
<div className="text-sm text-rose-300 bg-rose-500/10 border border-rose-500/20 rounded-lg px-3 py-2">{error}</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Link href="/pitch-admin/versions" className="text-sm text-white/60 hover:text-white px-4 py-2">Cancel</Link>
<button
type="submit"
disabled={submitting}
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-sm font-medium px-5 py-2.5 rounded-lg disabled:opacity-50 shadow-lg shadow-indigo-500/20"
>
{submitting ? 'Creating…' : 'Create draft'}
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,198 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { GitBranch, Plus, Lock, Pencil, Trash2, GitFork, Users } from 'lucide-react'
interface Version {
id: string
name: string
description: string | null
parent_id: string | null
status: 'draft' | 'committed'
created_by_name: string | null
created_by_email: string | null
committed_at: string | null
created_at: string
assigned_count: number
}
export default function VersionsPage() {
const [versions, setVersions] = useState<Version[]>([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState<string | null>(null)
const [toast, setToast] = useState<string | null>(null)
function flashToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 3000) }
async function load() {
setLoading(true)
const res = await fetch('/api/admin/versions')
if (res.ok) { const d = await res.json(); setVersions(d.versions) }
setLoading(false)
}
useEffect(() => { load() }, [])
async function commitVersion(id: string) {
if (!confirm('Commit this version? It becomes immutable and available for investor assignment.')) return
setBusy(id)
const res = await fetch(`/api/admin/versions/${id}/commit`, { method: 'POST' })
setBusy(null)
if (res.ok) { flashToast('Committed'); load() }
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
}
async function forkVersion(id: string) {
const name = prompt('Name for the new draft:')
if (!name) return
setBusy(id)
const res = await fetch(`/api/admin/versions/${id}/fork`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
setBusy(null)
if (res.ok) { flashToast('Forked'); load() }
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
}
async function deleteVersion(id: string, name: string) {
if (!confirm(`Delete "${name}"? This cannot be undone.`)) return
setBusy(id)
const res = await fetch(`/api/admin/versions/${id}`, { method: 'DELETE' })
setBusy(null)
if (res.ok) { flashToast('Deleted'); load() }
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">Pitch Versions</h1>
<p className="text-sm text-white/50 mt-1">
{versions.length} version{versions.length !== 1 ? 's' : ''} each is a complete snapshot of all pitch data
</p>
</div>
<Link
href="/pitch-admin/versions/new"
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-sm font-medium px-4 py-2 rounded-lg shadow-lg shadow-indigo-500/20 flex items-center gap-2"
>
<Plus className="w-4 h-4" /> New Version
</Link>
</div>
{loading ? (
<div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
) : versions.length === 0 ? (
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-12 text-center">
<GitBranch className="w-12 h-12 text-white/20 mx-auto mb-4" />
<p className="text-white/60 mb-4">No versions yet. Create your first version to snapshot the current pitch data.</p>
<Link
href="/pitch-admin/versions/new"
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-2 rounded-lg inline-flex items-center gap-2"
>
<Plus className="w-4 h-4" /> Create First Version
</Link>
</div>
) : (
<div className="space-y-3">
{versions.map(v => {
const parent = v.parent_id ? versions.find(p => p.id === v.parent_id) : null
return (
<div key={v.id} className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5 hover:border-white/[0.12] transition-colors">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<Link href={`/pitch-admin/versions/${v.id}`} className="text-base font-semibold text-white hover:text-indigo-300">
{v.name}
</Link>
<span className={`text-[9px] px-2 py-0.5 rounded-full border uppercase font-semibold ${
v.status === 'committed'
? 'bg-green-500/15 text-green-300 border-green-500/30'
: 'bg-amber-500/15 text-amber-300 border-amber-500/30'
}`}>
{v.status}
</span>
{v.assigned_count > 0 && (
<span className="text-[9px] px-2 py-0.5 rounded-full bg-indigo-500/15 text-indigo-300 border border-indigo-500/30 flex items-center gap-1">
<Users className="w-3 h-3" /> {v.assigned_count}
</span>
)}
</div>
{v.description && <p className="text-sm text-white/50 mb-1">{v.description}</p>}
<div className="flex items-center gap-3 text-xs text-white/40">
<span>by {v.created_by_name || v.created_by_email || 'system'}</span>
<span>{new Date(v.created_at).toLocaleDateString()}</span>
{parent && (
<span className="flex items-center gap-1">
<GitBranch className="w-3 h-3" /> from {parent.name}
</span>
)}
{v.committed_at && <span>committed {new Date(v.committed_at).toLocaleDateString()}</span>}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Link
href={`/pitch-admin/versions/${v.id}`}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-white/[0.06] hover:text-white"
title="Edit"
>
<Pencil className="w-4 h-4" />
</Link>
{v.status === 'draft' && (
<button
onClick={() => commitVersion(v.id)}
disabled={busy === v.id}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-green-500/15 hover:text-green-300 disabled:opacity-30"
title="Commit"
>
<Lock className="w-4 h-4" />
</button>
)}
<button
onClick={() => forkVersion(v.id)}
disabled={busy === v.id}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-indigo-500/15 hover:text-indigo-300 disabled:opacity-30"
title="Fork"
>
<GitFork className="w-4 h-4" />
</button>
<button
onClick={() => deleteVersion(v.id, v.name)}
disabled={busy === v.id}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-rose-500/15 hover:text-rose-300 disabled:opacity-30"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Quick diff link if has parent */}
{v.parent_id && (
<div className="mt-3 pt-3 border-t border-white/[0.04]">
<Link
href={`/pitch-admin/versions/${v.id}/diff/${v.parent_id}`}
className="text-xs text-indigo-400 hover:text-indigo-300"
>
Compare with parent
</Link>
</div>
)}
</div>
)
})}
</div>
)}
{toast && (
<div className="fixed bottom-6 right-6 bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 text-sm px-4 py-2 rounded-lg backdrop-blur-sm z-50">
{toast}
</div>
)}
</div>
)
}