Phase 1 UI: Thesis review (dual approval) + System Status views

Two React-via-Babel views in the CRM SPA, reusing the existing api() helper and
conventions:
- Thesis: lists thesis lines + the in-review queue with approvals/required pills;
  version detail renders throughline/pillars/claims/objections + the reviews
  timeline; admin review form (approve/request-changes/comment + feedback) ->
  POST /api/thesis/versions/{id}/review (the dual-approval feedback loop).
- System Status: entity counts, last index sync, thesis counts, recent activity
  from the interaction log — index health visible in-app, no shell.

Backend + full approve flow verified end-to-end via the running HTTP server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Keysat
2026-06-05 10:50:47 -05:00
parent dd2c34d7bc
commit fa2a5ce95f
+621
View File
@@ -1128,6 +1128,158 @@
margin: 16px 0;
}
.thesis-layout {
display: grid;
grid-template-columns: minmax(300px, 1fr) minmax(360px, 1.2fr);
gap: 20px;
align-items: start;
}
@media (max-width: 900px) {
.thesis-layout {
grid-template-columns: 1fr;
}
}
.thesis-col {
min-width: 0;
}
.thesis-line-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.thesis-line-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
background-color: #0d1622;
border: 1px solid #263548;
border-radius: 8px;
}
.thesis-version-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
text-align: left;
padding: 12px;
background-color: #0d1622;
border: 1px solid #263548;
border-radius: 8px;
color: var(--text-primary);
font-weight: 400;
}
.thesis-version-row:hover {
background-color: #152233;
border-color: #35506a;
transform: none;
box-shadow: none;
}
.thesis-version-row.active {
border-color: #3b82c4;
background-color: #152233;
box-shadow: inset 0 0 0 1px #3b82c455;
}
.thesis-line-name {
font-size: 14px;
font-weight: 500;
color: #e5edf5;
}
.thesis-line-meta {
font-size: 12px;
color: #8ea2b7;
margin-top: 3px;
}
.approval-pill {
flex-shrink: 0;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
font-weight: 600;
color: #93c5fd;
background-color: #3b82c422;
border-radius: 999px;
padding: 4px 10px;
}
.approval-meter {
display: flex;
align-items: baseline;
gap: 6px;
}
.approval-meter-count {
font-family: 'IBM Plex Mono', monospace;
font-size: 18px;
font-weight: 700;
color: #3b82c4;
}
.approval-meter-label {
font-size: 12px;
color: #8ea2b7;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.thesis-block {
margin-bottom: 18px;
}
.thesis-block-label {
font-size: 11px;
font-weight: 600;
color: #8ea2b7;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 8px;
}
.thesis-throughline {
font-size: 15px;
line-height: 1.6;
color: #e5edf5;
}
.thesis-list {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 8px;
color: #c7d3e0;
line-height: 1.5;
font-size: 13px;
}
.thesis-list-title {
color: #e5edf5;
font-weight: 500;
}
.thesis-list-detail {
margin-top: 3px;
font-size: 12px;
color: #8ea2b7;
}
.thesis-review-form {
margin-top: 8px;
border-top: 1px solid #263548;
padding-top: 16px;
}
.confirmation-dialog {
position: fixed;
top: 0;
@@ -8638,6 +8790,465 @@
);
};
const THESIS_STATUS_BADGE = {
canonical: 'badge-funded',
in_review: 'badge-meeting',
draft: 'badge-other',
superseded: 'badge-low',
archived: 'badge-low'
};
const thesisStatusClass = (status) => THESIS_STATUS_BADGE[status] || 'badge-other';
const formatThesisStatus = (status) => String(status || '').replace(/_/g, ' ');
const ThesisVersionDetail = ({ token, versionId, user, onShowToast, onReviewed }) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [reviewForm, setReviewForm] = useState({ decision: 'approve', feedback: '' });
const [submitting, setSubmitting] = useState(false);
const isAdmin = user?.role === 'admin';
const loadVersion = useCallback(async () => {
try {
setLoading(true);
const result = await api(`/api/thesis/versions/${versionId}`, {}, token);
setData(result);
setError('');
} catch (err) {
setError(getErrorMessage(err, 'Failed to load thesis version'));
} finally {
setLoading(false);
}
}, [token, versionId]);
useEffect(() => {
loadVersion();
}, [loadVersion]);
const handleSubmitReview = async (e) => {
e.preventDefault();
if (!isAdmin) return;
setSubmitting(true);
try {
await api(`/api/thesis/versions/${versionId}/review`, {
method: 'POST',
body: JSON.stringify({
decision: reviewForm.decision,
feedback: reviewForm.feedback
})
}, token);
setReviewForm({ decision: 'approve', feedback: '' });
onShowToast('Review submitted', 'success');
await loadVersion();
if (onReviewed) onReviewed();
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to submit review'), 'error');
} finally {
setSubmitting(false);
}
};
if (loading) return <div style={{ padding: '4px 0' }}><SkeletonBlock lines={6} /></div>;
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
if (!data) return <div className="empty-state" style={{ padding: '20px 0' }}>No content</div>;
const body = data.body || {};
const pillars = Array.isArray(body.pillars) ? body.pillars : [];
const claims = Array.isArray(body.claims) ? body.claims : [];
const proofPoints = Array.isArray(body.proof_points) ? body.proof_points : [];
const objections = Array.isArray(body.objections) ? body.objections : [];
const segmentCuts = Array.isArray(body.segment_cuts) ? body.segment_cuts : [];
const reviews = Array.isArray(data.reviews) ? data.reviews : [];
const approvals = data.approvals ?? 0;
const required = data.required ?? 0;
const renderTextOrLabel = (item) => {
if (item == null) return '-';
if (typeof item === 'string') return item;
return item.text || item.label || item.claim || item.title || JSON.stringify(item);
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '10px', marginBottom: '12px' }}>
<div style={{ fontWeight: 600, fontSize: '15px' }}>
{data.line_key} · v{data.version_no}
<span className={`badge ${thesisStatusClass(data.status)}`} style={{ marginLeft: '10px' }}>{formatThesisStatus(data.status)}</span>
</div>
<div className="approval-meter">
<span className="approval-meter-count">{approvals} / {required}</span>
<span className="approval-meter-label">approvals</span>
</div>
</div>
{body.throughline && (
<div className="thesis-block">
<div className="thesis-block-label">Throughline</div>
<div className="thesis-throughline">{body.throughline}</div>
</div>
)}
{pillars.length > 0 && (
<div className="thesis-block">
<div className="thesis-block-label">Pillars</div>
<ul className="thesis-list">
{pillars.map((p, i) => (
<li key={i}>
<span className="thesis-list-title">{renderTextOrLabel(p)}</span>
{p && typeof p === 'object' && p.detail && <div className="thesis-list-detail">{p.detail}</div>}
</li>
))}
</ul>
</div>
)}
{claims.length > 0 && (
<div className="thesis-block">
<div className="thesis-block-label">Claims</div>
<ul className="thesis-list">
{claims.map((c, i) => (<li key={i}>{renderTextOrLabel(c)}</li>))}
</ul>
</div>
)}
{proofPoints.length > 0 && (
<div className="thesis-block">
<div className="thesis-block-label">Proof Points</div>
<ul className="thesis-list">
{proofPoints.map((p, i) => (<li key={i}>{renderTextOrLabel(p)}</li>))}
</ul>
</div>
)}
{objections.length > 0 && (
<div className="thesis-block">
<div className="thesis-block-label">Objections</div>
<ul className="thesis-list">
{objections.map((o, i) => (<li key={i}>{renderTextOrLabel(o)}</li>))}
</ul>
</div>
)}
{segmentCuts.length > 0 && (
<div className="thesis-block">
<div className="thesis-block-label">Segment Cuts</div>
<ul className="thesis-list">
{segmentCuts.map((s, i) => (<li key={i}>{renderTextOrLabel(s)}</li>))}
</ul>
</div>
)}
<div className="thesis-block">
<div className="thesis-block-label">Reviews &amp; Feedback</div>
{reviews.length === 0 ? (
<div className="empty-state" style={{ padding: '16px 0' }}>No reviews yet.</div>
) : (
<div className="timeline">
{reviews.map((r, i) => (
<div key={i} className="timeline-item">
<div className="timeline-marker"></div>
<div className="timeline-content">
<div className="timeline-header">
{formatThesisStatus(r.decision)}
<span className="timeline-meta" style={{ marginLeft: '8px' }}>{r.reviewer_user_id || 'reviewer'}</span>
</div>
<div className="timeline-meta">{formatDateLong(r.created_at)}</div>
{r.feedback && <div className="timeline-body">{r.feedback}</div>}
</div>
</div>
))}
</div>
)}
</div>
{isAdmin ? (
<form onSubmit={handleSubmitReview} className="thesis-review-form">
<div className="thesis-block-label">Submit Review</div>
<div className="form-group">
<label className="form-label">Decision</label>
<select
className="select-input"
value={reviewForm.decision}
onChange={(e) => setReviewForm((f) => ({ ...f, decision: e.target.value }))}
>
<option value="approve">Approve</option>
<option value="request_changes">Request changes</option>
<option value="comment">Comment</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Feedback</label>
<textarea
className="text-input"
rows="4"
placeholder="Share your reasoning, concerns, or sign-off notes"
value={reviewForm.feedback}
onChange={(e) => setReviewForm((f) => ({ ...f, feedback: e.target.value }))}
/>
</div>
<div className="form-actions">
<button type="submit" disabled={submitting}>
{submitting ? 'Submitting…' : 'Submit Review'}
</button>
</div>
</form>
) : (
<div className="form-help" style={{ marginTop: '16px' }}>Only admins can submit a review. Two distinct approvals promote a version to canonical.</div>
)}
</div>
);
};
const ThesisPage = ({ token, user, onShowToast }) => {
const [lines, setLines] = useState([]);
const [versions, setVersions] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [selectedVersionId, setSelectedVersionId] = useState(null);
const [refreshTick, setRefreshTick] = useState(0);
useEffect(() => {
const load = async () => {
try {
setLoading(true);
const [linesResult, versionsResult] = await Promise.all([
api('/api/thesis/lines', {}, token),
api('/api/thesis/versions', {}, token)
]);
const lineList = linesResult.lines || [];
const versionList = versionsResult.versions || [];
setLines(lineList);
setVersions(versionList);
setError('');
setSelectedVersionId((prev) => {
if (prev && versionList.some((v) => v.id === prev)) return prev;
return versionList[0]?.id ?? null;
});
} catch (err) {
setError(getErrorMessage(err, 'Failed to load thesis data'));
} finally {
setLoading(false);
}
};
load();
}, [token, refreshTick]);
if (loading) return <div style={{ padding: '20px' }}><SkeletonBlock lines={8} /></div>;
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
return (
<div className="page-container">
<h2 className="section-title" style={{ marginBottom: '20px' }}>Thesis</h2>
<div className="thesis-layout">
<div className="thesis-col">
<div className="section">
<div className="section-title">Thesis Lines</div>
{lines.length === 0 ? (
<div className="empty-state" style={{ padding: '16px 0' }}>No thesis lines yet.</div>
) : (
<div className="thesis-line-list">
{lines.map((line) => (
<div key={line.id} className="thesis-line-row">
<div>
<div className="thesis-line-name">
{line.name}
{line.is_core && <span className="badge badge-investor" style={{ marginLeft: '8px' }}>Core</span>}
</div>
<div className="thesis-line-meta">{line.line_key}{line.segment_key ? ` · ${line.segment_key}` : ''}</div>
</div>
<span className={`badge ${thesisStatusClass(line.status)}`}>{formatThesisStatus(line.status)}</span>
</div>
))}
</div>
)}
</div>
<div className="section">
<div className="section-title">In-Review Queue</div>
{versions.length === 0 ? (
<div className="empty-state" style={{ padding: '16px 0' }}>Nothing awaiting review.</div>
) : (
<div className="thesis-line-list">
{versions.map((v) => (
<button
key={v.id}
className={`thesis-version-row ${selectedVersionId === v.id ? 'active' : ''}`}
onClick={() => setSelectedVersionId(v.id)}
>
<div>
<div className="thesis-line-name">{v.name || v.line_key} · v{v.version_no}</div>
<div className="thesis-line-meta">
{v.line_key} · {formatDate(v.created_at)}
{v.rationale ? ` · ${v.rationale}` : ''}
</div>
</div>
<span className="approval-pill">{v.approvals ?? 0}/{v.required ?? 0}</span>
</button>
))}
</div>
)}
</div>
</div>
<div className="thesis-col">
<div className="section">
<div className="section-title">Version Review</div>
{selectedVersionId == null ? (
<div className="empty-state" style={{ padding: '24px 0' }}>
<div className="empty-state-icon"></div>
Select a version from the review queue to read it and submit feedback.
</div>
) : (
<ThesisVersionDetail
key={selectedVersionId}
token={token}
user={user}
versionId={selectedVersionId}
onShowToast={onShowToast}
onReviewed={() => setRefreshTick((t) => t + 1)}
/>
)}
</div>
</div>
</div>
</div>
);
};
const SystemStatusPage = ({ token }) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
try {
setLoading(true);
const result = await api('/api/system/status', {}, token);
setData(result.data);
setError('');
} catch (err) {
setError(getErrorMessage(err, 'Failed to load system status'));
} finally {
setLoading(false);
}
};
load();
}, [token]);
if (loading) return <div style={{ padding: '20px' }}><SkeletonBlock lines={8} /></div>;
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
if (!data) return <div className="empty-state">No data</div>;
const entities = data.canonical_entities || {};
const sync = data.last_index_sync;
const thesis = data.thesis || {};
const activity = Array.isArray(data.recent_activity) ? data.recent_activity : [];
return (
<div className="page-container">
<h2 className="section-title" style={{ marginBottom: '20px' }}>System Status</h2>
<div className="kpi-grid">
<div className="kpi-card">
<div className="kpi-label">LPs</div>
<div className="kpi-value">{entities.lp ?? 0}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Organizations</div>
<div className="kpi-value">{entities.organization ?? 0}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">People</div>
<div className="kpi-value">{entities.person ?? 0}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Entity Links</div>
<div className="kpi-value">{data.entity_links ?? 0}</div>
</div>
</div>
<div className="section">
<div className="section-title">Last Index Sync</div>
{!sync ? (
<div className="empty-state" style={{ padding: '20px 0' }}>
<div className="empty-state-icon"></div>
The search index has not been built yet.
</div>
) : (
<div className="kpi-grid" style={{ marginBottom: 0 }}>
<div className="kpi-card">
<div className="kpi-label">When</div>
<div className="kpi-value" style={{ fontSize: '16px' }}>{formatDate(sync.ts)}</div>
<div className="kpi-subtitle">{formatDateLong(sync.ts)}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Mode</div>
<div className="kpi-value" style={{ fontSize: '16px' }}>{sync.mode || '-'}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Qdrant Points</div>
<div className="kpi-value">{sync.qdrant_points ?? 0}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Rows Embedded</div>
<div className="kpi-value">{sync.rows_embedded ?? 0}</div>
<div className="kpi-subtitle">{sync.total_chunks ?? 0} chunks</div>
</div>
</div>
)}
</div>
<div className="section">
<div className="section-title">Thesis</div>
<div className="kpi-grid" style={{ marginBottom: 0 }}>
<div className="kpi-card">
<div className="kpi-label">Lines</div>
<div className="kpi-value">{thesis.lines ?? 0}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Canonical Versions</div>
<div className="kpi-value">{thesis.canonical_versions ?? 0}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">In Review</div>
<div className="kpi-value">{thesis.in_review ?? 0}</div>
</div>
</div>
</div>
<div className="section">
<div className="section-title">Recent Activity</div>
{activity.length === 0 ? (
<div className="empty-state" style={{ padding: '20px 0' }}>No recent activity.</div>
) : (
<table className="table">
<thead>
<tr>
<th>When</th>
<th>Actor</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{activity.slice(0, 30).map((a, i) => (
<tr key={i}>
<td>{formatDateLong(a.ts)}</td>
<td>{a.actor_type}{a.actor_id ? ` · ${a.actor_id}` : ''}</td>
<td>{a.action}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
};
// ==================== Main App ====================
const App = () => {
const { token, user, logout } = useAuth();
@@ -8813,6 +9424,12 @@
<button className={`nav-item ${page === 'communications' ? 'active' : ''}`} onClick={() => setPage('communications')}>
<span className="nav-item-icon"></span> Communications
</button>
<button className={`nav-item ${page === 'thesis' ? 'active' : ''}`} onClick={() => setPage('thesis')}>
<span className="nav-item-icon">§</span> Thesis
</button>
<button className={`nav-item ${page === 'system-status' ? 'active' : ''}`} onClick={() => setPage('system-status')}>
<span className="nav-item-icon"></span> System Status
</button>
<button className={`nav-item ${page === 'feature-requests' ? 'active' : ''}`} onClick={() => setPage('feature-requests')}>
<span className="nav-item-icon"></span> Feedback
</button>
@@ -8838,6 +9455,8 @@
{page === 'contacts' && 'Contacts'}
{page === 'pipeline' && 'Pipeline'}
{page === 'communications' && 'Communications'}
{page === 'thesis' && 'Thesis'}
{page === 'system-status' && 'System Status'}
{page === 'feature-requests' && 'Feature Requests'}
{page === 'instructions' && 'Instructions'}
{page === 'settings' && 'Settings'}
@@ -8866,6 +9485,8 @@
{page === 'contacts' && <ContactsPage token={token} onShowToast={showToast} />}
{page === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} />}
{page === 'communications' && <CommunicationsPage token={token} onShowToast={showToast} />}
{page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />}
{page === 'system-status' && <SystemStatusPage token={token} />}
{page === 'feature-requests' && <FeatureRequestsPage token={token} onShowToast={showToast} user={user} />}
{page === 'instructions' && <InstructionsPage />}
{page === 'settings' && (