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
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
222
pitch-deck/app/pitch-admin/(authed)/versions/[id]/page.tsx
Normal file
222
pitch-deck/app/pitch-admin/(authed)/versions/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
120
pitch-deck/app/pitch-admin/(authed)/versions/new/page.tsx
Normal file
120
pitch-deck/app/pitch-admin/(authed)/versions/new/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
198
pitch-deck/app/pitch-admin/(authed)/versions/page.tsx
Normal file
198
pitch-deck/app/pitch-admin/(authed)/versions/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user