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:
@@ -1128,6 +1128,158 @@
|
|||||||
margin: 16px 0;
|
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 {
|
.confirmation-dialog {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
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 & 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 ====================
|
// ==================== Main App ====================
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const { token, user, logout } = useAuth();
|
const { token, user, logout } = useAuth();
|
||||||
@@ -8813,6 +9424,12 @@
|
|||||||
<button className={`nav-item ${page === 'communications' ? 'active' : ''}`} onClick={() => setPage('communications')}>
|
<button className={`nav-item ${page === 'communications' ? 'active' : ''}`} onClick={() => setPage('communications')}>
|
||||||
<span className="nav-item-icon">◌</span> Communications
|
<span className="nav-item-icon">◌</span> Communications
|
||||||
</button>
|
</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')}>
|
<button className={`nav-item ${page === 'feature-requests' ? 'active' : ''}`} onClick={() => setPage('feature-requests')}>
|
||||||
<span className="nav-item-icon">✦</span> Feedback
|
<span className="nav-item-icon">✦</span> Feedback
|
||||||
</button>
|
</button>
|
||||||
@@ -8838,6 +9455,8 @@
|
|||||||
{page === 'contacts' && 'Contacts'}
|
{page === 'contacts' && 'Contacts'}
|
||||||
{page === 'pipeline' && 'Pipeline'}
|
{page === 'pipeline' && 'Pipeline'}
|
||||||
{page === 'communications' && 'Communications'}
|
{page === 'communications' && 'Communications'}
|
||||||
|
{page === 'thesis' && 'Thesis'}
|
||||||
|
{page === 'system-status' && 'System Status'}
|
||||||
{page === 'feature-requests' && 'Feature Requests'}
|
{page === 'feature-requests' && 'Feature Requests'}
|
||||||
{page === 'instructions' && 'Instructions'}
|
{page === 'instructions' && 'Instructions'}
|
||||||
{page === 'settings' && 'Settings'}
|
{page === 'settings' && 'Settings'}
|
||||||
@@ -8866,6 +9485,8 @@
|
|||||||
{page === 'contacts' && <ContactsPage token={token} onShowToast={showToast} />}
|
{page === 'contacts' && <ContactsPage token={token} onShowToast={showToast} />}
|
||||||
{page === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} />}
|
{page === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} />}
|
||||||
{page === 'communications' && <CommunicationsPage 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 === 'feature-requests' && <FeatureRequestsPage token={token} onShowToast={showToast} user={user} />}
|
||||||
{page === 'instructions' && <InstructionsPage />}
|
{page === 'instructions' && <InstructionsPage />}
|
||||||
{page === 'settings' && (
|
{page === 'settings' && (
|
||||||
|
|||||||
Reference in New Issue
Block a user