Files
ten31-database/frontend/index.html
T
Keysat 27e9ea5b0b Add 'bot' to the admin edit-user role dropdown (v0.1.0:90)
v89 added the 'bot' role for the Matrix email-review bot's endpoints but kept it
out of the UI, leaving no click-path to assign it. Add 'bot' to the Settings ->
Admin edit-user role dropdown (the teammate-invite form stays member/admin only —
provisioning an agent account is an admin re-classification of a dedicated user,
not a teammate invite). The backend update validator already accepts 'bot'.
Frontend-only, no schema change.
2026-06-18 10:13:30 -05:00

11028 lines
582 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ten31 database</title>
<link rel="icon" type="image/png" href="/assets/ten31-inverted-square.png">
<link rel="shortcut icon" href="/assets/ten31-inverted-square.png">
<!-- Vendored + SRI-pinned (v0.1.0:82). These ship inside the s9pk and are served
same-origin from /assets/vendor/, so a CDN can never swap our prod deps (the
v78/v79 blank-screen class) and the box needs no outbound internet to render.
The integrity hashes are sha384 of the exact bytes below; regenerate with
`openssl dgst -sha384 -binary FILE | openssl base64 -A` if you re-vendor.
Babel stays on the 7.x line on purpose: Babel 8 defaults @babel/preset-react
to the automatic JSX runtime, which emits `import {jsx} from "react/jsx-runtime"`
— illegal in this classic (non-module) inline script and blanks the whole app.
7.x preset-react defaults to the classic runtime (React.createElement). -->
<script src="/assets/vendor/react-18.3.1.production.min.js"
integrity="sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"></script>
<script src="/assets/vendor/react-dom-18.3.1.production.min.js"
integrity="sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"></script>
<script src="/assets/vendor/babel-standalone-7.29.7.min.js"
integrity="sha384-ezQ6HS3FLspd9te19o2McUV6FAK091+GG7KO54f/R8DKgCDi7fULhapNrd5LY+vG"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap');
:root {
--bg-base: #0b1118;
--bg-panel: #111a27;
--bg-panel-elevated: #152233;
--bg-hover: #1b2a3a;
--border: #263548;
--text-primary: #e5edf5;
--text-secondary: #c7d3e0;
--text-muted: #8ea2b7;
--accent: #3b82c4;
--accent-strong: #2f6ea9;
--accent-soft: #3b82c422;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'IBM Plex Sans', 'Avenir Next', 'Segoe UI', sans-serif;
background:
radial-gradient(1200px 600px at 15% -10%, #1a3c5e44, transparent 60%),
radial-gradient(1000px 500px at 90% 0%, #27496b33, transparent 58%),
var(--bg-base);
color: var(--text-primary);
overflow: hidden;
letter-spacing: 0.01em;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
.app-container {
display: flex;
height: 100vh;
}
.sidebar {
width: 250px;
background: linear-gradient(180deg, #111a27 0%, #101926 100%);
border-right: 1px solid var(--border);
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 20px;
gap: 20px;
transition: width 0.2s ease, padding 0.2s ease, opacity 0.2s ease;
}
.sidebar.hidden {
width: 0;
padding: 0;
border-right: none;
opacity: 0;
overflow: hidden;
}
.sidebar-header {
display: inline-flex;
align-items: center;
justify-content: flex-start;
min-height: 30px;
}
.sidebar-logo {
width: 42px;
height: 42px;
display: inline-flex;
align-items: center;
}
.sidebar-logo-img {
width: 42px;
height: 42px;
object-fit: contain;
display: block;
border-radius: 8px;
}
.sidebar-header-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.sidebar-toggle {
padding: 6px 10px;
background: #263548;
color: #c7d3e0;
border-radius: 6px;
font-size: 12px;
}
.nav-items {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.18s ease, transform 0.14s ease, box-shadow 0.18s ease;
border: none;
font-size: 14px;
color: #c7d3e0;
background: transparent;
width: 100%;
text-align: left;
}
.nav-item:hover {
background-color: #263548;
transform: translateX(1px);
}
.nav-item.active {
background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%);
color: white;
box-shadow: inset 0 0 0 1px #6ea6d455;
}
.nav-item-icon {
font-size: 14px;
min-width: 24px;
color: #89a9c4;
font-family: 'IBM Plex Mono', monospace;
font-weight: 600;
line-height: 1;
}
.nav-item.active .nav-item-icon {
color: #ffffff;
}
.sub-nav-items {
margin: -4px 0 6px 32px;
display: flex;
flex-direction: column;
gap: 4px;
}
.sub-nav-item {
border: none;
background: transparent;
color: #8ea2b7;
text-align: left;
padding: 6px 8px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.sub-nav-item:hover {
background: #263548;
color: #e5edf5;
}
.sub-nav-item.active {
background: linear-gradient(180deg, #2f6ea9 0%, #295f91 100%);
color: #fff;
}
.column-header-inner {
position: relative;
display: flex;
align-items: center;
min-height: 20px;
padding-right: 8px;
gap: 7px;
}
.column-drag-indicator {
color: #7f95ab;
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
line-height: 1;
letter-spacing: -1px;
opacity: 0.9;
pointer-events: none;
user-select: none;
}
.table th.column-dragging {
opacity: 0.55;
}
.table th.column-drag-over {
box-shadow: inset -3px 0 0 #4a9adf;
background: #172635;
}
.column-resize-handle {
position: absolute;
top: -8px;
right: -6px;
width: 12px;
height: calc(100% + 16px);
cursor: col-resize;
background: transparent;
z-index: 2;
}
.column-resize-handle::before {
content: '';
position: absolute;
left: 5px;
top: 4px;
bottom: 4px;
width: 2px;
border-radius: 1px;
background: #36506a;
opacity: 0.55;
transition: opacity 0.15s ease, background-color 0.15s ease;
}
.column-resize-handle:hover {
background: #3b82c455;
}
.column-resize-handle:hover::before {
opacity: 1;
background: #78acd8;
}
.grid-cell-selected {
outline: 2px solid #4a9adf;
outline-offset: -2px;
background: #1a2a3c;
box-shadow: inset 0 0 0 1px #9ac7ec40;
}
.context-menu {
position: fixed;
z-index: 5000;
min-width: 220px;
background: #0b1118;
border: 1px solid #263548;
border-radius: 8px;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
padding: 6px;
}
.context-menu-item {
width: 100%;
border: none;
background: transparent;
color: #e5edf5;
padding: 8px 10px;
text-align: left;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
}
.context-menu-item:hover {
background: #263548;
}
.context-menu-item.context-menu-danger {
color: #f3b2b2;
}
.context-menu-item.context-menu-danger:hover {
background: #3b1b24;
color: #ffd3d3;
}
.context-menu-sep {
height: 1px;
background: #263548;
margin: 6px 4px;
}
.sidebar-footer {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #263548;
}
.logout-btn {
padding: 10px 12px;
background-color: #7f1d1d;
border: none;
border-radius: 6px;
color: #fca5a5;
cursor: pointer;
font-size: 13px;
transition: background-color 0.2s;
}
.logout-btn:hover {
background-color: #991b1b;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
background: linear-gradient(180deg, #121d2b 0%, #101926 100%);
border-bottom: 1px solid #263548;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 0 #ffffff08 inset;
}
.header-title {
font-size: 20px;
font-weight: 600;
letter-spacing: 0.02em;
}
.user-info {
font-size: 13px;
color: #8ea2b7;
}
.content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.content::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.content::-webkit-scrollbar-thumb {
background: #2a3a4d;
border-radius: 20px;
border: 2px solid #101926;
}
.content::-webkit-scrollbar-track {
background: #0f1723;
}
.page-container {
max-width: 1400px;
margin: 0 auto;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.kpi-card {
background-color: #111a27;
border: 1px solid #263548;
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.2s ease;
}
.kpi-card:hover {
transform: translateY(-1px);
border-color: #35506a;
box-shadow: 0 10px 20px rgba(7, 17, 30, 0.35);
}
.kpi-label {
font-size: 12px;
color: #8ea2b7;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.kpi-value {
font-size: 24px;
font-weight: 700;
color: #3b82c4;
font-family: 'IBM Plex Mono', monospace;
letter-spacing: 0;
}
.kpi-subtitle {
font-size: 11px;
color: #70859b;
}
.section {
background-color: #111a27;
border: 1px solid #263548;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 14px 26px rgba(2, 12, 24, 0.28), inset 0 1px 0 #ffffff07;
}
.section-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #e5edf5;
}
.controls {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.search-input, .text-input, .select-input {
padding: 10px 12px;
background-color: #0d1622;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
flex: 1;
min-width: 200px;
}
.search-input:focus, .text-input:focus, .select-input:focus {
outline: none;
border-color: #3b82c4;
background-color: #111a27;
}
.text-input, .select-input {
min-width: 150px;
}
button {
padding: 10px 16px;
background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%);
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
font-size: 13px;
transition: background-color 0.15s ease, transform 0.12s ease, box-shadow 0.15s ease;
font-weight: 500;
font-family: 'IBM Plex Sans', 'Avenir Next', 'Segoe UI', sans-serif;
letter-spacing: 0.01em;
}
button:hover {
background-color: #2f6ea9;
transform: translateY(-1px);
box-shadow: 0 6px 14px rgba(12, 40, 68, 0.35);
}
button:active {
transform: translateY(0);
box-shadow: none;
}
button:disabled {
background-color: #3b4e63;
cursor: not-allowed;
}
.button-secondary {
background: #1b2837;
color: var(--text-primary);
}
.button-secondary:hover {
background-color: #3b4e63;
}
.button-danger {
background-color: #dc2626;
}
.button-danger:hover {
background-color: #b91c1c;
}
.table {
width: 100%;
border-collapse: collapse;
}
.fundraising-table {
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
}
.fundraising-table.compact-rows td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-height: 34px;
padding-top: 6px;
padding-bottom: 6px;
line-height: 1.2;
}
.compact-cell-preview {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.grid-table-shell {
overflow: auto;
max-height: calc(100vh - 280px);
border: 1px solid #263548;
border-radius: 10px;
background: #0c141f;
box-shadow: inset 0 1px 0 #ffffff06;
}
.grid-table-shell::-webkit-scrollbar {
height: 11px;
}
.grid-table-shell::-webkit-scrollbar-thumb {
background: #2b3f54;
border-radius: 20px;
border: 2px solid #0f1723;
}
.grid-table-shell::-webkit-scrollbar-track {
background: #0c141f;
}
.table th {
background-color: #0d1622;
padding: 11px 12px;
text-align: left;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
border-bottom: 1px solid var(--border);
border-right: 1px solid var(--border);
cursor: pointer;
user-select: none;
position: sticky;
top: 0;
z-index: 3;
box-shadow: 0 1px 0 #1b2a3a;
}
.fundraising-table thead th {
position: sticky;
top: 0;
z-index: 5;
}
.table th:hover {
color: #c7d3e0;
}
.table td {
padding: 9px 12px;
border-bottom: 1px solid var(--border);
border-right: 1px solid var(--border);
font-size: 13px;
font-variant-numeric: tabular-nums;
line-height: 1.4;
transition: background-color 0.12s ease, box-shadow 0.14s ease;
}
.table th:last-child,
.table td:last-child {
border-right: none;
}
.table tbody tr:nth-child(even) td {
background: #101b28;
}
.table tr:hover {
background-color: #172435;
cursor: pointer;
}
.table tr:hover td {
background-color: #172435;
}
.table th.sticky-col {
position: sticky;
top: 0;
left: 0;
z-index: 8;
box-shadow: 1px 0 0 #263548, 0 1px 0 #1b2a3a;
}
.table td.sticky-col {
position: sticky;
left: 0;
z-index: 2;
background: #0d1622;
box-shadow: 1px 0 0 #263548;
}
.table tbody tr:nth-child(even) td.sticky-col {
background: #101b28;
}
.table tr:hover td.sticky-col {
background-color: #172435;
}
.fundraising-table tfoot td {
position: sticky;
bottom: 0;
z-index: 4;
background: #0f1b2a;
border-top: 1px solid #2a3d52;
box-shadow: 0 -1px 0 #1d2c3d;
}
.fundraising-table tfoot td.sticky-col {
z-index: 7;
left: 0;
box-shadow: 1px 0 0 #263548, 0 -1px 0 #1d2c3d;
background: #0f1b2a;
}
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-investor {
background-color: #10b98122;
color: #6ee7b7;
}
.badge-prospect {
background-color: #3b82c422;
color: #93c5fd;
}
.badge-advisor {
background-color: #f59e0b22;
color: #fcd34d;
}
.badge-other {
background-color: #70859b44;
color: #c7d3e0;
}
.badge-lead {
background-color: #ec407a22;
color: #f48fb1;
}
.badge-outreach {
background-color: #ff9800aa;
color: #fff3cd;
}
.badge-meeting {
background-color: #2196f322;
color: #90caf9;
}
.badge-due-diligence {
background-color: #2563eb22;
color: #93c5fd;
}
.badge-committed {
background-color: #ff5722aa;
color: #ffccbc;
}
.badge-funded {
background-color: #4caf5022;
color: #a5d6a7;
}
.badge-high {
background-color: #dc262622;
color: #fca5a5;
}
.badge-medium {
background-color: #f59e0b22;
color: #fcd34d;
}
.badge-low {
background-color: #6b7280aa;
color: #d1d5db;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.56);
backdrop-filter: blur(3px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background-color: #111a27;
border: 1px solid #263548;
border-radius: 12px;
padding: 24px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
animation: modalIn 0.22s ease-out;
box-shadow: 0 24px 56px rgba(1, 8, 17, 0.5), inset 0 1px 0 #ffffff08;
}
.modal-header {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.form-group {
margin-bottom: 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-size: 13px;
font-weight: 500;
color: #c7d3e0;
}
.form-help {
font-size: 12px;
color: #8ea2b7;
line-height: 1.4;
}
.form-error {
font-size: 12px;
color: #fca5a5;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 24px;
justify-content: flex-end;
}
.toast {
position: fixed;
bottom: 20px;
right: 20px;
background-color: #111a27;
border: 1px solid #263548;
border-radius: 8px;
padding: 16px;
max-width: 400px;
z-index: 2000;
animation: slideIn 0.3s ease-out;
box-shadow: 0 10px 24px rgba(4, 12, 22, 0.45);
}
.toast.error {
border-color: #7f1d1d;
background-color: #1f1a1a;
}
.toast.success {
border-color: #15803d;
background-color: #1a1f1a;
}
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes modalIn {
from {
transform: translateY(8px) scale(0.985);
opacity: 0;
}
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #263548;
border-top: 2px solid #3b82c4;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.skeleton-block {
border: 1px solid #263548;
border-radius: 10px;
background: linear-gradient(180deg, #101926 0%, #0e1824 100%);
padding: 14px;
box-shadow: inset 0 1px 0 #ffffff06;
}
.skeleton-line {
height: 11px;
border-radius: 999px;
background: linear-gradient(90deg, #263548 0%, #3a526d 48%, #263548 100%);
background-size: 200% 100%;
animation: shimmer 1.35s linear infinite;
margin-bottom: 10px;
}
.skeleton-line:last-child {
margin-bottom: 0;
}
.skeleton-line.w-30 { width: 30%; }
.skeleton-line.w-45 { width: 45%; }
.skeleton-line.w-60 { width: 60%; }
.skeleton-line.w-75 { width: 75%; }
.skeleton-line.w-90 { width: 90%; }
@keyframes shimmer {
from { background-position: 180% 0; }
to { background-position: -20% 0; }
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #70859b;
}
.empty-state-icon {
font-size: 48px;
margin-bottom: 16px;
}
.tabs {
display: flex;
gap: 20px;
border-bottom: 1px solid #263548;
margin-bottom: 20px;
}
.tab {
padding: 12px 0;
color: #70859b;
border: none;
background: none;
cursor: pointer;
font-size: 13px;
font-weight: 500;
position: relative;
transition: color 0.2s;
}
.tab:hover {
color: #c7d3e0;
}
.tab.active {
color: #3b82c4;
}
.tab.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background-color: #3b82c4;
}
.kanban-board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px;
margin-top: 20px;
}
.kanban-column {
background-color: #0b1118;
border: 1px solid #263548;
border-radius: 8px;
padding: 16px;
min-height: 400px;
}
.kanban-header {
font-weight: 600;
margin-bottom: 16px;
color: #e5edf5;
}
.kanban-card {
background-color: #111a27;
border: 1px solid #263548;
border-radius: 6px;
padding: 12px;
margin-bottom: 12px;
cursor: pointer;
transition: border-color 0.18s ease, transform 0.14s ease, box-shadow 0.2s ease;
}
.kanban-card:hover {
border-color: #3b82c4;
transform: translateY(-1px);
box-shadow: 0 8px 18px rgba(6, 20, 34, 0.36);
}
.kanban-card-title {
font-weight: 500;
margin-bottom: 6px;
color: #e5edf5;
}
.kanban-card-subtitle {
font-size: 12px;
color: #8ea2b7;
margin-bottom: 8px;
}
.kanban-card-amount {
font-size: 13px;
font-weight: 600;
color: #10b981;
margin-bottom: 6px;
}
.slide-over {
position: fixed;
right: 0;
top: 0;
bottom: 0;
width: 400px;
background-color: #111a27;
border-left: 1px solid #263548;
overflow-y: auto;
z-index: 999;
padding: 20px;
animation: slideInRight 0.3s ease-out;
box-shadow: -12px 0 32px rgba(4, 10, 18, 0.45), inset 1px 0 0 #ffffff07;
}
@keyframes slideInRight {
from {
transform: translateX(400px);
}
to {
transform: translateX(0);
}
}
.slide-over-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid #263548;
}
.slide-over-title {
font-size: 18px;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
color: #8ea2b7;
cursor: pointer;
font-size: 24px;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.close-btn:hover {
color: #e5edf5;
}
.detail-section {
margin-bottom: 20px;
}
.detail-section-title {
font-size: 12px;
font-weight: 600;
color: #8ea2b7;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #263548;
font-size: 13px;
}
.detail-label {
color: #8ea2b7;
}
.detail-value {
color: #e5edf5;
font-weight: 500;
}
.timeline {
display: flex;
flex-direction: column;
gap: 16px;
}
.timeline-item {
display: flex;
gap: 12px;
position: relative;
padding-left: 24px;
transition: transform 0.14s ease;
}
.timeline-item:hover {
transform: translateX(2px);
}
.timeline-marker {
width: 8px;
height: 8px;
background-color: #3b82c4;
border-radius: 50%;
position: absolute;
left: 0;
top: 6px;
}
.timeline-content {
flex: 1;
}
.timeline-header {
font-weight: 500;
color: #e5edf5;
margin-bottom: 4px;
}
.timeline-meta {
font-size: 12px;
color: #8ea2b7;
}
.timeline-body {
margin-top: 6px;
font-size: 13px;
color: #c7d3e0;
line-height: 1.5;
}
.divider {
height: 1px;
background-color: #263548;
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;
}
/* Thesis Workshop ----------------------------------------------------- */
.thesis-ws-banner {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
margin-bottom: 18px;
background-color: #1a2233;
border: 1px solid #35506a;
border-radius: 8px;
color: #c7d3e0;
font-size: 13px;
line-height: 1.5;
}
.thesis-ws-banner.ready {
background-color: #10b9810f;
border-color: #1f6f54;
}
.thesis-ws-banner-icon {
flex-shrink: 0;
font-size: 16px;
line-height: 1.3;
}
.thesis-ws-node {
border: 1px solid #263548;
border-radius: 8px;
background-color: #0d1622;
margin-bottom: 14px;
overflow: hidden;
}
.thesis-ws-node.selected {
border-color: #3b82c4;
box-shadow: inset 0 0 0 1px #3b82c455;
}
.thesis-ws-node-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
text-align: left;
padding: 12px 14px;
background-color: transparent;
color: var(--text-primary);
font-weight: 400;
border-radius: 0;
}
.thesis-ws-node-head:hover {
background-color: #152233;
transform: none;
box-shadow: none;
}
.thesis-ws-node-type {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #8ea2b7;
}
.thesis-ws-node-title {
font-size: 14px;
font-weight: 500;
color: #e5edf5;
margin-top: 3px;
}
.thesis-ws-children {
padding-left: 18px;
border-left: 1px solid #1d2a3a;
margin: 4px 0 4px 18px;
}
.thesis-ws-body {
padding: 4px 14px 16px;
}
.thesis-ws-count {
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;
}
.thesis-ws-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.thesis-ws-option {
border: 1px solid #263548;
border-radius: 8px;
background-color: #111a27;
padding: 14px;
}
.thesis-ws-option-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.thesis-ws-option-num {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
font-weight: 600;
color: #8ea2b7;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.thesis-ws-option-text {
font-size: 14px;
line-height: 1.6;
color: #e5edf5;
white-space: pre-wrap;
}
.thesis-ws-rationale {
margin-top: 10px;
padding: 8px 10px;
background-color: #0d1622;
border-left: 2px solid #3b82c4;
border-radius: 0 6px 6px 0;
font-size: 12px;
line-height: 1.5;
color: #8ea2b7;
}
.thesis-ws-rationale-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #93c5fd;
margin-bottom: 3px;
}
.thesis-ws-controls {
margin-top: 16px;
border-top: 1px solid #263548;
padding-top: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
@media (min-width: 760px) {
.thesis-ws-controls {
flex-direction: row;
align-items: flex-start;
}
.thesis-ws-control-col {
flex: 1;
min-width: 0;
}
}
.thesis-ws-control-col {
display: flex;
flex-direction: column;
gap: 8px;
}
.thesis-ws-n-input {
min-width: 0;
width: 80px;
flex: none;
}
.index-action-status {
margin-bottom: 14px;
}
.index-job-pill {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
color: #c7d3e0;
background-color: #1b2837;
border-radius: 999px;
padding: 6px 12px;
}
.index-job-pill.idle {
color: #8ea2b7;
}
.index-job-pill.running {
color: #fcd34d;
background-color: #f59e0b22;
}
.index-job-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #fcd34d;
animation: index-job-pulse 1.2s ease-in-out infinite;
}
@keyframes index-job-pulse {
0%, 100% { opacity: 0.35; }
50% { opacity: 1; }
}
.index-job-tail {
margin: 0 0 14px;
max-height: 140px;
overflow: auto;
background-color: #0d1622;
border: 1px solid #263548;
border-radius: 6px;
padding: 10px 12px;
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: #8ea2b7;
white-space: pre-wrap;
}
.index-action-buttons {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.index-action-hint {
margin-top: 12px;
font-size: 12px;
color: #8ea2b7;
line-height: 1.5;
}
.merge-candidate-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.merge-candidate-card {
background-color: #0d1622;
border: 1px solid #263548;
border-radius: 8px;
padding: 16px;
}
.merge-candidate-people {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.merge-candidate-person {
flex: 1;
min-width: 160px;
}
.merge-candidate-name {
font-size: 14px;
font-weight: 600;
color: #e5edf5;
}
.merge-candidate-email {
margin-top: 2px;
font-size: 12px;
color: #8ea2b7;
font-family: 'IBM Plex Mono', monospace;
}
.merge-candidate-vs {
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
color: #8ea2b7;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.merge-candidate-context {
margin-top: 10px;
font-size: 12px;
color: #c7d3e0;
}
.merge-candidate-suggestion {
margin-top: 12px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.merge-candidate-confidence {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
color: #8ea2b7;
}
.merge-candidate-reason {
margin-top: 8px;
font-size: 13px;
line-height: 1.5;
color: #c7d3e0;
}
.merge-candidate-actions {
margin-top: 14px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.confirmation-dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1001;
}
.confirmation-box {
background-color: #111a27;
border: 1px solid #263548;
border-radius: 12px;
padding: 24px;
max-width: 400px;
width: 90%;
}
.confirmation-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
}
.confirmation-message {
font-size: 13px;
color: #c7d3e0;
margin-bottom: 20px;
}
.confirmation-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.login-container {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background-color: #0b1118;
}
.login-card {
background-color: #111a27;
border: 1px solid #263548;
border-radius: 12px;
padding: 32px;
width: 100%;
max-width: 400px;
}
.login-title {
font-size: 24px;
font-weight: 700;
margin-bottom: 8px;
text-align: center;
}
.login-logo-wrap {
display: flex;
justify-content: center;
margin-bottom: 10px;
}
.login-logo-img {
width: 96px;
height: 96px;
object-fit: contain;
display: block;
border-radius: 12px;
}
.login-subtitle {
font-size: 13px;
color: #8ea2b7;
text-align: center;
margin-bottom: 24px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.login-button {
width: 100%;
}
.login-link {
text-align: center;
font-size: 13px;
color: #8ea2b7;
}
.login-link a {
color: #3b82c4;
text-decoration: none;
cursor: pointer;
}
.login-link a:hover {
text-decoration: underline;
}
.pipeline-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.pipeline-stage-card {
background-color: #0b1118;
border: 1px solid #263548;
border-radius: 6px;
padding: 12px;
text-align: center;
}
.pipeline-stage-name {
font-size: 12px;
color: #8ea2b7;
margin-bottom: 6px;
}
.pipeline-stage-count {
font-size: 18px;
font-weight: 700;
color: #3b82c4;
margin-bottom: 4px;
}
.pipeline-stage-amount {
font-size: 11px;
color: #10b981;
}
.csv-preview {
background-color: #0b1118;
border: 1px solid #263548;
border-radius: 6px;
padding: 12px;
margin: 16px 0;
overflow-x: auto;
}
.csv-preview-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.csv-preview-table th {
background-color: #111a27;
padding: 8px;
text-align: left;
border-bottom: 1px solid #263548;
font-weight: 600;
color: #8ea2b7;
}
.csv-preview-table td {
padding: 8px;
border-bottom: 1px solid #263548;
}
.drag-drop-area {
border: 2px dashed #263548;
border-radius: 8px;
padding: 30px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s;
margin-bottom: 16px;
}
.drag-drop-area:hover {
border-color: #3b82c4;
}
.drag-drop-area.active {
border-color: #3b82c4;
background-color: #0b1118;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #10b981;
}
.stat-label {
font-size: 12px;
color: #8ea2b7;
margin-top: 4px;
}
@media (max-width: 768px) {
.sidebar {
display: none;
}
.header {
padding: 14px 12px;
}
.content {
padding: 12px;
}
.section {
padding: 12px;
}
.slide-over {
width: 100%;
}
.kpi-grid {
grid-template-columns: repeat(2, 1fr);
}
.kanban-board {
grid-template-columns: 1fr;
}
.table th {
font-size: 10px;
padding: 9px 8px;
}
.table td {
font-size: 12px;
padding: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useContext, createContext, useCallback, useMemo, useRef } = React;
// ==================== Context ====================
const AuthContext = createContext();
const MOCK_MODE = false;
const getErrorMessage = (err, fallback) => {
if (!err) return fallback;
if (typeof err === 'string') return err;
return err.message || err.error || fallback;
};
const contactName = (row) => {
if (!row) return '-';
if (row.contact_name) return row.contact_name;
if (row.first_name || row.last_name) return `${row.first_name || ''} ${row.last_name || ''}`.trim();
return '-';
};
const loadFeatureRequests = () => {
try {
const raw = localStorage.getItem('venture_crm_feature_requests');
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (_) {
return [];
}
};
const FUNDRAISING_GRID_STORAGE_KEY = 'venture_crm_fundraising_grid_v1';
const FUNDRAISING_VIEWS_STORAGE_KEY = 'venture_crm_fundraising_views_v1';
const FUNDRAISING_VERSION_STORAGE_KEY = 'venture_crm_fundraising_version_v1';
// Persisted auth so refreshes / tab closes don't force a re-login.
// The JWT carries the user's real role and is verified server-side on
// every request, so a tampered localStorage user object can't escalate
// privileges — the backend will simply reject the next admin call.
const AUTH_TOKEN_STORAGE_KEY = 'venture_crm_auth_token_v1';
const AUTH_USER_STORAGE_KEY = 'venture_crm_auth_user_v1';
const persistAuth = (token, user) => {
try {
if (token) localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token);
else localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
if (user) localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(user));
else localStorage.removeItem(AUTH_USER_STORAGE_KEY);
} catch (_) {
// Private browsing, quota exceeded, etc. — non-fatal; the user
// just won't get the persistence convenience this session.
}
};
const loadStoredAuthToken = () => {
try { return localStorage.getItem(AUTH_TOKEN_STORAGE_KEY) || null; }
catch (_) { return null; }
};
const loadStoredAuthUser = () => {
try {
const raw = localStorage.getItem(AUTH_USER_STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (_) { return null; }
};
const MOCK_DEFAULT_FUNDRAISING_GRID = {
columns: [
{ id: 'investor_name', label: 'Investor Name', type: 'text', width: 220 },
{ id: 'contacts', label: 'Contacts', type: 'contacts', width: 260 },
{ id: 'log_action', label: 'Log', type: 'action', readOnly: true, width: 90 },
{ id: 'notes', label: 'Notes / Communication / Outreach', type: 'longtext', width: 420 },
{ id: 'lead_source', label: 'Lead Source', type: 'text', width: 180 },
{ id: 'notes_last_modified', label: 'Notes Last Modified', type: 'date', readOnly: true, width: 180 },
{ id: 'last_communication_date', label: 'Last Communication Date', type: 'date', readOnly: true, width: 195 },
{ id: 'priority', label: 'Priority', type: 'checkbox', width: 110 },
{ id: 'follow_up', label: 'Follow up', type: 'checkbox', width: 110 },
{ id: 'lead', label: 'Lead', type: 'select', options: ['Grant', 'JK', 'GG', 'MB', 'Unassigned'], width: 130 },
{ id: 'graveyard', label: 'Graveyard', type: 'checkbox', width: 115 },
{ id: 'fund_i', label: 'Fund I', type: 'currency', isFund: true, width: 130 },
{ id: 'fund_ii', label: 'Fund II', type: 'currency', isFund: true, width: 130 },
{ id: 'fund_iii', label: 'Fund III', type: 'currency', isFund: true, width: 130 },
{ id: 'tactical_fund', label: 'Tactical Fund', type: 'currency', isFund: true, width: 140 },
{ id: 'pawn_to_e4', label: 'Pawn to E4', type: 'currency', isFund: true, width: 130 },
{ id: 'ten31_terahash', label: 'Ten31 Terahash', type: 'currency', isFund: true, width: 150 },
{ id: 'sats_and_stats', label: 'Sats and Stats', type: 'currency', isFund: true, width: 140 },
{ id: 'pawn_to_f4', label: 'Pawn to f4', type: 'currency', isFund: true, width: 130 },
{ id: 'join_the_fold', label: 'Join the Fold', type: 'currency', isFund: true, width: 130 },
{ id: 'total_invested', label: 'Total invested', type: 'rollup', readOnly: true, width: 150 },
{ id: 'tactical_fund_commit_date', label: 'Tactical Fund Commit Date', type: 'date', width: 180 }
],
rows: [
{
id: 'inv-1',
investor_name: 'Caprock / Grey Street',
contacts: [
{ name: 'Jeffrey Friedstein', email: 'jeffrey@example.com', title: '', city: 'New York City', state: 'NY', country: 'USA', location_query: 'New York City' },
{ name: 'Jay P', email: 'jay@example.com', title: 'Analyst', city: '', state: '', country: '', location_query: '' }
],
notes: 'Intro from Alan Handler. Potentially interested in Strike.',
notes_last_modified: '2026-02-10',
last_communication_date: '2026-02-10',
priority: true,
follow_up: true,
lead: 'JK',
graveyard: false,
fund_i: 0,
fund_ii: 2500000,
fund_iii: 0,
tactical_fund: 0,
pawn_to_e4: 0,
ten31_terahash: 0,
sats_and_stats: 0,
pawn_to_f4: 0,
join_the_fold: 0,
tactical_fund_commit_date: ''
},
{
id: 'inv-2',
investor_name: 'Comer Family Office',
contacts: [{ name: 'Michael O\'Shaughnessy', email: 'mike@example.com', title: '', city: 'Austin', state: 'TX', country: 'USA', location_query: 'Austin' }],
notes: 'Met in Austin. Wants updates in Q2.',
notes_last_modified: '2026-02-12',
last_communication_date: '2026-02-12',
priority: false,
follow_up: true,
lead: 'Grant',
graveyard: false,
fund_i: 0,
fund_ii: 1000000,
fund_iii: 0,
tactical_fund: 500000,
pawn_to_e4: 0,
ten31_terahash: 0,
sats_and_stats: 0,
pawn_to_f4: 0,
join_the_fold: 0,
tactical_fund_commit_date: '2026-03-15'
}
]
};
let mockDb = {
users: [
{ id: 'u-admin', username: 'admin', email: 'admin@fund.com', full_name: 'Fund Admin', role: 'admin', password: 'admin123' },
{ id: 'u-grant', username: 'grant', email: 'grant@fund.com', full_name: 'Grant', role: 'admin', password: 'password' }
],
contacts: [
{ id: 'c-1001', first_name: 'James', last_name: 'Chen', email: 'jchen@sovereign.com', phone: '555-1001', title: 'Managing Director', organization: 'Sovereign Wealth Holdings', organization_name: 'Sovereign Wealth Holdings', contact_type: 'investor', status: 'active', last_contact_date: '2026-02-12T15:00:00Z', communication_count: 3, comm_count: 3 },
{ id: 'c-1002', first_name: 'Sarah', last_name: 'Williams', email: 'swilliams@pacificcap.com', phone: '555-1002', title: 'CIO', organization: 'Pacific Capital Partners', organization_name: 'Pacific Capital Partners', contact_type: 'investor', status: 'active', last_contact_date: '2026-02-11T12:00:00Z', communication_count: 2, comm_count: 2 },
{ id: 'c-1003', first_name: 'David', last_name: 'Martinez', email: 'dmartinez@cascade.com', phone: '555-1003', title: 'Partner', organization: 'Cascade Wealth Management', organization_name: 'Cascade Wealth Management', contact_type: 'prospect', status: 'active', last_contact_date: '2026-02-10T10:00:00Z', communication_count: 2, comm_count: 2 },
{ id: 'c-1004', first_name: 'Jennifer', last_name: 'Taylor', email: 'jtaylor@blueharbor.org', phone: '555-1004', title: 'Executive Director', organization: 'Blue Harbor Foundation', organization_name: 'Blue Harbor Foundation', contact_type: 'prospect', status: 'active', last_contact_date: '2026-02-09T08:30:00Z', communication_count: 1, comm_count: 1 }
],
opportunities: [
{ id: 'o-2001', name: 'Cascade - Fund II', contact_id: 'c-1003', contact_name: 'David Martinez', stage: 'meeting', expected_amount: 10000000, commitment_amount: 0, probability: 35, priority: 'high', fund_name: 'Fund II', organization_name: 'Cascade Wealth Management', updated_at: '2026-02-12T10:00:00Z' },
{ id: 'o-2002', name: 'Blue Harbor - Fund II', contact_id: 'c-1004', contact_name: 'Jennifer Taylor', stage: 'due_diligence', expected_amount: 5000000, commitment_amount: 0, probability: 60, priority: 'medium', fund_name: 'Fund II', organization_name: 'Blue Harbor Foundation', updated_at: '2026-02-11T10:00:00Z' },
{ id: 'o-2003', name: 'Sovereign Re-up', contact_id: 'c-1001', contact_name: 'James Chen', stage: 'committed', expected_amount: 25000000, commitment_amount: 10000000, probability: 85, priority: 'high', fund_name: 'Fund III', organization_name: 'Sovereign Wealth Holdings', updated_at: '2026-02-10T10:00:00Z' }
],
communications: [
{ id: 'm-3001', contact_id: 'c-1001', contact_name: 'James Chen', type: 'meeting', subject: 'Q1 Strategy Review', body: 'Discussed deployment pace.', communication_date: '2026-02-12T15:00:00Z', outcome: 'positive' },
{ id: 'm-3002', contact_id: 'c-1003', contact_name: 'David Martinez', type: 'call', subject: 'Follow-up call', body: 'Requested portfolio details.', communication_date: '2026-02-10T10:00:00Z', outcome: 'positive', next_action: 'Send updated deck', next_action_date: '2026-02-20' },
{ id: 'm-3003', contact_id: 'c-1004', contact_name: 'Jennifer Taylor', type: 'email', subject: 'DDQ package', body: 'Sent due diligence package.', communication_date: '2026-02-11T09:00:00Z', outcome: 'neutral', next_action: 'Schedule IC call', next_action_date: '2026-02-22' }
],
lp_profiles: [
{ id: 'lp-4001', contact_id: 'c-1001', contact_name: 'James Chen', organization: 'Sovereign Wealth Holdings', commitment_amount: 25000000, funded_amount: 25000000, fund_name: 'Fund I', legal_docs_signed: true, wire_received: true, k1_sent: true },
{ id: 'lp-4002', contact_id: 'c-1002', contact_name: 'Sarah Williams', organization: 'Pacific Capital Partners', commitment_amount: 15000000, funded_amount: 15000000, fund_name: 'Fund I', legal_docs_signed: true, wire_received: true, k1_sent: false }
],
tags: [
{ id: 't-5001', name: 'High Priority', color: '#ef4444' },
{ id: 't-5002', name: 'Fund II Prospect', color: '#3b82c4' }
],
feature_requests: loadFeatureRequests(),
audit_log: []
};
const persistFeatureRequests = () => {
if (!MOCK_MODE) return;
try {
localStorage.setItem('venture_crm_feature_requests', JSON.stringify(mockDb.feature_requests || []));
} catch (_) {
// no-op
}
};
const loadMockFundraisingGrid = () => {
try {
const raw = localStorage.getItem(FUNDRAISING_GRID_STORAGE_KEY);
if (!raw) return clone(MOCK_DEFAULT_FUNDRAISING_GRID);
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.columns) || !Array.isArray(parsed.rows)) return clone(MOCK_DEFAULT_FUNDRAISING_GRID);
return parsed;
} catch (_) {
return clone(MOCK_DEFAULT_FUNDRAISING_GRID);
}
};
const loadMockFundraisingViews = () => {
try {
const raw = localStorage.getItem(FUNDRAISING_VIEWS_STORAGE_KEY);
if (!raw) return clone(DEFAULT_GRID_VIEWS);
const parsed = JSON.parse(raw);
return Array.isArray(parsed) && parsed.length > 0 ? parsed : clone(DEFAULT_GRID_VIEWS);
} catch (_) {
return clone(DEFAULT_GRID_VIEWS);
}
};
const loadMockFundraisingVersion = () => {
const raw = localStorage.getItem(FUNDRAISING_VERSION_STORAGE_KEY);
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
};
const persistMockFundraisingState = (grid, views, version) => {
try {
localStorage.setItem(FUNDRAISING_GRID_STORAGE_KEY, JSON.stringify(grid));
localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(views));
localStorage.setItem(FUNDRAISING_VERSION_STORAGE_KEY, String(version));
} catch (_) {
// no-op
}
};
const parseEndpoint = (endpoint) => {
const [path, queryString = ''] = endpoint.split('?');
return { path, params: new URLSearchParams(queryString) };
};
const clone = (value) => JSON.parse(JSON.stringify(value));
const refreshDerived = () => {
mockDb.contacts = mockDb.contacts.map((c) => {
const comms = mockDb.communications.filter((m) => m.contact_id === c.id);
const lastContact = comms.length ? comms.map((m) => m.communication_date).sort().slice(-1)[0] : null;
return {
...c,
communication_count: comms.length,
comm_count: comms.length,
last_contact_date: lastContact
};
});
mockDb.opportunities = mockDb.opportunities.map((o) => {
const c = mockDb.contacts.find((x) => x.id === o.contact_id);
return {
...o,
contact_name: contactName(c),
organization_name: c?.organization_name || c?.organization || o.organization_name || ''
};
});
mockDb.lp_profiles = mockDb.lp_profiles.map((lp) => {
const c = mockDb.contacts.find((x) => x.id === lp.contact_id);
return {
...lp,
contact_name: contactName(c),
organization: c?.organization_name || c?.organization || lp.organization || ''
};
});
mockDb.communications = mockDb.communications.map((m) => {
const c = mockDb.contacts.find((x) => x.id === m.contact_id);
return { ...m, contact_name: contactName(c) };
});
};
const mockApi = async (endpoint, options = {}) => {
refreshDerived();
const method = (options.method || 'GET').toUpperCase();
const body = options.body ? JSON.parse(options.body) : {};
const { path, params } = parseEndpoint(endpoint);
const makeResult = (payload) => payload;
if (path === '/api/auth/register' && method === 'POST') {
throw new Error('Registration is disabled. Ask an admin for an invite.');
}
if (path === '/api/auth/login' && method === 'POST') {
const username = (body.username || '').trim();
const password = body.password || '';
const user = mockDb.users.find((u) => u.username === username);
if (!user || user.password !== password) throw new Error('Invalid credentials');
return makeResult({ token: `mock-token-${user.id}`, user: { id: user.id, username: user.username, email: user.email, full_name: user.full_name, role: user.role } });
}
if (path === '/api/reports/dashboard' && method === 'GET') {
const metrics = {
total_lps: mockDb.contacts.filter((c) => c.contact_type === 'investor').length,
total_prospects: mockDb.contacts.filter((c) => c.contact_type === 'prospect').length,
total_committed: mockDb.lp_profiles.reduce((s, lp) => s + (lp.commitment_amount || 0), 0),
pipeline_value: mockDb.opportunities.filter((o) => !['funded', 'lost'].includes(o.stage)).reduce((s, o) => s + (o.expected_amount || 0), 0),
active_opportunities: mockDb.opportunities.filter((o) => !['funded', 'lost'].includes(o.stage)).length,
comms_this_month: mockDb.communications.length
};
const stages = ['lead', 'outreach', 'meeting', 'due_diligence', 'committed', 'funded']
.map((stage) => {
const rows = mockDb.opportunities.filter((o) => o.stage === stage);
return { stage, count: rows.length, total_value: rows.reduce((s, r) => s + (r.expected_amount || 0), 0) };
});
const upcoming = mockDb.communications.filter((m) => m.next_action).map((m) => ({ id: m.id, description: m.next_action, due_date: m.next_action_date }));
return makeResult({ data: { metrics, pipeline_stages: stages, recent_communications: mockDb.communications, upcoming_actions: upcoming } });
}
if (path === '/api/contacts' && method === 'GET') {
let rows = [...mockDb.contacts];
const type = params.get('type');
const search = (params.get('search') || '').toLowerCase();
const sort = params.get('sort') || 'updated_at';
const order = params.get('order') === 'asc' ? 1 : -1;
if (type) rows = rows.filter((r) => r.contact_type === type);
if (search) {
rows = rows.filter((r) => `${r.first_name} ${r.last_name} ${r.email || ''} ${r.organization || ''}`.toLowerCase().includes(search));
}
if (sort === 'name') rows.sort((a, b) => (`${a.first_name} ${a.last_name}`).localeCompare(`${b.first_name} ${b.last_name}`) * order);
return makeResult({ data: clone(rows), total: rows.length, limit: rows.length, offset: 0 });
}
if (path === '/api/contacts' && method === 'POST') {
const item = {
id: `c-${Date.now()}`,
first_name: body.first_name,
last_name: body.last_name,
email: body.email || '',
phone: body.phone || '',
title: body.title || '',
organization: body.organization || '',
organization_name: body.organization || '',
contact_type: body.contact_type || 'prospect',
status: body.status || 'active',
communication_count: 0,
comm_count: 0,
last_contact_date: null
};
mockDb.contacts.unshift(item);
return makeResult({ data: clone(item) }, 201);
}
if (/^\/api\/contacts\/[^/]+$/.test(path) && method === 'GET') {
const id = path.split('/').pop();
const item = mockDb.contacts.find((c) => c.id === id);
if (!item) throw new Error('Contact not found');
const opportunities = mockDb.opportunities.filter((o) => o.contact_id === id);
const communications = mockDb.communications.filter((m) => m.contact_id === id);
const lp = mockDb.lp_profiles.find((lpRow) => lpRow.contact_id === id) || null;
return makeResult({ data: clone({ ...item, opportunities, communications, lp_profile: lp }) });
}
if (/^\/api\/contacts\/[^/]+$/.test(path) && method === 'DELETE') {
const id = path.split('/').pop();
mockDb.contacts = mockDb.contacts.filter((c) => c.id !== id);
mockDb.opportunities = mockDb.opportunities.filter((o) => o.contact_id !== id);
mockDb.communications = mockDb.communications.filter((m) => m.contact_id !== id);
mockDb.lp_profiles = mockDb.lp_profiles.filter((lp) => lp.contact_id !== id);
return makeResult({ message: 'Contact deleted' });
}
if (path === '/api/opportunities' && method === 'GET') {
return makeResult({ data: clone(mockDb.opportunities), total: mockDb.opportunities.length });
}
if (path === '/api/opportunities' && method === 'POST') {
const c = mockDb.contacts.find((x) => x.id === body.contact_id);
if (!c) throw new Error('Valid contact is required');
const item = {
id: `o-${Date.now()}`,
name: body.name || 'Untitled Opportunity',
contact_id: body.contact_id,
contact_name: contactName(c),
stage: body.stage || 'lead',
expected_amount: Number(body.expected_amount) || 0,
commitment_amount: Number(body.commitment_amount) || 0,
probability: Number(body.probability) || 10,
priority: body.priority || 'medium',
fund_name: body.fund_name || '',
organization_name: c.organization_name || c.organization || ''
};
mockDb.opportunities.unshift(item);
return makeResult({ data: clone(item) }, 201);
}
if (/^\/api\/opportunities\/[^/]+$/.test(path) && method === 'GET') {
const id = path.split('/').pop();
const item = mockDb.opportunities.find((o) => o.id === id);
if (!item) throw new Error('Opportunity not found');
const comms = mockDb.communications.filter((m) => m.contact_id === item.contact_id);
return makeResult({ data: clone({ ...item, communications: comms }) });
}
if (/^\/api\/opportunities\/[^/]+$/.test(path) && method === 'DELETE') {
const id = path.split('/').pop();
mockDb.opportunities = mockDb.opportunities.filter((o) => o.id !== id);
return makeResult({ message: 'Opportunity deleted' });
}
if (/^\/api\/opportunities\/[^/]+\/stage$/.test(path) && method === 'PATCH') {
const id = path.split('/')[3];
mockDb.opportunities = mockDb.opportunities.map((o) => (o.id === id ? { ...o, stage: body.stage || o.stage } : o));
const item = mockDb.opportunities.find((o) => o.id === id);
return makeResult({ data: clone(item) });
}
if (path === '/api/communications' && method === 'GET') {
const type = params.get('type');
const search = (params.get('search') || '').toLowerCase();
let rows = [...mockDb.communications];
if (type) rows = rows.filter((r) => r.type === type);
if (search) rows = rows.filter((r) => `${r.subject || ''} ${r.body || ''} ${r.contact_name || ''}`.toLowerCase().includes(search));
return makeResult({ data: clone(rows), total: rows.length });
}
if (path === '/api/communications' && method === 'POST') {
const c = mockDb.contacts.find((x) => x.id === body.contact_id);
if (!c) throw new Error('Valid contact is required');
const item = {
id: `m-${Date.now()}`,
contact_id: body.contact_id,
contact_name: contactName(c),
type: body.type || 'note',
subject: body.subject || '',
body: body.body || '',
communication_date: body.communication_date || new Date().toISOString(),
outcome: body.outcome || ''
};
mockDb.communications.unshift(item);
return makeResult({ data: clone(item) }, 201);
}
if (path === '/api/fundraising/log-communication' && method === 'POST') {
const investorName = body.investor_name || '';
const contactPayload = body.contact || {};
const createInvestorIfMissing = !!body.create_investor_if_missing;
const byEmail = (contactPayload.email || '').toLowerCase();
let c = null;
if (byEmail) c = mockDb.contacts.find((x) => String(x.email || '').toLowerCase() === byEmail) || null;
if (!c) {
const full = String(contactPayload.name || '').trim();
const parts = full.split(/\s+/).filter(Boolean);
c = {
id: `c-${Date.now()}`,
first_name: parts[0] || 'Unknown',
last_name: parts.slice(1).join(' '),
email: contactPayload.email || '',
title: contactPayload.title || '',
organization: investorName || '',
organization_name: investorName || '',
contact_type: 'investor',
status: 'active'
};
mockDb.contacts.unshift(c);
}
const item = {
id: `m-${Date.now()}`,
contact_id: c.id,
contact_name: contactName(c),
type: body.type || 'note',
subject: body.subject || '',
body: body.body || '',
communication_date: new Date().toISOString(),
outcome: body.outcome || ''
};
mockDb.communications.unshift(item);
const gridRows = (mockDb.fundraisingGrid && Array.isArray(mockDb.fundraisingGrid.rows)) ? mockDb.fundraisingGrid.rows : [];
let row = gridRows.find((r) => (body.row_id && r.id === body.row_id) || (investorName && r.investor_name === investorName));
if (!row && createInvestorIfMissing && investorName) {
row = {
id: `inv-${Date.now()}`,
investor_name: investorName,
contacts: [],
notes: '',
lead_source: '',
notes_last_modified: '',
last_communication_date: '',
lead: '',
priority: false,
follow_up: false,
graveyard: false
};
gridRows.unshift(row);
}
if (row) {
const d = new Date().toISOString().slice(0, 10);
row.last_communication_date = d;
if (body.append_note) {
const line = `${d} [${item.type}] ${contactPayload.name || ''}: ${item.subject || item.body || ''}`.trim();
row.notes = row.notes ? `${row.notes}\n${line}` : line;
row.notes_last_modified = d;
}
}
return makeResult({ data: { communication: clone(item), row: row ? clone(row) : null, version: loadMockFundraisingVersion() + 1 } }, 201);
}
if (/^\/api\/communications\/[^/]+$/.test(path) && method === 'DELETE') {
const id = path.split('/').pop();
mockDb.communications = mockDb.communications.filter((m) => m.id !== id);
return makeResult({ message: 'Communication deleted' });
}
if (path === '/api/lp-profiles' && method === 'GET') {
const search = (params.get('search') || '').toLowerCase();
let rows = [...mockDb.lp_profiles];
if (search) rows = rows.filter((r) => `${r.contact_name || ''} ${r.organization || ''}`.toLowerCase().includes(search));
return makeResult({ data: clone(rows), total: rows.length });
}
if (path === '/api/lp-profiles' && method === 'POST') {
const c = mockDb.contacts.find((x) => x.id === body.contact_id);
if (!c) throw new Error('Valid contact is required');
const item = {
id: `lp-${Date.now()}`,
contact_id: body.contact_id,
contact_name: contactName(c),
organization: c.organization_name || c.organization || '',
commitment_amount: Number(body.commitment_amount) || 0,
funded_amount: Number(body.funded_amount) || 0,
fund_name: body.fund_name || '',
legal_docs_signed: !!body.legal_docs_signed,
wire_received: !!body.wire_received,
k1_sent: !!body.k1_sent
};
mockDb.lp_profiles.unshift(item);
return makeResult({ data: clone(item) }, 201);
}
if (/^\/api\/lp-profiles\/[^/]+$/.test(path) && method === 'DELETE') {
const id = path.split('/').pop();
mockDb.lp_profiles = mockDb.lp_profiles.filter((lp) => lp.id !== id);
return makeResult({ message: 'LP deleted' });
}
if (path === '/api/import/csv' && method === 'POST') {
const rows = Array.isArray(body.data) ? body.data : [];
return makeResult({ data: { created: rows.length, updated: 0, skipped: 0, errors: [] }, dry_run: !!body.dry_run });
}
if (path === '/api/fundraising/state' && method === 'GET') {
const grid = loadMockFundraisingGrid();
const views = loadMockFundraisingViews();
const version = loadMockFundraisingVersion();
return makeResult({
data: {
grid: clone(grid),
views: clone(views),
version,
updated_at: new Date().toISOString()
}
});
}
if (path === '/api/fundraising/state' && method === 'PUT') {
const grid = body.grid;
const views = body.views;
const expectedVersion = Number(body.expected_version);
const currentVersion = loadMockFundraisingVersion();
if (Number.isFinite(expectedVersion) && expectedVersion !== currentVersion) {
const conflict = new Error('Version conflict');
conflict.status = 409;
conflict.payload = { error: 'Version conflict', current_version: currentVersion };
throw conflict;
}
if (!grid || !Array.isArray(grid.columns) || !Array.isArray(grid.rows)) {
throw new Error('grid must include columns and rows');
}
const nextViews = Array.isArray(views) ? views : loadMockFundraisingViews();
const nextVersion = currentVersion + 1;
persistMockFundraisingState(grid, nextViews, nextVersion);
return makeResult({ data: { version: nextVersion, updated_at: new Date().toISOString() } });
}
if (path === '/api/fundraising/collab/state' && method === 'GET') {
return makeResult({ data: { presence: [], locks: [] } });
}
if (path === '/api/fundraising/collab/heartbeat' && method === 'POST') {
return makeResult({ data: { presence: [], locks: [], lock_conflict: null } });
}
if (path === '/api/feature-requests' && method === 'GET') {
const status = params.get('status') || '';
const search = (params.get('search') || '').toLowerCase();
let rows = [...mockDb.feature_requests];
if (status) rows = rows.filter((r) => r.status === status);
if (search) {
rows = rows.filter((r) => `${r.title || ''} ${r.description || ''} ${r.requested_by || ''} ${r.category || ''}`.toLowerCase().includes(search));
}
rows.sort((a, b) => (a.created_at > b.created_at ? -1 : 1));
return makeResult({ data: clone(rows), total: rows.length });
}
if (path === '/api/feature-requests' && method === 'POST') {
if (!body.title || !body.title.trim()) throw new Error('Title is required');
const item = {
id: `fr-${Date.now()}`,
title: body.title.trim(),
description: (body.description || '').trim(),
category: body.category || 'other',
priority: body.priority || 'medium',
status: 'new',
requested_by: (body.requested_by || 'Unknown').trim(),
page: body.page || '',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
mockDb.feature_requests.unshift(item);
persistFeatureRequests();
return makeResult({ data: clone(item) }, 201);
}
if (/^\/api\/feature-requests\/[^/]+$/.test(path) && method === 'PATCH') {
const id = path.split('/').pop();
const allowed = ['status', 'priority', 'category', 'page'];
mockDb.feature_requests = mockDb.feature_requests.map((r) => {
if (r.id !== id) return r;
const next = { ...r, updated_at: new Date().toISOString() };
allowed.forEach((field) => {
if (Object.prototype.hasOwnProperty.call(body, field)) next[field] = body[field];
});
return next;
});
persistFeatureRequests();
const item = mockDb.feature_requests.find((r) => r.id === id);
if (!item) throw new Error('Feature request not found');
return makeResult({ data: clone(item) });
}
throw new Error(`Mock endpoint not implemented: ${method} ${path}`);
};
const AuthProvider = ({ children }) => {
// Lazy initializers: read once on mount so refreshes / new tabs
// start signed-in if a valid token is in localStorage. The server
// still verifies the JWT on every request, so a forged or expired
// token fails fast and the 'crm:unauthorized' handler below clears
// the stored auth back to a logged-out state.
const [token, setToken] = useState(() => (
MOCK_MODE ? 'mock-token-u-admin' : loadStoredAuthToken()
));
const [user, setUser] = useState(() => {
if (MOCK_MODE) {
return {
id: 'u-admin',
username: 'admin',
email: 'admin@fund.com',
full_name: 'Fund Admin',
role: 'admin'
};
}
return loadStoredAuthUser();
});
const [loading, setLoading] = useState(false);
const clearAuth = useCallback(() => {
setToken(null);
setUser(null);
if (!MOCK_MODE) persistAuth(null, null);
}, []);
// Auto-clear stored auth when any API call is rejected as 401.
// api() dispatches this event so we don't have to thread the
// auth context through every fetch caller.
useEffect(() => {
if (MOCK_MODE) return;
const handler = () => clearAuth();
window.addEventListener('crm:unauthorized', handler);
return () => window.removeEventListener('crm:unauthorized', handler);
}, [clearAuth]);
const login = useCallback(async (username, password) => {
setLoading(true);
try {
const data = await api('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
setToken(data.token);
setUser(data.user);
if (!MOCK_MODE) persistAuth(data.token, data.user);
return true;
} catch (err) {
throw new Error(getErrorMessage(err, 'Login failed'));
} finally {
setLoading(false);
}
}, []);
const setupFirstAdmin = useCallback(async ({ username, password, email, full_name }) => {
setLoading(true);
try {
const data = await api('/api/auth/register', {
method: 'POST',
body: JSON.stringify({ username, password, email, full_name })
});
setToken(data.token);
setUser(data.user);
if (!MOCK_MODE) persistAuth(data.token, data.user);
return true;
} catch (err) {
throw new Error(getErrorMessage(err, 'Setup failed'));
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(() => {
if (MOCK_MODE) return;
clearAuth();
}, [clearAuth]);
return (
<AuthContext.Provider value={{ token, user, loading, login, setupFirstAdmin, logout }}>
{children}
</AuthContext.Provider>
);
};
const useAuth = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth outside AuthProvider');
return ctx;
};
// ==================== Utilities ====================
const api = async (endpoint, options = {}, token) => {
if (MOCK_MODE) {
return mockApi(endpoint, options);
}
const headers = {
'Content-Type': 'application/json',
...options.headers
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const res = await fetch(endpoint, {
...options,
headers
});
if (!res.ok) {
let err = {};
try {
err = await res.json();
} catch (_) {
err = {};
}
// Stored token rejected (expired, server restarted with a new
// signing key, etc.) — tell AuthProvider to drop persisted auth
// and bounce the user to the login screen. Only fires if we
// actually sent a token; an unauthenticated 401 (e.g. login
// failure) is handled by the caller as a normal error.
if (res.status === 401 && token) {
try { window.dispatchEvent(new CustomEvent('crm:unauthorized')); }
catch (_) {}
}
const error = new Error(getErrorMessage(err, `API error: ${res.status}`));
error.status = res.status;
error.payload = err;
throw error;
}
return res.json();
};
const formatCurrency = (amount) => {
if (!amount) return '$0';
const num = Math.abs(amount);
if (num >= 1000000) {
return (amount / 1000000).toFixed(1) + 'M';
}
if (num >= 1000) {
return (amount / 1000).toFixed(1) + 'K';
}
return amount.toFixed(0);
};
const formatCurrencyLong = (amount) => {
if (!amount) return '$0';
return '$' + formatCurrency(amount);
};
const formatDate = (date) => {
if (!date) return '';
const d = new Date(date);
const now = new Date();
const diff = now - d;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'Today';
if (days === 1) return 'Yesterday';
if (days < 7) return days + ' days ago';
if (days < 30) return Math.floor(days / 7) + ' weeks ago';
return d.toLocaleDateString();
};
const formatDateLong = (date) => {
if (!date) return '';
const d = new Date(date);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const parseNumericInput = (value) => {
if (value === null || value === undefined) return 0;
const cleaned = String(value).replace(/[^0-9.-]/g, '');
const parsed = Number(cleaned);
return Number.isFinite(parsed) ? parsed : 0;
};
const normalizeKey = (value) => {
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
};
const subsequenceScore = (needle, haystack) => {
const q = normalizeKey(needle).replace(/\s+/g, '');
const t = normalizeKey(haystack).replace(/\s+/g, '');
if (!q || !t) return 0;
let qi = 0;
let first = -1;
let last = -1;
for (let ti = 0; ti < t.length && qi < q.length; ti += 1) {
if (q[qi] === t[ti]) {
if (first === -1) first = ti;
last = ti;
qi += 1;
}
}
if (qi !== q.length || first === -1 || last === -1) return 0;
const span = Math.max(1, last - first + 1);
return Math.min(1, (q.length / span) * 0.9 + (q.length / t.length) * 0.1);
};
const fuzzyScore = (query, text) => {
const q = normalizeKey(query);
const t = normalizeKey(text);
if (!q || !t) return 0;
if (t.includes(q)) return 1;
const qTokens = q.split(' ').filter(Boolean);
const tTokens = t.split(' ').filter(Boolean);
if (qTokens.length === 0 || tTokens.length === 0) return subsequenceScore(q, t);
let tokenScore = 0;
qTokens.forEach((token) => {
let best = 0;
tTokens.forEach((word) => {
if (word.startsWith(token)) {
best = Math.max(best, 0.92 * (token.length / Math.max(token.length, word.length)));
} else if (word.includes(token)) {
best = Math.max(best, 0.78 * (token.length / Math.max(token.length, word.length)));
} else {
best = Math.max(best, 0.55 * subsequenceScore(token, word));
}
});
tokenScore += best;
});
const avgTokenScore = tokenScore / qTokens.length;
const fullScore = subsequenceScore(q, t);
return Math.max(avgTokenScore, fullScore * 0.85);
};
const tokenizeFormula = (input) => {
const tokens = [];
let i = 0;
const text = String(input || '');
while (i < text.length) {
const ch = text[i];
if (/\s/.test(ch)) { i += 1; continue; }
if (ch === '{') {
const end = text.indexOf('}', i + 1);
if (end < 0) return null;
tokens.push({ type: 'field', value: text.slice(i + 1, end).trim() });
i = end + 1;
continue;
}
if (ch === '"' || ch === '\'') {
const quote = ch;
let j = i + 1;
let out = '';
while (j < text.length) {
const c = text[j];
if (c === '\\' && j + 1 < text.length) {
out += text[j + 1];
j += 2;
continue;
}
if (c === quote) break;
out += c;
j += 1;
}
if (j >= text.length || text[j] !== quote) return null;
tokens.push({ type: 'string', value: out });
i = j + 1;
continue;
}
const two = text.slice(i, i + 2);
if (['>=', '<=', '!=', '==', '&&', '||'].includes(two)) {
tokens.push({ type: 'op', value: two });
i += 2;
continue;
}
if ('+-*/(),><&'.includes(ch)) {
tokens.push({ type: ch === '&' ? 'op' : 'sym', value: ch === '&' ? '+' : ch });
i += 1;
continue;
}
if (/[0-9.]/.test(ch)) {
let j = i + 1;
while (j < text.length && /[0-9.]/.test(text[j])) j += 1;
tokens.push({ type: 'number', value: Number(text.slice(i, j)) });
i = j;
continue;
}
if (/[A-Za-z_]/.test(ch)) {
let j = i + 1;
while (j < text.length && /[A-Za-z0-9_]/.test(text[j])) j += 1;
tokens.push({ type: 'ident', value: text.slice(i, j).toUpperCase() });
i = j;
continue;
}
return null;
}
return tokens;
};
const evaluateFormulaSafe = (expression, getFieldValue) => {
const tokens = tokenizeFormula(expression);
if (!tokens) return '#ERR';
let idx = 0;
const peek = () => tokens[idx];
const take = () => tokens[idx++];
const match = (type, value = null) => {
const t = peek();
if (!t || t.type !== type) return false;
if (value !== null && t.value !== value) return false;
idx += 1;
return true;
};
const parsePrimary = () => {
const t = peek();
if (!t) return null;
if (t.type === 'number' || t.type === 'string') return take();
if (t.type === 'field') return take();
if (t.type === 'ident') {
const ident = take();
if (match('sym', '(')) {
const args = [];
if (!match('sym', ')')) {
while (true) {
const arg = parseExpression();
if (!arg) return null;
args.push(arg);
if (match('sym', ')')) break;
if (!match('sym', ',')) return null;
}
}
return { type: 'call', name: ident.value, args };
}
return ident;
}
if (match('sym', '(')) {
const inner = parseExpression();
if (!inner || !match('sym', ')')) return null;
return inner;
}
if (match('sym', '-')) {
const value = parsePrimary();
if (!value) return null;
return { type: 'neg', value };
}
return null;
};
const parseMul = () => {
let left = parsePrimary();
if (!left) return null;
while (true) {
if (match('sym', '*')) {
const right = parsePrimary();
if (!right) return null;
left = { type: 'bin', op: '*', left, right };
} else if (match('sym', '/')) {
const right = parsePrimary();
if (!right) return null;
left = { type: 'bin', op: '/', left, right };
} else break;
}
return left;
};
const parseAdd = () => {
let left = parseMul();
if (!left) return null;
while (true) {
if (match('sym', '+') || match('op', '+')) {
const right = parseMul();
if (!right) return null;
left = { type: 'bin', op: '+', left, right };
} else if (match('sym', '-')) {
const right = parseMul();
if (!right) return null;
left = { type: 'bin', op: '-', left, right };
} else break;
}
return left;
};
const parseCompare = () => {
let left = parseAdd();
if (!left) return null;
while (true) {
const op = peek();
if (!op || op.type !== 'op' || !['>', '<', '>=', '<=', '==', '!='].includes(op.value)) break;
take();
const right = parseAdd();
if (!right) return null;
left = { type: 'bin', op: op.value, left, right };
}
return left;
};
const parseLogicalAnd = () => {
let left = parseCompare();
if (!left) return null;
while (match('op', '&&')) {
const right = parseCompare();
if (!right) return null;
left = { type: 'bin', op: '&&', left, right };
}
return left;
};
const parseExpression = () => {
let left = parseLogicalAnd();
if (!left) return null;
while (match('op', '||')) {
const right = parseLogicalAnd();
if (!right) return null;
left = { type: 'bin', op: '||', left, right };
}
return left;
};
const ast = parseExpression();
if (!ast || idx !== tokens.length) return '#ERR';
const asNum = (v) => parseNumericInput(v);
const asBool = (v) => !!v;
const evalNode = (node) => {
if (!node) return null;
if (node.type === 'number') return node.value;
if (node.type === 'string') return node.value;
if (node.type === 'field') return getFieldValue(node.value);
if (node.type === 'ident') {
if (node.value === 'TRUE') return true;
if (node.value === 'FALSE') return false;
if (node.value === 'NULL') return null;
return node.value;
}
if (node.type === 'neg') return -asNum(evalNode(node.value));
if (node.type === 'bin') {
const a = evalNode(node.left);
const b = evalNode(node.right);
if (node.op === '+') return (typeof a === 'string' || typeof b === 'string') ? `${a ?? ''}${b ?? ''}` : asNum(a) + asNum(b);
if (node.op === '-') return asNum(a) - asNum(b);
if (node.op === '*') return asNum(a) * asNum(b);
if (node.op === '/') return asNum(b) === 0 ? 0 : asNum(a) / asNum(b);
if (node.op === '>') return asNum(a) > asNum(b);
if (node.op === '<') return asNum(a) < asNum(b);
if (node.op === '>=') return asNum(a) >= asNum(b);
if (node.op === '<=') return asNum(a) <= asNum(b);
if (node.op === '==') return String(a ?? '') === String(b ?? '');
if (node.op === '!=') return String(a ?? '') !== String(b ?? '');
if (node.op === '&&') return asBool(a) && asBool(b);
if (node.op === '||') return asBool(a) || asBool(b);
}
if (node.type === 'call') {
const name = node.name;
const args = node.args.map(evalNode);
if (name === 'IF') return args[0] ? (args[1] ?? '') : (args[2] ?? '');
if (name === 'AND') return args.every((v) => !!v);
if (name === 'OR') return args.some((v) => !!v);
if (name === 'NOT') return !args[0];
if (name === 'MIN') return Math.min(...args.map(asNum));
if (name === 'MAX') return Math.max(...args.map(asNum));
if (name === 'ABS') return Math.abs(asNum(args[0]));
if (name === 'ROUND') {
const places = Math.max(0, Math.min(10, Math.floor(asNum(args[1] ?? 0))));
return Number(asNum(args[0]).toFixed(places));
}
if (name === 'SUM') return args.reduce((sum, x) => sum + asNum(x), 0);
if (name === 'COALESCE') return args.find((v) => v !== null && v !== undefined && String(v) !== '') ?? '';
if (name === 'CONCAT') return args.map((v) => String(v ?? '')).join('');
return '#ERR';
}
return '#ERR';
};
try {
return evalNode(ast);
} catch (_) {
return '#ERR';
}
};
const parseBoolCell = (value) => {
const normalized = String(value || '').toLowerCase().trim();
return ['true', '1', 'yes', 'y', 'checked', '✓'].includes(normalized);
};
const parseCsvRecords = (csvText) => {
const text = String(csvText || '');
if (!text.trim()) return [];
const rows = [];
let row = [];
let cell = '';
let inQuotes = false;
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
const next = text[i + 1];
if (ch === '"') {
if (inQuotes && next === '"') {
cell += '"';
i += 1;
} else {
inQuotes = !inQuotes;
}
continue;
}
if (ch === ',' && !inQuotes) {
row.push(cell);
cell = '';
continue;
}
if ((ch === '\n' || ch === '\r') && !inQuotes) {
if (ch === '\r' && next === '\n') i += 1;
row.push(cell);
rows.push(row);
row = [];
cell = '';
continue;
}
cell += ch;
}
if (cell.length > 0 || row.length > 0) {
row.push(cell);
rows.push(row);
}
if (rows.length === 0) return [];
const headers = rows[0].map((h) => h.trim());
return rows.slice(1).filter((r) => r.some((c) => String(c || '').trim() !== '')).map((r) => {
const out = {};
headers.forEach((h, idx) => {
out[h] = (r[idx] || '').trim();
});
return out;
});
};
const GRID_VIEW_STORAGE_KEY = 'venture_crm_fundraising_views_v1';
const FOOTER_AGGREGATE_OPTIONS = [
{ value: 'none', label: 'None' },
{ value: 'sum', label: 'Sum' },
{ value: 'avg', label: 'Average' },
{ value: 'median', label: 'Median' },
{ value: 'min', label: 'Min' },
{ value: 'max', label: 'Max' },
{ value: 'range', label: 'Range' },
{ value: 'stddev', label: 'Std Dev' },
{ value: 'filled', label: 'Filled' },
{ value: 'empty', label: 'Empty' }
];
const FOOTER_AGGREGATE_ALLOWED = new Set(FOOTER_AGGREGATE_OPTIONS.map((o) => o.value));
const DEFAULT_GRID_VIEWS = [
{ id: 'view-main', name: 'Main Fundraising', filters: { includeGraveyard: false, graveyardOnly: false, followUpOnly: false, lead: '' }, quickSearch: '', hiddenColumns: [], columnFilters: [], footerAggs: {}, rowDensity: 'expanded', columnOrder: [], columnWidths: {} },
{ id: 'view-followup', name: 'Follow-up List', filters: { includeGraveyard: false, graveyardOnly: false, followUpOnly: true, lead: '' }, quickSearch: '', hiddenColumns: [], columnFilters: [], footerAggs: {}, rowDensity: 'expanded', columnOrder: [], columnWidths: {} },
{ id: 'view-graveyard', name: 'Graveyard', filters: { includeGraveyard: true, graveyardOnly: true, followUpOnly: false, lead: '' }, quickSearch: '', hiddenColumns: [], columnFilters: [], footerAggs: {}, rowDensity: 'expanded', columnOrder: [], columnWidths: {} },
{ id: 'view-all', name: 'All Investors', filters: { includeGraveyard: true, graveyardOnly: false, followUpOnly: false, lead: '' }, quickSearch: '', hiddenColumns: [], columnFilters: [], footerAggs: {}, rowDensity: 'expanded', columnOrder: [], columnWidths: {} }
];
const PROTECTED_VIEW_IDS = new Set(DEFAULT_GRID_VIEWS.map((v) => v.id));
const sanitizeGridViews = (views) => {
if (!Array.isArray(views)) return DEFAULT_GRID_VIEWS;
const next = views
.filter((v) => v && v.id !== 'view-longshot')
.map((v) => {
const n = { ...v };
if (n.filters && typeof n.filters === 'object') {
const f = { ...n.filters };
delete f.longshotOnly;
if (n.id === 'view-graveyard') {
f.graveyardOnly = true;
f.includeGraveyard = true;
} else {
f.graveyardOnly = !!f.graveyardOnly;
}
if (n.id === 'view-followup') {
f.followUpOnly = true;
} else {
f.followUpOnly = !!f.followUpOnly;
}
n.filters = f;
}
const footerAggs = (n.footerAggs && typeof n.footerAggs === 'object') ? { ...n.footerAggs } : {};
Object.keys(footerAggs).forEach((k) => {
if (!FOOTER_AGGREGATE_ALLOWED.has(footerAggs[k])) delete footerAggs[k];
});
n.footerAggs = footerAggs;
n.rowDensity = n.rowDensity === 'compact' ? 'compact' : 'expanded';
n.columnOrder = Array.isArray(n.columnOrder) ? n.columnOrder.map((id) => String(id || '').trim()).filter(Boolean) : [];
const columnWidths = (n.columnWidths && typeof n.columnWidths === 'object') ? { ...n.columnWidths } : {};
Object.keys(columnWidths).forEach((k) => {
const parsed = Number(columnWidths[k]);
if (!Number.isFinite(parsed) || parsed <= 0) delete columnWidths[k];
else columnWidths[k] = Math.max(90, Math.floor(parsed));
});
n.columnWidths = columnWidths;
return n;
});
return next.length > 0 ? next : DEFAULT_GRID_VIEWS;
};
const loadGridViews = () => {
try {
const raw = localStorage.getItem(GRID_VIEW_STORAGE_KEY);
if (!raw) return DEFAULT_GRID_VIEWS;
const parsed = JSON.parse(raw);
return sanitizeGridViews(parsed);
} catch (_) {
return DEFAULT_GRID_VIEWS;
}
};
// ==================== Components ====================
const Toast = ({ message, type = 'info', onClose }) => {
useEffect(() => {
const timer = setTimeout(onClose, 3000);
return () => clearTimeout(timer);
}, [onClose]);
return (
<div className={`toast ${type}`}>
{message}
</div>
);
};
const Spinner = () => <div className="spinner"></div>;
const SkeletonBlock = ({ lines = 5 }) => {
const widths = ['w-90', 'w-75', 'w-60', 'w-45', 'w-30'];
return (
<div className="skeleton-block">
{Array.from({ length: lines }).map((_, i) => (
<div key={i} className={`skeleton-line ${widths[i % widths.length]}`}></div>
))}
</div>
);
};
const ConfirmDialog = ({ title, message, onConfirm, onCancel }) => (
<div className="confirmation-dialog">
<div className="confirmation-box">
<div className="confirmation-title">{title}</div>
<div className="confirmation-message">{message}</div>
<div className="confirmation-actions">
<button className="button-secondary" onClick={onCancel}>Cancel</button>
<button className="button-danger" onClick={onConfirm}>Confirm</button>
</div>
</div>
</div>
);
const LoginPage = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [setupRequired, setSetupRequired] = useState(false);
const [setupChecking, setSetupChecking] = useState(true);
const [setupForm, setSetupForm] = useState({ username: '', full_name: '', email: '', password: '' });
const { login, setupFirstAdmin, loading } = useAuth();
useEffect(() => {
let cancelled = false;
const checkSetup = async () => {
try {
const result = await fetch('/api/bootstrap/status');
const data = await result.json();
if (!cancelled) {
setSetupRequired(Boolean(data?.data?.setup_required));
}
} catch (_) {
if (!cancelled) setSetupRequired(false);
} finally {
if (!cancelled) setSetupChecking(false);
}
};
checkSetup();
return () => { cancelled = true; };
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
await login(username, password);
} catch (err) {
setError(getErrorMessage(err, 'Login failed'));
}
};
const handleSetupSubmit = async (e) => {
e.preventDefault();
setError('');
try {
await setupFirstAdmin(setupForm);
} catch (err) {
setError(getErrorMessage(err, 'Setup failed'));
}
};
return (
<div className="login-container">
<div className="login-card">
<div className="login-logo-wrap">
<img className="login-logo-img" src="/assets/ten31-inverted-square.png" alt="Ten31" />
</div>
<div className="login-subtitle">
{setupRequired ? 'Create your first admin account' : 'Sign in to your account'}
</div>
{MOCK_MODE && (
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '12px' }}>
Prototype mode: use `admin / admin123` or `grant / password`
</div>
)}
{error && <div className="toast error" style={{ position: 'static', marginBottom: '16px' }}>{error}</div>}
{setupChecking ? (
<SkeletonBlock lines={3} />
) : setupRequired ? (
<form className="login-form" onSubmit={handleSetupSubmit}>
<div className="form-group">
<label className="form-label">Username</label>
<input type="text" className="text-input" value={setupForm.username} onChange={(e) => setSetupForm((f) => ({ ...f, username: e.target.value }))} required />
</div>
<div className="form-group">
<label className="form-label">Full Name</label>
<input type="text" className="text-input" value={setupForm.full_name} onChange={(e) => setSetupForm((f) => ({ ...f, full_name: e.target.value }))} required />
</div>
<div className="form-group">
<label className="form-label">Email</label>
<input type="email" className="text-input" value={setupForm.email} onChange={(e) => setSetupForm((f) => ({ ...f, email: e.target.value }))} required />
</div>
<div className="form-group">
<label className="form-label">Password (min 8)</label>
<input type="password" className="text-input" value={setupForm.password} onChange={(e) => setSetupForm((f) => ({ ...f, password: e.target.value }))} required />
</div>
<button type="submit" className="login-button" disabled={loading}>
{loading ? <Spinner /> : 'Create Admin'}
</button>
</form>
) : (
<form className="login-form" onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Username</label>
<input
type="text"
className="text-input"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div className="form-group">
<label className="form-label">Password</label>
<input
type="password"
className="text-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button type="submit" className="login-button" disabled={loading}>
{loading ? <Spinner /> : 'Sign In'}
</button>
</form>
)}
</div>
</div>
);
};
class AppErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, message: '' };
}
static getDerivedStateFromError(error) {
return { hasError: true, message: String(error?.message || error || 'Unknown error') };
}
componentDidCatch(error) {
console.error('App runtime error:', error);
}
render() {
if (this.state.hasError) {
return (
<div className="page-container">
<div className="toast error" style={{ position: 'static' }}>
Runtime error: {this.state.message}
</div>
</div>
);
}
return this.props.children;
}
}
const DashboardPage = ({ token }) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const fetchDashboard = async () => {
try {
const result = await api('/api/reports/dashboard', {}, token);
setData(result.data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchDashboard();
}, [token]);
if (loading) return <div style={{ padding: '20px' }}><SkeletonBlock lines={7} /></div>;
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
if (!data) return <div className="empty-state">No data</div>;
const metrics = data.metrics || {};
const stages = data.pipeline_stages || [];
const communications = data.recent_communications || [];
const actions = data.upcoming_actions || [];
return (
<div className="page-container">
<h2 className="section-title" style={{ marginBottom: '20px' }}>Dashboard</h2>
<div className="kpi-grid">
<div className="kpi-card">
<div className="kpi-label">Total LPs</div>
<div className="kpi-value">{metrics.total_lps || 0}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Total Committed</div>
<div className="kpi-value">{formatCurrencyLong(metrics.total_committed)}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Pipeline Value</div>
<div className="kpi-value">{formatCurrencyLong(metrics.pipeline_value)}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Active Opportunities</div>
<div className="kpi-value">{metrics.active_opportunities || 0}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Prospects</div>
<div className="kpi-value">{metrics.prospects ?? metrics.total_prospects ?? 0}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Comms This Month</div>
<div className="kpi-value">{metrics.communications_month ?? metrics.comms_this_month ?? 0}</div>
</div>
</div>
<div className="section">
<div className="section-title">Pipeline by Stage</div>
<div className="pipeline-summary">
{stages.map((stage) => (
<div key={stage.stage} className="pipeline-stage-card">
<div className="pipeline-stage-name">{stage.stage}</div>
<div className="pipeline-stage-count">{stage.count}</div>
<div className="pipeline-stage-amount">{formatCurrencyLong(stage.total_amount ?? stage.total_value ?? 0)}</div>
</div>
))}
</div>
</div>
<div className="section">
<div className="section-title">Recent Communications</div>
{communications.length === 0 ? (
<div className="empty-state">No recent communications</div>
) : (
<div className="timeline">
{communications.slice(0, 10).map((comm) => (
<div key={comm.id} className="timeline-item">
<div className="timeline-marker"></div>
<div className="timeline-content">
<div className="timeline-header">
{contactName(comm)} - {comm.type}
</div>
<div className="timeline-meta">{formatDate(comm.communication_date)}</div>
{comm.subject && <div className="timeline-body">{comm.subject}</div>}
</div>
</div>
))}
</div>
)}
</div>
<div className="section">
<div className="section-title">Upcoming Actions</div>
{actions.length === 0 ? (
<div className="empty-state">No upcoming actions</div>
) : (
<div className="timeline">
{actions.slice(0, 10).map((action) => (
<div key={action.id} className="timeline-item">
<div className="timeline-marker"></div>
<div className="timeline-content">
<div className="timeline-header">{action.description || action.next_action || '-'}</div>
<div className="timeline-meta">Due: {formatDate(action.due_date || action.next_action_date)}</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
const ContactsPage = ({ token, onShowToast }) => {
const CONTACTS_PAGE_SIZE = 100;
const [contacts, setContacts] = useState([]);
const [contactsTotal, setContactsTotal] = useState(0);
const [contactsPage, setContactsPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [tab, setTab] = useState('all');
const [search, setSearch] = useState('');
const [sort, setSort] = useState('last_name');
const [order, setOrder] = useState('asc');
const [selectedContact, setSelectedContact] = useState(null);
const [deleting, setDeleting] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const tabFilter = tab === 'all' ? '' : tab === 'investors' ? 'investor' : 'prospect';
const contactsOffset = (contactsPage - 1) * CONTACTS_PAGE_SIZE;
const contactsMaxPage = Math.max(1, Math.ceil((Number(contactsTotal) || 0) / CONTACTS_PAGE_SIZE));
useEffect(() => {
setContactsPage(1);
}, [tab, search, sort, order]);
useEffect(() => {
const fetchContacts = async () => {
try {
setLoading(true);
const result = await api(`/api/contacts?search=${encodeURIComponent(search)}&type=${tabFilter}&sort=${sort}&order=${order}&limit=${CONTACTS_PAGE_SIZE}&offset=${contactsOffset}`, {}, token);
setContacts(result.data || []);
setContactsTotal(Number(result.total) || 0);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchContacts();
}, [token, tab, search, sort, order, contactsOffset]);
const handleDeleteContact = async (id) => {
setDeleting(id);
try {
await api(`/api/contacts/${id}`, { method: 'DELETE' }, token);
setContacts(contacts.filter(c => c.id !== id));
setConfirmDelete(null);
setSelectedContact(null);
onShowToast('Contact deleted', 'success');
} catch (err) {
onShowToast(err.message, 'error');
} finally {
setDeleting(null);
}
};
const contactTypeTooltip = (type) => {
const badges = {
'investor': 'badge-investor',
'prospect': 'badge-prospect',
'advisor': 'badge-advisor',
'other': 'badge-other'
};
return badges[type] || 'badge-other';
};
const handleSort = (column) => {
if (sort === column) {
setOrder(order === 'asc' ? 'desc' : 'asc');
} else {
setSort(column);
setOrder('asc');
}
};
return (
<div className="page-container">
<h2 className="section-title">Contacts</h2>
<div className="section">
<div className="tabs">
{['all', 'investors', 'prospects'].map(t => (
<button
key={t}
className={`tab ${tab === t ? 'active' : ''}`}
onClick={() => setTab(t)}
>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
<div className="controls">
<input
type="text"
className="search-input"
placeholder="Search contacts..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<span style={{ fontSize: '12px', color: '#8a93a6', alignSelf: 'center' }}>New people are added from the Fundraising Grid; click anyone here to view or edit.</span>
</div>
{loading ? (
<SkeletonBlock lines={8} />
) : contacts.length === 0 ? (
<div className="empty-state">No contacts found</div>
) : (
<table className="table">
<thead>
<tr>
<th onClick={() => handleSort('last_name')} style={{ cursor: 'pointer' }}>
Name {sort === 'last_name' && (order === 'asc' ? '▲' : '▼')}
</th>
<th>Organization</th>
<th onClick={() => handleSort('source')} style={{ cursor: 'pointer' }}>
Lead Source {sort === 'source' && (order === 'asc' ? '▲' : '▼')}
</th>
<th>Type</th>
<th>Email</th>
<th>Last Contact</th>
<th>Communications</th>
</tr>
</thead>
<tbody>
{contacts.map(contact => (
<tr key={contact.id} onClick={() => setSelectedContact(contact)}>
<td>{contact.first_name} {contact.last_name}</td>
<td>{contact.organization || contact.organization_name || '-'}</td>
<td>{contact.source || '-'}</td>
<td>
<span className={`badge ${contactTypeTooltip(contact.contact_type)}`}>
{contact.contact_type}
</span>
</td>
<td>{contact.email || '-'}</td>
<td>{formatDate(contact.last_contact_date)}</td>
<td>{contact.communication_count ?? contact.comm_count ?? 0}</td>
</tr>
))}
</tbody>
</table>
)}
{!loading && contactsTotal > CONTACTS_PAGE_SIZE && (
<div style={{ marginTop: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '10px', flexWrap: 'wrap' }}>
<div style={{ fontSize: '12px', color: '#8ea2b7' }}>
Showing {contacts.length === 0 ? 0 : contactsOffset + 1}-{Math.min(contactsOffset + contacts.length, contactsTotal)} of {contactsTotal}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
type="button"
className="button-secondary"
onClick={() => setContactsPage((p) => Math.max(1, p - 1))}
disabled={contactsPage <= 1}
>
Prev
</button>
<span style={{ fontSize: '12px', color: '#c7d3e0' }}>Page {contactsPage} / {contactsMaxPage}</span>
<button
type="button"
className="button-secondary"
onClick={() => setContactsPage((p) => Math.min(contactsMaxPage, p + 1))}
disabled={contactsPage >= contactsMaxPage}
>
Next
</button>
</div>
</div>
)}
</div>
{selectedContact && (
<ContactDetailPanel
contact={selectedContact}
onClose={() => setSelectedContact(null)}
onDelete={() => setConfirmDelete(selectedContact.id)}
token={token}
onRefresh={() => {
const result = api(`/api/contacts?search=${encodeURIComponent(search)}&type=${tabFilter}&sort=${sort}&order=${order}&limit=${CONTACTS_PAGE_SIZE}&offset=${contactsOffset}`, {}, token);
result.then(r => {
setContacts(r.data || []);
setContactsTotal(Number(r.total) || 0);
});
}}
onShowToast={onShowToast}
/>
)}
{confirmDelete && (
<ConfirmDialog
title="Delete Contact"
message="Are you sure you want to delete this contact? This action cannot be undone."
onConfirm={() => handleDeleteContact(confirmDelete)}
onCancel={() => setConfirmDelete(null)}
/>
)}
</div>
);
};
const ContactDetailPanel = ({ contact, onClose, onDelete, token, onShowToast, onRefresh }) => {
const [details, setDetails] = useState(null);
const [loading, setLoading] = useState(true);
const [editMode, setEditMode] = useState(false);
const [saving, setSaving] = useState(false);
const [editDraft, setEditDraft] = useState({
first_name: '',
last_name: '',
email: '',
phone: '',
title: '',
organization: '',
source: '',
linkedin_url: '',
city: '',
state: '',
country: '',
contact_type: 'prospect',
status: 'active'
});
useEffect(() => {
const fetchDetails = async () => {
try {
const result = await api(`/api/contacts/${contact.id}`, {}, token);
setDetails(result.data);
} catch (err) {
onShowToast(err.message, 'error');
} finally {
setLoading(false);
}
};
fetchDetails();
}, [contact.id, token, onShowToast]);
useEffect(() => {
if (!details) return;
setEditDraft({
first_name: details.first_name || '',
last_name: details.last_name || '',
email: details.email || '',
phone: details.phone || '',
title: details.title || '',
organization: details.organization || details.organization_name || '',
source: details.source || '',
linkedin_url: details.linkedin_url || '',
city: details.city || '',
state: details.state || '',
country: details.country || '',
contact_type: details.contact_type || 'prospect',
status: details.status || 'active'
});
}, [details]);
const handleSaveEdit = async () => {
if (!String(editDraft.first_name || '').trim() || !String(editDraft.last_name || '').trim()) {
onShowToast('First and last name are required', 'error');
return;
}
setSaving(true);
try {
const result = await api(`/api/contacts/${contact.id}`, {
method: 'PUT',
body: JSON.stringify({
first_name: editDraft.first_name.trim(),
last_name: editDraft.last_name.trim(),
email: editDraft.email.trim(),
phone: editDraft.phone.trim(),
title: editDraft.title.trim(),
organization: editDraft.organization.trim(),
source: editDraft.source.trim(),
linkedin_url: editDraft.linkedin_url.trim(),
city: editDraft.city.trim(),
state: editDraft.state.trim(),
country: editDraft.country.trim(),
contact_type: editDraft.contact_type,
status: editDraft.status
})
}, token);
setDetails(result?.data || details);
setEditMode(false);
if (onRefresh) onRefresh();
onShowToast('Contact updated', 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to update contact'), 'error');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="slide-over">
<div style={{ padding: '20px' }}><SkeletonBlock lines={6} /></div>
</div>
);
}
if (!details) return null;
return (
<div className="slide-over">
<div className="slide-over-header">
<div className="slide-over-title">{details.first_name} {details.last_name}</div>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<div className="detail-section">
<div className="detail-section-title">Information</div>
{editMode ? (
<>
<div className="form-group">
<label className="form-label">First Name *</label>
<input className="text-input" value={editDraft.first_name} onChange={(e) => setEditDraft((d) => ({ ...d, first_name: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">Last Name *</label>
<input className="text-input" value={editDraft.last_name} onChange={(e) => setEditDraft((d) => ({ ...d, last_name: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">Email</label>
<input type="email" className="text-input" value={editDraft.email} onChange={(e) => setEditDraft((d) => ({ ...d, email: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">Phone</label>
<input className="text-input" value={editDraft.phone} onChange={(e) => setEditDraft((d) => ({ ...d, phone: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">Title</label>
<input className="text-input" value={editDraft.title} onChange={(e) => setEditDraft((d) => ({ ...d, title: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">Organization</label>
<input className="text-input" value={editDraft.organization} onChange={(e) => setEditDraft((d) => ({ ...d, organization: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">Lead Source</label>
<input className="text-input" value={editDraft.source} onChange={(e) => setEditDraft((d) => ({ ...d, source: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">LinkedIn URL</label>
<input className="text-input" value={editDraft.linkedin_url} onChange={(e) => setEditDraft((d) => ({ ...d, linkedin_url: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">City</label>
<input className="text-input" value={editDraft.city} onChange={(e) => setEditDraft((d) => ({ ...d, city: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">State</label>
<input className="text-input" value={editDraft.state} onChange={(e) => setEditDraft((d) => ({ ...d, state: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">Country</label>
<input className="text-input" value={editDraft.country} onChange={(e) => setEditDraft((d) => ({ ...d, country: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">Type</label>
<select className="select-input" value={editDraft.contact_type} onChange={(e) => setEditDraft((d) => ({ ...d, contact_type: e.target.value }))}>
<option value="investor">Investor</option>
<option value="prospect">Prospect</option>
<option value="advisor">Advisor</option>
<option value="other">Other</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Status</label>
<select className="select-input" value={editDraft.status} onChange={(e) => setEditDraft((d) => ({ ...d, status: e.target.value }))}>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="prospect">Prospect</option>
</select>
</div>
</>
) : (
<>
<div className="detail-row">
<span className="detail-label">Email</span>
<span className="detail-value">{details.email || '-'}</span>
</div>
<div className="detail-row">
<span className="detail-label">Phone</span>
<span className="detail-value">{details.phone || '-'}</span>
</div>
<div className="detail-row">
<span className="detail-label">Title</span>
<span className="detail-value">{details.title || '-'}</span>
</div>
<div className="detail-row">
<span className="detail-label">Organization</span>
<span className="detail-value">{details.organization || details.organization_name || '-'}</span>
</div>
<div className="detail-row">
<span className="detail-label">Lead Source</span>
<span className="detail-value">{details.source || '-'}</span>
</div>
<div className="detail-row">
<span className="detail-label">LinkedIn</span>
<span className="detail-value">{details.linkedin_url || '-'}</span>
</div>
<div className="detail-row">
<span className="detail-label">Location</span>
<span className="detail-value">
{[details.city, details.state, details.country].filter(Boolean).join(', ') || details.location_query || '-'}
</span>
</div>
<div className="detail-row">
<span className="detail-label">Type</span>
<span className="detail-value">
<span className={`badge ${details.contact_type === 'investor' ? 'badge-investor' : 'badge-prospect'}`}>
{details.contact_type}
</span>
</span>
</div>
</>
)}
</div>
{details.opportunities && details.opportunities.length > 0 && (
<div className="detail-section">
<div className="detail-section-title">Opportunities</div>
{details.opportunities.map(opp => (
<div key={opp.id} style={{ marginBottom: '12px', paddingBottom: '12px', borderBottom: '1px solid #263548' }}>
<div style={{ color: '#e5edf5', fontWeight: 500 }}>{opp.name}</div>
<div style={{ fontSize: '12px', color: '#8ea2b7' }}>
{opp.stage} · {formatCurrencyLong(opp.expected_amount)}
</div>
</div>
))}
</div>
)}
{details.communications && details.communications.length > 0 && (
<div className="detail-section">
<div className="detail-section-title">Communications</div>
<div className="timeline">
{details.communications.map(comm => (
<div key={comm.id} className="timeline-item">
<div className="timeline-marker"></div>
<div className="timeline-content">
<div className="timeline-header">{comm.type}</div>
<div className="timeline-meta">{formatDate(comm.communication_date)}</div>
{comm.subject && <div className="timeline-body">{comm.subject}</div>}
</div>
</div>
))}
</div>
</div>
)}
<div style={{ marginTop: '20px', paddingTop: '20px', borderTop: '1px solid #263548' }}>
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
{editMode ? (
<>
<button className="button-secondary" style={{ flex: 1 }} onClick={() => setEditMode(false)} disabled={saving}>Cancel</button>
<button style={{ flex: 1 }} onClick={handleSaveEdit} disabled={saving}>
{saving ? <Spinner /> : 'Save Contact'}
</button>
</>
) : (
<button className="button-secondary" style={{ width: '100%' }} onClick={() => setEditMode(true)}>Edit Contact</button>
)}
</div>
<button className="button-danger" style={{ width: '100%' }} onClick={onDelete} disabled={saving}>Delete Contact</button>
</div>
</div>
);
};
const PipelinePage = ({ token, onShowToast }) => {
const [opportunities, setOpportunities] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedOpp, setSelectedOpp] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const stages = ['lead', 'outreach', 'meeting', 'due_diligence', 'committed', 'funded'];
useEffect(() => {
const fetchOpportunities = async () => {
try {
setLoading(true);
const oppResult = await api('/api/opportunities?limit=1000', {}, token);
setOpportunities(oppResult.data || []);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to load pipeline'), 'error');
} finally {
setLoading(false);
}
};
fetchOpportunities();
}, [token, onShowToast]);
const handleDeleteOpp = async (id) => {
try {
await api(`/api/opportunities/${id}`, { method: 'DELETE' }, token);
setOpportunities(opportunities.filter(o => o.id !== id));
setConfirmDelete(null);
setSelectedOpp(null);
onShowToast('Opportunity deleted', 'success');
} catch (err) {
onShowToast(err.message, 'error');
}
};
const handleChangeStage = async (oppId, newStage) => {
try {
await api(`/api/opportunities/${oppId}/stage`, {
method: 'PATCH',
body: JSON.stringify({ stage: newStage })
}, token);
setOpportunities(opportunities.map(o =>
o.id === oppId ? { ...o, stage: newStage } : o
));
onShowToast('Stage updated', 'success');
} catch (err) {
onShowToast(err.message, 'error');
}
};
const getPriorityBadge = (priority) => {
const badges = {
'high': 'badge-high',
'medium': 'badge-medium',
'low': 'badge-low'
};
return badges[priority] || 'badge-medium';
};
const opportunitiesByStage = useMemo(() => {
const result = {};
stages.forEach(s => result[s] = []);
opportunities.forEach(o => {
if (result[o.stage]) result[o.stage].push(o);
});
return result;
}, [opportunities]);
const stageTotals = useMemo(() => {
const totals = {};
stages.forEach(s => {
totals[s] = opportunitiesByStage[s]?.reduce((sum, o) => sum + (o.expected_amount || 0), 0) || 0;
});
return totals;
}, [opportunitiesByStage]);
return (
<div className="page-container">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h2 className="section-title">Pipeline</h2>
<span style={{ fontSize: '12px', color: '#8ea2b7' }}>Add deals from the Fundraising Grid "+ Pipeline" on an investor row</span>
</div>
{loading ? (
<div style={{ padding: '20px' }}><SkeletonBlock lines={7} /></div>
) : (
<>
<div className="pipeline-summary">
{stages.map(stage => (
<div key={stage} className="pipeline-stage-card">
<div className="pipeline-stage-name">{stage.replace('_', ' ')}</div>
<div className="pipeline-stage-count">{opportunitiesByStage[stage].length}</div>
<div className="pipeline-stage-amount">{formatCurrencyLong(stageTotals[stage])}</div>
</div>
))}
</div>
<div className="kanban-board">
{stages.map(stage => (
<div key={stage} className="kanban-column">
<div className="kanban-header">{stage.replace(/_/g, ' ')}</div>
{opportunitiesByStage[stage].map(opp => (
<div
key={opp.id}
className="kanban-card"
onClick={() => setSelectedOpp(opp)}
>
<div className="kanban-card-title">{opp.name}</div>
<div className="kanban-card-subtitle">{contactName(opp)}</div>
<div className="kanban-card-amount">{formatCurrencyLong(opp.expected_amount)}</div>
<span className={`badge ${getPriorityBadge(opp.priority)}`}>
{opp.priority}
</span>
</div>
))}
</div>
))}
</div>
</>
)}
{selectedOpp && (
<OpportunityDetailPanel
opp={selectedOpp}
onClose={() => setSelectedOpp(null)}
onStageChange={handleChangeStage}
onDelete={() => setConfirmDelete(selectedOpp.id)}
token={token}
stages={stages}
onShowToast={onShowToast}
/>
)}
{confirmDelete && (
<ConfirmDialog
title="Delete Opportunity"
message="Are you sure? This cannot be undone."
onConfirm={() => handleDeleteOpp(confirmDelete)}
onCancel={() => setConfirmDelete(null)}
/>
)}
</div>
);
};
const OpportunityDetailPanel = ({ opp, onClose, onStageChange, onDelete, token, stages, onShowToast }) => {
const [details, setDetails] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchDetails = async () => {
try {
const result = await api(`/api/opportunities/${opp.id}`, {}, token);
setDetails(result.data);
} catch (err) {
onShowToast(err.message, 'error');
} finally {
setLoading(false);
}
};
fetchDetails();
}, [opp.id, token, onShowToast]);
if (loading) {
return (
<div className="slide-over">
<div style={{ padding: '20px' }}><SkeletonBlock lines={6} /></div>
</div>
);
}
if (!details) return null;
return (
<div className="slide-over">
<div className="slide-over-header">
<div className="slide-over-title">{details.name}</div>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<div className="detail-section">
<div className="detail-section-title">Details</div>
<div className="detail-row">
<span className="detail-label">Contact</span>
<span className="detail-value">{contactName(details)}</span>
</div>
<div className="detail-row">
<span className="detail-label">Organization</span>
<span className="detail-value">{details.organization_name || '-'}</span>
</div>
<div className="detail-row">
<span className="detail-label">Stage</span>
<span className="detail-value">
<select
className="select-input"
value={details.stage}
onChange={(e) => {
onStageChange(details.id, e.target.value);
setDetails({ ...details, stage: e.target.value });
}}
style={{ width: '100%' }}
>
{stages.map(s => (
<option key={s} value={s}>{s.replace(/_/g, ' ')}</option>
))}
</select>
</span>
</div>
<div className="detail-row">
<span className="detail-label">Expected Amount</span>
<span className="detail-value">{formatCurrencyLong(details.expected_amount)}</span>
</div>
<div className="detail-row">
<span className="detail-label">Probability</span>
<span className="detail-value">{(details.probability || 0) > 1 ? `${details.probability}%` : `${Math.round((details.probability || 0) * 100)}%`}</span>
</div>
<div className="detail-row">
<span className="detail-label">Priority</span>
<span className="detail-value">
<span className={`badge ${details.priority === 'high' ? 'badge-high' : details.priority === 'low' ? 'badge-low' : 'badge-medium'}`}>
{details.priority}
</span>
</span>
</div>
<div className="detail-row">
<span className="detail-label">Fund</span>
<span className="detail-value">{details.fund_name || '-'}</span>
</div>
<div className="detail-row">
<span className="detail-label">Expected Close</span>
<span className="detail-value">{formatDateLong(details.expected_close_date) || '-'}</span>
</div>
</div>
{details.communications && details.communications.length > 0 && (
<div className="detail-section">
<div className="detail-section-title">Communications</div>
<div className="timeline">
{details.communications.map(comm => (
<div key={comm.id} className="timeline-item">
<div className="timeline-marker"></div>
<div className="timeline-content">
<div className="timeline-header">{comm.type}</div>
<div className="timeline-meta">{formatDate(comm.communication_date)}</div>
{comm.subject && <div className="timeline-body">{comm.subject}</div>}
</div>
</div>
))}
</div>
</div>
)}
<div style={{ marginTop: '20px', paddingTop: '20px', borderTop: '1px solid #263548' }}>
<button className="button-danger" style={{ width: '100%' }} onClick={onDelete}>Delete Opportunity</button>
</div>
</div>
);
};
const CommunicationsPage = ({ token, user, onShowToast }) => {
// Repurposed (v0.1.0:80): the Communications tab is now the admin-only
// email-activity panel over the captured email_* tables. The classic
// manual "Log Communication" surface was retired (grid is canonical).
const isAdmin = user?.role === 'admin';
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [disabled, setDisabled] = useState(false);
const [error, setError] = useState('');
const [investorId, setInvestorId] = useState('');
const [accountId, setAccountId] = useState('');
const [direction, setDirection] = useState('');
const [since, setSince] = useState('');
const [until, setUntil] = useState('');
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [expandedId, setExpandedId] = useState(null);
const [detailCache, setDetailCache] = useState({}); // id -> {loading, data, error}
const [mode, setMode] = useState('filter'); // 'filter' | 'search' (semantic content)
const [contentQuery, setContentQuery] = useState('');
const [searchResults, setSearchResults] = useState(null); // {results, count} | null
const [searchLoading, setSearchLoading] = useState(false);
const [searchError, setSearchError] = useState('');
// Debounce the free-text box so each keystroke doesn't hit the server.
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300);
return () => clearTimeout(t);
}, [search]);
useEffect(() => {
if (!isAdmin) { setLoading(false); return undefined; }
let cancelled = false;
(async () => {
try {
setLoading(true);
const params = new URLSearchParams();
if (investorId) params.set('investor_id', investorId);
if (accountId) params.set('account_id', accountId);
if (direction) params.set('direction', direction);
if (since) params.set('since', since + 'T00:00:00');
// `until` is exclusive in the query; send the day AFTER the picked
// date at midnight so the whole "to" day is included regardless of
// the stored timestamp's precision/zone suffix.
if (until) {
const u = new Date(until + 'T00:00:00');
u.setDate(u.getDate() + 1);
const nd = `${u.getFullYear()}-${String(u.getMonth() + 1).padStart(2, '0')}-${String(u.getDate()).padStart(2, '0')}`;
params.set('until', nd + 'T00:00:00');
}
if (debouncedSearch) params.set('q', debouncedSearch);
params.set('limit', '200');
const res = await api(`/api/email/activity?${params.toString()}`, {}, token);
if (cancelled) return;
setData(res);
setDisabled(false);
setError('');
} catch (err) {
if (cancelled) return;
// Integration off on the server -> show the disabled state, not an error.
if (err?.status === 503 || /disabl/i.test(err?.payload?.error || '')) {
setDisabled(true);
setData(null);
} else {
setError(getErrorMessage(err, 'Failed to load email activity'));
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [token, isAdmin, investorId, accountId, direction, since, until, debouncedSearch]);
// Lazily fetch and cache the full body when a row is expanded.
const toggleExpand = async (id) => {
if (expandedId === id) { setExpandedId(null); return; }
setExpandedId(id);
if (detailCache[id]) return;
setDetailCache((c) => ({ ...c, [id]: { loading: true } }));
try {
const res = await api(`/api/email/detail?id=${encodeURIComponent(id)}`, {}, token);
setDetailCache((c) => ({ ...c, [id]: { loading: false, data: res } }));
} catch (err) {
setDetailCache((c) => ({ ...c, [id]: { loading: false, error: getErrorMessage(err, 'Failed to load email') } }));
}
};
// Shared expanded-body panel (used by both the filter list and search results).
const renderExpandedBody = (id) => {
const det = detailCache[id];
return (
<div style={{ marginTop: '8px', border: '1px solid #263548', borderRadius: '6px', background: '#0d1622', padding: '10px' }}>
{det?.loading ? <SkeletonBlock lines={4} />
: det?.error ? <div className="toast error" style={{ position: 'static' }}>{det.error}</div>
: det?.data ? (() => {
const d = det.data;
const rcpt = (kind) => (d.recipients || []).filter((r) => r.kind === kind)
.map((r) => r.display_name ? `${r.display_name} <${r.address}>` : r.address).join(', ');
const to = rcpt('to'), cc = rcpt('cc');
return (
<>
{to && <div style={{ fontSize: '11px', color: '#8ea2b7' }}><b>To:</b> {to}</div>}
{cc && <div style={{ fontSize: '11px', color: '#8ea2b7' }}><b>Cc:</b> {cc}</div>}
{(d.attachments || []).length > 0 && (
<div style={{ fontSize: '11px', color: '#8ea2b7', marginTop: '2px' }}>
<b>Attachments:</b> {d.attachments.map((a) => a.filename).join(', ')}
</div>
)}
<pre style={{ margin: '8px 0 0', whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: '12px', lineHeight: 1.5, color: '#cdd9e5', fontFamily: 'inherit' }}>
{d.body_text || (d.has_html ? '(HTML-only email — open in Gmail to view formatting)' : '(no body captured)')}
</pre>
</>
);
})() : null}
</div>
);
};
// Semantic search over email *content* (bodies) — heavier (Spark + Qdrant),
// so it runs on submit, not per keystroke.
const runContentSearch = async () => {
const query = contentQuery.trim();
if (!query) { setSearchResults(null); setSearchError(''); return; }
setSearchLoading(true); setSearchError('');
try {
const res = await api(`/api/email/search?q=${encodeURIComponent(query)}`, {}, token);
setSearchResults(res);
} catch (err) {
if (err?.status === 503) {
setSearchError('Content search is unavailable right now (Spark/Qdrant not reachable).');
} else {
setSearchError(getErrorMessage(err, 'Search failed'));
}
setSearchResults(null);
} finally {
setSearchLoading(false);
}
};
if (!isAdmin) {
return (
<div className="page-container">
<h2 className="section-title" style={{ marginBottom: '20px' }}>Communications</h2>
<div className="empty-state">Communications is an admin-only view (captured email activity).</div>
</div>
);
}
const emails = data?.emails || [];
const accounts = data?.accounts || [];
const investors = data?.investors || [];
const hasFilter = !!(investorId || accountId || direction || since || until || debouncedSearch);
return (
<div className="page-container">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h2 className="section-title">Communications</h2>
<span className="form-help">Captured email activity. Logging &amp; drafts live in the Fundraising Grid and Outreach.</span>
</div>
<div className="section">
<div style={{ display: 'flex', gap: '8px', marginBottom: '14px' }}>
<button type="button" onClick={() => setMode('filter')}
style={mode === 'filter' ? {} : { background: 'transparent', color: '#8ea2b7', border: '1px solid #2a3a4d', boxShadow: 'none' }}>
Filter
</button>
<button type="button" onClick={() => setMode('search')}
style={mode === 'search' ? {} : { background: 'transparent', color: '#8ea2b7', border: '1px solid #2a3a4d', boxShadow: 'none' }}>
Search content
</button>
<span className="form-help" style={{ alignSelf: 'center' }}>
{mode === 'filter' ? 'Structured filters over captured email metadata.' : 'Semantic search over email bodies (finds by meaning, not just keywords).'}
</span>
</div>
{mode === 'filter' && (<>
<div className="controls">
<input
type="text"
className="search-input"
placeholder="Search subject, sender, snippet..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select className="select-input" value={investorId} onChange={(e) => setInvestorId(e.target.value)}>
<option value="">All investors</option>
{investors.map((iv) => <option key={iv.id} value={iv.id}>{iv.name}</option>)}
</select>
<select className="select-input" value={accountId} onChange={(e) => setAccountId(e.target.value)}>
<option value="">All mailboxes</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.email_address}</option>)}
</select>
<select className="select-input" value={direction} onChange={(e) => setDirection(e.target.value)}>
<option value="">In &amp; out</option>
<option value="inbound">Received</option>
<option value="outbound">Sent</option>
</select>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: '#8ea2b7' }}>
from <input type="date" value={since} max={until || undefined} onChange={(e) => setSince(e.target.value)} />
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: '#8ea2b7' }}>
to <input type="date" value={until} min={since || undefined} onChange={(e) => setUntil(e.target.value)} />
</label>
{(since || until) && (
<button type="button" onClick={() => { setSince(''); setUntil(''); }}
style={{ background: 'transparent', color: '#8ea2b7', border: '1px solid #2a3a4d', boxShadow: 'none', padding: '6px 10px' }}>
Clear dates
</button>
)}
</div>
{loading ? (
<SkeletonBlock lines={8} />
) : error ? (
<div className="toast error" style={{ position: 'static' }}>{error}</div>
) : disabled ? (
<span className="index-job-pill idle"><span className="index-job-dot" /> Email integration is disabled no Gmail service-account key on the server.</span>
) : emails.length === 0 ? (
<div className="empty-state">{hasFilter ? 'No email activity matches these filters.' : 'No captured email yet. Enroll mailboxes in Email Capture.'}</div>
) : (
<>
<div className="form-help" style={{ marginBottom: '10px' }}>
Showing {emails.length}{data?.truncated ? '+ (refine filters to see more)' : ''} email{emails.length === 1 ? '' : 's'}.
</div>
<div className="timeline">
{emails.map((em) => {
const sent = em.direction === 'outbound';
const who = em.from_name ? `${em.from_name} <${em.from_email}>` : (em.from_email || 'Unknown sender');
const tags = (em.investors || []).map((iv) => iv.name);
const open = expandedId === em.id;
return (
<div key={em.id} className="timeline-item">
<div className="timeline-marker"></div>
<div className="timeline-content">
<div className="timeline-header" style={{ cursor: 'pointer' }} onClick={() => toggleExpand(em.id)}>
<span style={{ color: '#6b7c90', marginRight: '4px' }}>{open ? '▾' : '▸'}</span>
<span style={{ color: sent ? '#7fd1a8' : '#7fb0e0' }}>{sent ? '↗ Sent' : '↘ Received'}</span>
{' · '}{who}{em.has_attachments ? ' 📎' : ''}
</div>
<div className="timeline-meta">
{formatDate(em.sent_at)}
{(em.mailboxes || []).length > 0 && <span> · {em.mailboxes.join(', ')}</span>}
</div>
{em.subject && <div className="timeline-body" style={{ fontWeight: 600, cursor: 'pointer' }} onClick={() => toggleExpand(em.id)}>{em.subject}</div>}
{!open && em.snippet && <div className="timeline-body" style={{ marginTop: '4px', color: '#9fb0c2' }}>{em.snippet}</div>}
{open && renderExpandedBody(em.id)}
{tags.length > 0 ? (
<div style={{ marginTop: '6px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{tags.map((t) => (
<span key={t} style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '10px', border: '1px solid #263548', background: '#0d1622', color: em.is_matched ? '#cfe0f2' : '#9fb0c2' }}>{t}</span>
))}
</div>
) : (
<div style={{ marginTop: '6px' }}>
<span style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '10px', border: '1px dashed #4a3a2a', color: '#c0a070' }}>Unmatched</span>
</div>
)}
</div>
</div>
);
})}
</div>
</>
)}
</>)}
{mode === 'search' && (
<>
<div className="controls">
<input
type="text"
className="search-input"
placeholder="Find emails by content, e.g. “the mining deal wire timeline”"
value={contentQuery}
onChange={(e) => setContentQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') runContentSearch(); }}
/>
<button type="button" onClick={runContentSearch} disabled={searchLoading || !contentQuery.trim()}>
{searchLoading ? <Spinner /> : 'Search'}
</button>
</div>
{searchLoading ? (
<SkeletonBlock lines={6} />
) : searchError ? (
<div className="toast error" style={{ position: 'static' }}>{searchError}</div>
) : searchResults === null ? (
<div className="empty-state">Search the text of captured (investor-matched) emails. Only matched email bodies are indexed.</div>
) : (searchResults.results || []).length === 0 ? (
<div className="empty-state">No emails matched {contentQuery.trim()}.</div>
) : (
<>
<div className="form-help" style={{ marginBottom: '10px' }}>
{searchResults.results.length} result{searchResults.results.length === 1 ? '' : 's'}, most relevant first.
</div>
<div className="timeline">
{searchResults.results.map((r) => {
const sent = r.direction === 'outbound';
const who = r.from_name ? `${r.from_name} <${r.from_email}>` : (r.from_email || 'Unknown sender');
const open = expandedId === r.email_id;
return (
<div key={r.email_id} className="timeline-item">
<div className="timeline-marker"></div>
<div className="timeline-content">
<div className="timeline-header" style={{ cursor: 'pointer' }} onClick={() => toggleExpand(r.email_id)}>
<span style={{ color: '#6b7c90', marginRight: '4px' }}>{open ? '▾' : '▸'}</span>
<span style={{ color: sent ? '#7fd1a8' : '#7fb0e0' }}>{sent ? '↗ Sent' : '↘ Received'}</span>
{' · '}{who}{r.has_attachments ? ' 📎' : ''}
</div>
<div className="timeline-meta">
{formatDate(r.sent_at)}
{r.lp_name && <span> · {r.lp_name}</span>}
{typeof r.score === 'number' && <span> · score {r.score.toFixed(2)}</span>}
</div>
{r.subject && <div className="timeline-body" style={{ fontWeight: 600, cursor: 'pointer' }} onClick={() => toggleExpand(r.email_id)}>{r.subject}</div>}
{!open && r.excerpt && <div className="timeline-body" style={{ marginTop: '4px', color: '#9fb0c2', fontStyle: 'italic' }}>{r.excerpt}</div>}
{open && renderExpandedBody(r.email_id)}
</div>
</div>
);
})}
</div>
</>
)}
</>
)}
</div>
</div>
);
};
const FeatureRequestsPage = ({ token, onShowToast, user }) => {
const [requests, setRequests] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [showForm, setShowForm] = useState(false);
const [formError, setFormError] = useState('');
const [formData, setFormData] = useState({
title: '',
description: '',
category: 'ui_ux',
priority: 'medium',
page: '',
requested_by: user?.full_name || user?.username || ''
});
const fetchRequests = useCallback(async () => {
try {
setLoading(true);
const result = await api(`/api/feature-requests?status=${statusFilter}&search=${encodeURIComponent(search)}`, {}, token);
setRequests(result.data || []);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to load feature requests'), 'error');
} finally {
setLoading(false);
}
}, [statusFilter, search, token, onShowToast]);
useEffect(() => {
fetchRequests();
}, [fetchRequests]);
const handleSubmit = async (e) => {
e.preventDefault();
setFormError('');
try {
await api('/api/feature-requests', {
method: 'POST',
body: JSON.stringify(formData)
}, token);
setShowForm(false);
setFormData({
title: '',
description: '',
category: 'ui_ux',
priority: 'medium',
page: '',
requested_by: user?.full_name || user?.username || ''
});
await fetchRequests();
onShowToast('Feature request submitted', 'success');
} catch (err) {
setFormError(getErrorMessage(err, 'Failed to submit request'));
}
};
const handleStatusChange = async (id, status) => {
try {
await api(`/api/feature-requests/${id}`, {
method: 'PATCH',
body: JSON.stringify({ status })
}, token);
setRequests((prev) => prev.map((r) => (r.id === id ? { ...r, status } : r)));
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to update status'), 'error');
}
};
const handlePriorityChange = async (id, priority) => {
try {
await api(`/api/feature-requests/${id}`, {
method: 'PATCH',
body: JSON.stringify({ priority })
}, token);
setRequests((prev) => prev.map((r) => (r.id === id ? { ...r, priority } : r)));
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to update priority'), 'error');
}
};
const counts = useMemo(() => ({
total: requests.length,
newCount: requests.filter((r) => r.status === 'new').length,
planned: requests.filter((r) => r.status === 'planned').length,
done: requests.filter((r) => r.status === 'done').length
}), [requests]);
return (
<div className="page-container">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h2 className="section-title">Feature Requests</h2>
<button onClick={() => setShowForm(true)}>+ Submit Feedback</button>
</div>
<div className="kpi-grid">
<div className="kpi-card">
<div className="kpi-label">Total Requests</div>
<div className="kpi-value">{counts.total}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">New</div>
<div className="kpi-value">{counts.newCount}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Planned</div>
<div className="kpi-value">{counts.planned}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Done</div>
<div className="kpi-value">{counts.done}</div>
</div>
</div>
<div className="section">
<div className="controls">
<input
type="text"
className="search-input"
placeholder="Search requests..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select
className="select-input"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="">All Statuses</option>
<option value="new">New</option>
<option value="planned">Planned</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
<option value="wont_do">Won't Do</option>
</select>
</div>
{loading ? (
<SkeletonBlock lines={6} />
) : requests.length === 0 ? (
<div className="empty-state">No feature requests yet</div>
) : (
<table className="table">
<thead>
<tr>
<th>Title</th>
<th>Requested By</th>
<th>Category</th>
<th>Priority</th>
<th>Status</th>
<th>Submitted</th>
</tr>
</thead>
<tbody>
{requests.map((r) => (
<tr key={r.id}>
<td>
<div style={{ fontWeight: 600 }}>{r.title}</div>
{r.description && <div style={{ fontSize: '12px', color: '#8ea2b7', marginTop: '4px' }}>{r.description}</div>}
</td>
<td>{r.requested_by || '-'}</td>
<td>{(r.category || 'other').replace('_', ' ')}</td>
<td>
<select
className="select-input"
style={{ minWidth: '120px' }}
value={r.priority || 'medium'}
onChange={(e) => handlePriorityChange(r.id, e.target.value)}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</td>
<td>
<select
className="select-input"
style={{ minWidth: '140px' }}
value={r.status || 'new'}
onChange={(e) => handleStatusChange(r.id, e.target.value)}
>
<option value="new">New</option>
<option value="planned">Planned</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
<option value="wont_do">Won't Do</option>
</select>
</td>
<td>{formatDateLong(r.created_at)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{showForm && (
<div className="modal-overlay">
<div className="modal">
<div className="modal-header">Submit Feature Request</div>
{formError && <div className="toast error" style={{ position: 'static', marginBottom: '16px' }}>{formError}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Title *</label>
<input
type="text"
className="text-input"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
/>
</div>
<div className="form-group">
<label className="form-label">Description</label>
<textarea
className="text-input"
rows="4"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label">Category</label>
<select
className="select-input"
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
>
<option value="ui_ux">UI / UX</option>
<option value="workflow">Workflow</option>
<option value="reporting">Reporting</option>
<option value="integrations">Integrations</option>
<option value="bugs">Bug</option>
<option value="other">Other</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Priority</label>
<select
className="select-input"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Page / Area</label>
<input
type="text"
className="text-input"
placeholder="e.g., Pipeline, LP Tracker"
value={formData.page}
onChange={(e) => setFormData({ ...formData, page: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label">Requested By</label>
<input
type="text"
className="text-input"
value={formData.requested_by}
onChange={(e) => setFormData({ ...formData, requested_by: e.target.value })}
/>
</div>
<div className="form-actions">
<button type="button" className="button-secondary" onClick={() => setShowForm(false)}>Cancel</button>
<button type="submit">Submit</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
const FundraisingGridPage = ({ user, token, onShowToast, views, activeView, setActiveView, setViews, uiAction, onUiActionHandled }) => {
const STORAGE_KEY = FUNDRAISING_GRID_STORAGE_KEY;
const teamMembers = ['Grant', 'JK', 'GG', 'MB', 'Unassigned'];
const locationCatalog = [
{ label: 'New York City, NY, USA', city: 'New York City', state: 'NY', country: 'USA', aliases: ['new york', 'nyc'] },
{ label: 'Austin, TX, USA', city: 'Austin', state: 'TX', country: 'USA', aliases: ['austin'] },
{ label: 'San Francisco, CA, USA', city: 'San Francisco', state: 'CA', country: 'USA', aliases: ['san francisco', 'sf'] },
{ label: 'Miami, FL, USA', city: 'Miami', state: 'FL', country: 'USA', aliases: ['miami'] },
{ label: 'Los Angeles, CA, USA', city: 'Los Angeles', state: 'CA', country: 'USA', aliases: ['los angeles', 'la'] },
{ label: 'London, England, UK', city: 'London', state: 'England', country: 'UK', aliases: ['london'] }
];
const defaultColumns = [
{ id: 'investor_name', label: 'Investor Name', type: 'text', width: 220 },
{ id: 'contacts', label: 'Contacts', type: 'contacts', width: 260 },
{ id: 'log_action', label: 'Log', type: 'action', readOnly: true, width: 90 },
{ id: 'notes', label: 'Notes / Communication / Outreach', type: 'longtext', width: 420 },
{ id: 'lead_source', label: 'Lead Source', type: 'text', width: 180 },
{ id: 'notes_last_modified', label: 'Notes Last Modified', type: 'date', readOnly: true, width: 180 },
{ id: 'last_communication_date', label: 'Last Communication Date', type: 'date', readOnly: true, width: 195 },
{ id: 'priority', label: 'Priority', type: 'checkbox', width: 110 },
{ id: 'follow_up', label: 'Follow up', type: 'checkbox', width: 110 },
{ id: 'lead', label: 'Lead', type: 'select', options: teamMembers, width: 130 },
{ id: 'graveyard', label: 'Graveyard', type: 'checkbox', width: 115 },
{ id: 'pipeline', label: 'Pipeline', type: 'action', readOnly: true, width: 120 },
{ id: 'pipeline_stage', label: 'Pipeline Stage', type: 'text', readOnly: true, width: 150 },
{ id: 'fund_i', label: 'Fund I', type: 'currency', isFund: true, width: 130 },
{ id: 'fund_ii', label: 'Fund II', type: 'currency', isFund: true, width: 130 },
{ id: 'fund_iii', label: 'Fund III', type: 'currency', isFund: true, width: 130 },
{ id: 'tactical_fund', label: 'Tactical Fund', type: 'currency', isFund: true, width: 140 },
{ id: 'pawn_to_e4', label: 'Pawn to E4', type: 'currency', isFund: true, width: 130 },
{ id: 'ten31_terahash', label: 'Ten31 Terahash', type: 'currency', isFund: true, width: 150 },
{ id: 'sats_and_stats', label: 'Sats and Stats', type: 'currency', isFund: true, width: 140 },
{ id: 'pawn_to_f4', label: 'Pawn to f4', type: 'currency', isFund: true, width: 130 },
{ id: 'join_the_fold', label: 'Join the Fold', type: 'currency', isFund: true, width: 130 },
{ id: 'total_invested', label: 'Total invested', type: 'rollup', readOnly: true, width: 150 },
{ id: 'tactical_fund_commit_date', label: 'Tactical Fund Commit Date', type: 'date', width: 180 }
];
const defaultRows = [];
const loadGrid = () => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { columns: defaultColumns, rows: defaultRows };
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed.columns) || !Array.isArray(parsed.rows)) return { columns: defaultColumns, rows: defaultRows };
return parsed;
} catch (_) {
return { columns: defaultColumns, rows: defaultRows };
}
};
const initialGrid = loadGrid();
const [columns, setColumns] = useState(initialGrid.columns);
const [rows, setRows] = useState(initialGrid.rows);
const [quickSearch, setQuickSearch] = useState('');
const [filters, setFilters] = useState({ includeGraveyard: false, graveyardOnly: false, followUpOnly: false, lead: '' });
const [editing, setEditing] = useState(null);
const [showColumnModal, setShowColumnModal] = useState(false);
const [showViewModal, setShowViewModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [showColumnVisibilityModal, setShowColumnVisibilityModal] = useState(false);
const [showFilterModal, setShowFilterModal] = useState(false);
const [newColumn, setNewColumn] = useState({ label: '', type: 'text', options: '', isFund: false, formula: '' });
const [columnModalMode, setColumnModalMode] = useState('add');
const [editingColumnId, setEditingColumnId] = useState(null);
const [newViewName, setNewViewName] = useState('');
const [importCsvText, setImportCsvText] = useState('');
const [importError, setImportError] = useState('');
const [createMissingColumns, setCreateMissingColumns] = useState(true);
const [hiddenColumns, setHiddenColumns] = useState([]);
const [columnFilters, setColumnFilters] = useState([]);
const [footerAggs, setFooterAggs] = useState({});
const [rowDensity, setRowDensity] = useState('expanded');
const [sortState, setSortState] = useState({ colId: '', dir: 'asc' });
const [newRule, setNewRule] = useState({ colId: '', op: 'contains', value: '' });
const [resizing, setResizing] = useState(null);
const suppressNextHeaderSortRef = useRef(false);
const gridTableRef = useRef(null);
const [selectedCell, setSelectedCell] = useState(null);
const [contextMenu, setContextMenu] = useState(null);
const [newColumnInsertIndex, setNewColumnInsertIndex] = useState(null);
const [draggingColId, setDraggingColId] = useState(null);
const [dragOverColId, setDragOverColId] = useState(null);
const [showLogCommModal, setShowLogCommModal] = useState(false);
const [logCommContext, setLogCommContext] = useState(null);
const [logCommForm, setLogCommForm] = useState({ type: 'note', subject: '', body: '', outcome: '', next_action: '', next_action_date: '', append_note: true });
const [logCommSubmitting, setLogCommSubmitting] = useState(false);
const [showContactCardModal, setShowContactCardModal] = useState(false);
const [contactCardContext, setContactCardContext] = useState(null);
const [contactCardLoading, setContactCardLoading] = useState(false);
const contactLookupCacheRef = useRef(new Map());
const [showCreateOppModal, setShowCreateOppModal] = useState(false);
const [createOppContext, setCreateOppContext] = useState(null);
const [createOppForm, setCreateOppForm] = useState({ name: '', contactIndex: 0, stage: 'lead', expected_amount: '', probability: 35, fund_name: '' });
const [createOppSubmitting, setCreateOppSubmitting] = useState(false);
const [remoteVersion, setRemoteVersion] = useState(null);
const [stateHydrated, setStateHydrated] = useState(false);
const saveTimerRef = useRef(null);
const lastSyncedSnapshotRef = useRef('');
const lastAppliedViewIdRef = useRef(null);
const [importDebugOpen, setImportDebugOpen] = useState(false);
const [importDebugEvents, setImportDebugEvents] = useState([]);
const [importDebugServerSnapshot, setImportDebugServerSnapshot] = useState(null);
const rowCountRef = useRef(null);
const [collabPresence, setCollabPresence] = useState([]);
const [collabLocks, setCollabLocks] = useState([]);
const collabPollRef = useRef(null);
const collabSyncTimerRef = useRef(null);
const activeViewConfig = useMemo(() => views.find((v) => v.id === activeView) || null, [views, activeView]);
const columnsForActiveView = useMemo(() => {
const order = Array.isArray(activeViewConfig?.columnOrder) ? activeViewConfig.columnOrder : [];
const widths = (activeViewConfig?.columnWidths && typeof activeViewConfig.columnWidths === 'object') ? activeViewConfig.columnWidths : {};
const byId = new Map(columns.map((c) => [c.id, c]));
const ordered = [];
const used = new Set();
order.forEach((id) => {
const col = byId.get(id);
if (!col || used.has(id)) return;
used.add(id);
ordered.push(col);
});
columns.forEach((c) => {
if (used.has(c.id)) return;
ordered.push(c);
});
return ordered.map((c) => {
const w = Number(widths[c.id]);
if (Number.isFinite(w) && w > 0) return { ...c, width: Math.max(90, Math.floor(w)) };
return c;
});
}, [columns, activeViewConfig]);
const ensureNotesLastModifiedSchema = (incomingColumns, incomingRows) => {
const cols = (Array.isArray(incomingColumns) ? [...incomingColumns] : [...defaultColumns]).filter((c) => c && c.id !== 'longshot_followup');
let changed = false;
if (Array.isArray(incomingColumns) && incomingColumns.length !== cols.length) {
changed = true;
}
const hasCol = cols.some((c) => c.id === 'notes_last_modified');
if (!hasCol) {
const insertAt = Math.max(0, cols.findIndex((c) => c.id === 'priority'));
const col = { id: 'notes_last_modified', label: 'Notes Last Modified', type: 'date', readOnly: true, width: 180 };
if (insertAt > 0) cols.splice(insertAt, 0, col);
else cols.push(col);
changed = true;
}
const hasLogAction = cols.some((c) => c.id === 'log_action');
if (!hasLogAction) {
const contactsIdx = cols.findIndex((c) => c.id === 'contacts');
const col = { id: 'log_action', label: 'Log', type: 'action', readOnly: true, width: 90 };
if (contactsIdx >= 0) cols.splice(contactsIdx + 1, 0, col);
else cols.unshift(col);
changed = true;
}
const hasLeadSource = cols.some((c) => c.id === 'lead_source');
if (!hasLeadSource) {
const notesIdx = cols.findIndex((c) => c.id === 'notes');
const col = { id: 'lead_source', label: 'Lead Source', type: 'text', width: 180 };
if (notesIdx >= 0) cols.splice(notesIdx + 1, 0, col);
else cols.push(col);
changed = true;
}
const hasCommCol = cols.some((c) => c.id === 'last_communication_date');
if (!hasCommCol) {
const insertAt = Math.max(0, cols.findIndex((c) => c.id === 'priority'));
const col = { id: 'last_communication_date', label: 'Last Communication Date', type: 'date', readOnly: true, width: 195 };
if (insertAt > 0) cols.splice(insertAt, 0, col);
else cols.push(col);
changed = true;
}
// Pipeline-adoption columns: a per-row "Add to / In Pipeline" control and a
// read-only mirror of the linked opportunity's stage. Both are injected here so
// existing saved grids pick them up; their VALUES are server-computed on read.
const hasPipeline = cols.some((c) => c.id === 'pipeline');
if (!hasPipeline) {
const gy = cols.findIndex((c) => c.id === 'graveyard');
const col = { id: 'pipeline', label: 'Pipeline', type: 'action', readOnly: true, width: 120 };
if (gy >= 0) cols.splice(gy + 1, 0, col);
else cols.push(col);
changed = true;
}
const hasPipelineStage = cols.some((c) => c.id === 'pipeline_stage');
if (!hasPipelineStage) {
const pi = cols.findIndex((c) => c.id === 'pipeline');
const col = { id: 'pipeline_stage', label: 'Pipeline Stage', type: 'text', readOnly: true, width: 150 };
if (pi >= 0) cols.splice(pi + 1, 0, col);
else cols.push(col);
changed = true;
}
const rowsIn = Array.isArray(incomingRows) ? incomingRows : defaultRows;
const rowsOut = rowsIn.map((r) => {
const next = { ...r };
if (Object.prototype.hasOwnProperty.call(next, 'longshot_followup')) {
delete next.longshot_followup;
changed = true;
}
if (typeof next.notes_last_modified !== 'string') {
next.notes_last_modified = '';
changed = true;
}
if (typeof next.last_communication_date !== 'string') {
next.last_communication_date = '';
changed = true;
}
return next;
});
return { columns: cols, rows: rowsOut, changed };
};
// `pipeline` / `pipeline_stage` are read-only, server-computed (from the linked
// opportunity) and injected on GET. They must never participate in dirty-detection
// or be persisted — otherwise a link/unlink flips a value and triggers a no-op
// autosave + version bump. Strip them at every snapshot / persist boundary.
const stripComputedRows = (rs) => (Array.isArray(rs) ? rs.map((r) => {
if (!r || typeof r !== 'object') return r;
const { pipeline, pipeline_stage, ...rest } = r;
return rest;
}) : rs);
useEffect(() => {
let cancelled = false;
const hydrateFundraisingState = async () => {
try {
const result = await api('/api/fundraising/state', {}, token);
const data = result?.data || {};
const grid = data.grid || {};
const incomingColumnsRaw = Array.isArray(grid.columns) ? grid.columns : defaultColumns;
const incomingRowsRaw = Array.isArray(grid.rows) ? grid.rows : defaultRows;
const normalized = ensureNotesLastModifiedSchema(incomingColumnsRaw, incomingRowsRaw);
const incomingColumns = normalized.columns;
const incomingRows = normalized.rows;
const incomingViews = sanitizeGridViews(Array.isArray(data.views) && data.views.length > 0 ? data.views : DEFAULT_GRID_VIEWS);
const incomingVersion = Number(data.version);
pushImportDebugEvent('hydrate-success', {
rows: incomingRows.length,
cols: incomingColumns.length,
version: Number.isFinite(incomingVersion) ? incomingVersion : null,
views: incomingViews.length
});
if (cancelled) return;
setColumns(incomingColumns);
setRows(incomingRows);
setViews(incomingViews);
if (!incomingViews.find((v) => v.id === activeView)) {
setActiveView(incomingViews[0]?.id || 'view-main');
}
setRemoteVersion(Number.isFinite(incomingVersion) && incomingVersion > 0 ? incomingVersion : 1);
lastSyncedSnapshotRef.current = JSON.stringify({ columns: incomingColumns, rows: stripComputedRows(incomingRows), views: incomingViews });
localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns: incomingColumns, rows: stripComputedRows(incomingRows) }));
localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(incomingViews));
} catch (err) {
onShowToast(getErrorMessage(err, 'Using local fundraising data'), 'error');
pushImportDebugEvent('hydrate-error', { message: getErrorMessage(err, 'hydrate failed') });
setRemoteVersion(loadMockFundraisingVersion());
} finally {
if (!cancelled) setStateHydrated(true);
}
};
hydrateFundraisingState();
return () => {
cancelled = true;
};
}, [token, onShowToast, pushImportDebugEvent]);
useEffect(() => {
if (!stateHydrated || !Number.isFinite(remoteVersion)) return;
const persistRows = stripComputedRows(rows);
const snapshot = JSON.stringify({ columns, rows: persistRows, views });
if (snapshot === lastSyncedSnapshotRef.current) return;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(async () => {
try {
const response = await api('/api/fundraising/state', {
method: 'PUT',
body: JSON.stringify({
grid: { columns, rows: persistRows },
views,
expected_version: remoteVersion
})
}, token);
const nextVersion = Number(response?.data?.version);
if (Number.isFinite(nextVersion) && nextVersion > 0) {
setRemoteVersion(nextVersion);
} else {
setRemoteVersion((v) => (Number.isFinite(v) ? v + 1 : 1));
}
pushImportDebugEvent('autosave-success', {
rows: rows.length,
cols: columns.length,
views: views.length,
next_version: Number.isFinite(nextVersion) ? nextVersion : null
});
lastSyncedSnapshotRef.current = snapshot;
localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns, rows: persistRows }));
localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(views));
} catch (err) {
if (err?.status === 409) {
const payload = err?.payload || {};
const latestVersion = Number(payload?.current_version);
const who = payload?.current_updated_by ? ` by ${payload.current_updated_by}` : '';
const when = payload?.current_updated_at ? ` at ${formatDateLong(payload.current_updated_at)}` : '';
onShowToast(`Grid changed elsewhere${who}${when}. Retrying your save on latest version.`, 'error');
pushImportDebugEvent('autosave-conflict', {
current_version: Number.isFinite(latestVersion) ? latestVersion : null,
updated_by: payload?.current_updated_by || '',
updated_at: payload?.current_updated_at || ''
});
try {
if (Number.isFinite(latestVersion) && latestVersion > 0) {
setRemoteVersion(latestVersion);
} else {
const latest = await api('/api/fundraising/state', {}, token);
const latestData = latest?.data || {};
const fromServer = Number(latestData.version);
setRemoteVersion(Number.isFinite(fromServer) && fromServer > 0 ? fromServer : 1);
}
} catch (reloadErr) {
onShowToast(getErrorMessage(reloadErr, 'Failed to refresh version for retry'), 'error');
pushImportDebugEvent('autosave-conflict-refresh-error', { message: getErrorMessage(reloadErr, 'refresh failed') });
}
return;
}
onShowToast(getErrorMessage(err, 'Failed to save fundraising state'), 'error');
pushImportDebugEvent('autosave-error', { message: getErrorMessage(err, 'save failed') });
localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns, rows: persistRows }));
localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(views));
}
}, 550);
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
};
}, [columns, rows, views, remoteVersion, stateHydrated, token, setViews, onShowToast, pushImportDebugEvent]);
useEffect(() => {
if (!stateHydrated) return undefined;
sendCollabHeartbeat({ quiet: true });
if (collabPollRef.current) clearInterval(collabPollRef.current);
collabPollRef.current = setInterval(() => {
sendCollabHeartbeat({ quiet: true });
}, 7000);
return () => {
if (collabPollRef.current) clearInterval(collabPollRef.current);
collabPollRef.current = null;
sendCollabHeartbeat({ editingOverride: null, quiet: true });
};
}, [stateHydrated, sendCollabHeartbeat]);
useEffect(() => {
if (!stateHydrated) return undefined;
if (collabSyncTimerRef.current) clearTimeout(collabSyncTimerRef.current);
collabSyncTimerRef.current = setTimeout(() => {
sendCollabHeartbeat({ quiet: true });
}, 120);
return () => {
if (collabSyncTimerRef.current) clearTimeout(collabSyncTimerRef.current);
};
}, [selectedCell, editing, activeView, stateHydrated, sendCollabHeartbeat]);
useEffect(() => {
const selected = views.find((v) => v.id === activeView);
if (selected) {
if (lastAppliedViewIdRef.current === activeView) return;
const nextFilters = selected.filters || { includeGraveyard: false, graveyardOnly: false, followUpOnly: false, lead: '' };
setFilters({
includeGraveyard: !!nextFilters.includeGraveyard,
graveyardOnly: selected.id === 'view-graveyard' ? true : !!nextFilters.graveyardOnly,
followUpOnly: selected.id === 'view-followup' ? true : !!nextFilters.followUpOnly,
lead: nextFilters.lead || ''
});
setQuickSearch(selected.quickSearch || '');
setHiddenColumns(Array.isArray(selected.hiddenColumns) ? selected.hiddenColumns.filter((id) => id !== 'investor_name') : []);
setColumnFilters(Array.isArray(selected.columnFilters) ? selected.columnFilters : []);
setFooterAggs((selected.footerAggs && typeof selected.footerAggs === 'object') ? selected.footerAggs : {});
setRowDensity(selected.rowDensity === 'compact' ? 'compact' : 'expanded');
lastAppliedViewIdRef.current = activeView;
}
}, [activeView, views]);
useEffect(() => {
if (uiAction === 'open-import') {
setShowImportModal(true);
if (onUiActionHandled) onUiActionHandled();
return;
}
if (uiAction === 'open-save-view') {
setShowViewModal(true);
if (onUiActionHandled) onUiActionHandled();
return;
}
if (uiAction === 'save-active-view') {
setViews((prev) => prev.map((v) => (v.id === activeView ? {
...v,
filters: { ...filters },
quickSearch,
hiddenColumns: hiddenColumns.filter((id) => id !== 'investor_name'),
columnFilters: [...columnFilters],
footerAggs: { ...footerAggs },
rowDensity,
columnOrder: columnsForActiveView.map((c) => c.id),
columnWidths: Object.fromEntries(columnsForActiveView.map((c) => [c.id, Math.max(90, Number(c.width) || 140)]))
} : v)));
onShowToast('View updated', 'success');
if (onUiActionHandled) onUiActionHandled();
}
}, [uiAction, onUiActionHandled, setViews, activeView, filters, quickSearch, hiddenColumns, columnFilters, footerAggs, rowDensity, columnsForActiveView, onShowToast]);
const fundColumnIds = useMemo(() => columns.filter((c) => c.isFund).map((c) => c.id), [columns]);
const defaultTotalInvestedFormula = useMemo(() => {
const ids = columns
.filter((c) => (c.isFund || c.type === 'currency') && c.id !== 'total_invested')
.map((c) => c.id);
return ids.length > 0 ? ids.map((id) => `{${id}}`).join(' + ') : '0';
}, [columns]);
const computeRollup = useCallback((row) => {
return fundColumnIds.reduce((sum, colId) => sum + parseNumericInput(row[colId]), 0);
}, [fundColumnIds]);
const applyLocationAutoFill = (query) => {
const q = String(query || '').toLowerCase().trim();
if (!q) return null;
return locationCatalog.find((l) => l.label.toLowerCase().includes(q) || l.aliases.some((a) => a.includes(q)));
};
const getColumnById = useCallback((colId) => columns.find((c) => c.id === colId), [columns]);
const evaluateFormulaCell = useCallback((row, col, depth = 0) => {
const expression = String(col?.formula || '').trim();
if (!expression) return '';
if (depth > 6) return '#ERR';
const __FIELD = (fieldRef) => {
const key = String(fieldRef || '').trim();
if (!key) return '';
if (normalizeKey(key) === normalizeKey('total invested')) return computeRollup(row);
const target = columns.find((c) => c.id === key)
|| columns.find((c) => normalizeKey(c.id) === normalizeKey(key))
|| columns.find((c) => normalizeKey(c.label) === normalizeKey(key));
if (!target) return '';
if (target.id === 'total_invested') return computeRollup(row);
if (target.type === 'formula') return evaluateFormulaCell(row, target, depth + 1);
if (target.type === 'currency' || target.type === 'number') return parseNumericInput(row[target.id]);
return row[target.id];
};
const result = evaluateFormulaSafe(expression, __FIELD);
return result ?? '';
}, [columns, computeRollup]);
const getFilterableValue = useCallback((row, col) => {
if (!col) return '';
const value = row[col.id];
if (col.id === 'total_invested') {
if (col.formula) return parseNumericInput(evaluateFormulaCell(row, col));
return computeRollup(row);
}
if (col.type === 'formula') return evaluateFormulaCell(row, col);
if (col.type === 'contacts') {
return (Array.isArray(value) ? value : []).map((c) => `${c.name || ''} ${c.email || ''} ${c.title || ''} ${c.city || ''} ${c.state || ''} ${c.country || ''}`).join(' ');
}
return value;
}, [computeRollup, evaluateFormulaCell]);
const evaluateFilterRule = useCallback((row, rule) => {
const col = getColumnById(rule.colId);
if (!col) return true;
const cellValue = getFilterableValue(row, col);
const op = rule.op || 'contains';
const ruleValue = rule.value ?? '';
if (op === 'contains') return String(cellValue || '').toLowerCase().includes(String(ruleValue).toLowerCase());
if (op === 'equals') return String(cellValue || '').toLowerCase() === String(ruleValue).toLowerCase();
if (op === 'not_equals') return String(cellValue || '').toLowerCase() !== String(ruleValue).toLowerCase();
if (op === 'is_true') return !!cellValue;
if (op === 'is_false') return !cellValue;
if (op === 'gt') return parseNumericInput(cellValue) > parseNumericInput(ruleValue);
if (op === 'lt') return parseNumericInput(cellValue) < parseNumericInput(ruleValue);
if (op === 'on_or_after') return String(cellValue || '') >= String(ruleValue || '');
if (op === 'on_or_before') return String(cellValue || '') <= String(ruleValue || '');
return true;
}, [getColumnById, getFilterableValue]);
const displayedRows = useMemo(() => {
const withRollup = rows.map((r) => ({ ...r, total_invested: computeRollup(r) }));
const baseFiltered = withRollup.filter((r) => {
if (filters.graveyardOnly && !r.graveyard) return false;
if (!filters.graveyardOnly && !filters.includeGraveyard && r.graveyard) return false;
if (filters.followUpOnly && !r.follow_up) return false;
if (filters.lead && r.lead !== filters.lead) return false;
if (!Array.isArray(columnFilters) || columnFilters.length === 0) return true;
return columnFilters.every((rule) => evaluateFilterRule(r, rule));
});
const applySort = (items) => {
if (!sortState.colId) return items;
const col = getColumnById(sortState.colId);
if (!col) return items;
const dir = sortState.dir === 'desc' ? -1 : 1;
const normalized = items.map((row, idx) => ({ row, idx }));
normalized.sort((a, b) => {
const av = getFilterableValue(a.row, col);
const bv = getFilterableValue(b.row, col);
let cmp = 0;
if (col.type === 'currency' || col.type === 'number' || col.id === 'total_invested' || col.type === 'formula') {
cmp = parseNumericInput(av) - parseNumericInput(bv);
} else if (col.type === 'checkbox') {
cmp = (av === bv) ? 0 : (av ? 1 : -1);
} else {
cmp = String(av || '').localeCompare(String(bv || ''), undefined, { numeric: true, sensitivity: 'base' });
}
if (cmp === 0) return a.idx - b.idx;
return cmp * dir;
});
return normalized.map((x) => x.row);
};
const search = quickSearch.trim();
if (!search) return applySort(baseFiltered);
const rowText = (r) => {
const contactText = (r.contacts || []).map((c) => `${c.name || ''} ${c.email || ''} ${c.city || ''} ${c.state || ''} ${c.country || ''}`).join(' ');
return `${r.investor_name || ''} ${r.notes || ''} ${contactText}`;
};
const lowerSearch = search.toLowerCase();
const exact = baseFiltered.filter((r) => rowText(r).toLowerCase().includes(lowerSearch));
// Keep exact contains as primary behavior; add fuzzy only when exact is sparse.
if (exact.length >= 3) return applySort(exact);
const fuzzyRanked = baseFiltered
.map((r, index) => ({ row: r, index, score: fuzzyScore(search, rowText(r)) }))
.filter((x) => x.score >= 0.45)
.sort((a, b) => (b.score - a.score) || (a.index - b.index))
.map((x) => x.row);
if (exact.length === 0) return applySort(fuzzyRanked);
const seen = new Set(exact.map((r) => r.id));
const extras = fuzzyRanked.filter((r) => !seen.has(r.id));
return applySort([...exact, ...extras]);
}, [rows, filters, quickSearch, computeRollup, columnFilters, evaluateFilterRule, sortState, getColumnById, getFilterableValue]);
const visibleColumns = useMemo(() => {
return columnsForActiveView.filter((c) => !hiddenColumns.includes(c.id));
}, [columnsForActiveView, hiddenColumns]);
const isNumericColumn = useCallback((col) => {
if (!col) return false;
if (col.id === 'total_invested') return true;
return col.type === 'currency' || col.type === 'number' || col.type === 'rollup' || col.type === 'formula';
}, []);
const numericValuesByColumn = useMemo(() => {
const parseStrictNumber = (raw) => {
if (raw === null || raw === undefined) return null;
const cleaned = String(raw).replace(/[^0-9.-]/g, '').trim();
if (!cleaned || cleaned === '-' || cleaned === '.' || cleaned === '-.') return null;
const parsed = Number(cleaned);
return Number.isFinite(parsed) ? parsed : null;
};
const result = {};
visibleColumns.forEach((col) => {
if (!isNumericColumn(col)) return;
const values = [];
displayedRows.forEach((row) => {
let raw = null;
if (col.id === 'total_invested') {
raw = col.formula ? evaluateFormulaCell(row, col) : computeRollup(row);
} else if (col.type === 'formula') {
raw = evaluateFormulaCell(row, col);
} else {
raw = row[col.id];
}
const num = parseStrictNumber(raw);
if (num !== null) values.push(num);
});
result[col.id] = values;
});
return result;
}, [visibleColumns, displayedRows, isNumericColumn, evaluateFormulaCell, computeRollup]);
const formatAggregateValue = useCallback((col, value) => {
if (value === null || value === undefined || (typeof value === 'number' && !Number.isFinite(value))) return '-';
if (col?.type === 'currency' || col?.id === 'total_invested') return formatCurrencyLong(value);
if (typeof value === 'number') return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
return String(value);
}, []);
const getAggregateForColumn = useCallback((col) => {
if (!isNumericColumn(col)) return { op: 'none', label: '', value: '' };
const op = footerAggs[col.id] || 'sum';
const values = numericValuesByColumn[col.id] || [];
const n = values.length;
const sorted = [...values].sort((a, b) => a - b);
const sum = values.reduce((acc, v) => acc + v, 0);
const mean = n > 0 ? sum / n : null;
let agg = null;
if (op === 'none') agg = '';
else if (op === 'sum') agg = sum;
else if (op === 'avg') agg = mean;
else if (op === 'median') agg = n === 0 ? null : (n % 2 ? sorted[(n - 1) / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2);
else if (op === 'min') agg = n === 0 ? null : sorted[0];
else if (op === 'max') agg = n === 0 ? null : sorted[n - 1];
else if (op === 'range') agg = n === 0 ? null : sorted[n - 1] - sorted[0];
else if (op === 'stddev') {
if (n === 0 || mean === null) agg = null;
else {
const variance = values.reduce((acc, v) => acc + ((v - mean) ** 2), 0) / n;
agg = Math.sqrt(variance);
}
} else if (op === 'filled') agg = n;
else if (op === 'empty') agg = Math.max(0, displayedRows.length - n);
const opLabel = FOOTER_AGGREGATE_OPTIONS.find((o) => o.value === op)?.label || 'Sum';
return { op, label: opLabel, value: agg };
}, [footerAggs, numericValuesByColumn, displayedRows.length, isNumericColumn]);
const pushImportDebugEvent = useCallback((kind, payload = {}) => {
const stamp = new Date().toISOString();
setImportDebugEvents((prev) => [{ ts: stamp, kind, payload }, ...prev].slice(0, 80));
}, []);
const loadImportDebugSnapshot = useCallback(async () => {
try {
const [stateRes, summaryRes] = await Promise.all([
api('/api/fundraising/state', {}, token),
api('/api/fundraising/relational-summary', {}, token)
]);
const serverRows = Array.isArray(stateRes?.data?.grid?.rows) ? stateRes.data.grid.rows : [];
const relationalInvestors = Number(summaryRes?.data?.investors?.count || 0);
const version = Number(stateRes?.data?.version || 0);
const snapshot = {
fetched_at: new Date().toISOString(),
server_rows_count: serverRows.length,
relational_investors_count: relationalInvestors,
server_version: Number.isFinite(version) ? version : null
};
setImportDebugServerSnapshot(snapshot);
pushImportDebugEvent('server-snapshot', snapshot);
return snapshot;
} catch (err) {
pushImportDebugEvent('server-snapshot-error', { message: getErrorMessage(err, 'snapshot failed') });
return null;
}
}, [token, pushImportDebugEvent]);
const getCellKey = useCallback((rowId, colId) => `${rowId || ''}:${colId || ''}`, []);
const getCellLockByOther = useCallback((rowId, colId) => {
const key = getCellKey(rowId, colId);
return collabLocks.find((lock) => lock.cell_key === key && lock.locked_by_user_id !== user?.id) || null;
}, [collabLocks, getCellKey, user?.id]);
useEffect(() => {
const previous = rowCountRef.current;
if (previous === null) {
rowCountRef.current = rows.length;
pushImportDebugEvent('rows-init', { rows: rows.length, displayed: displayedRows.length });
return;
}
if (previous !== rows.length) {
pushImportDebugEvent('rows-changed', {
from: previous,
to: rows.length,
displayed: displayedRows.length,
active_view: activeView,
quick_search: quickSearch,
include_graveyard: !!filters.includeGraveyard,
graveyard_only: !!filters.graveyardOnly,
follow_up_only: !!filters.followUpOnly
});
rowCountRef.current = rows.length;
}
}, [rows.length, displayedRows.length, activeView, quickSearch, filters.includeGraveyard, filters.graveyardOnly, filters.followUpOnly, pushImportDebugEvent]);
const sendCollabHeartbeat = useCallback(async ({ selectedOverride = null, editingOverride = undefined, quiet = true } = {}) => {
try {
const selected = selectedOverride || (selectedCell ? { row_id: selectedCell.rowId, col_id: selectedCell.colId } : {});
let editingPayload = {};
if (editingOverride !== undefined) {
editingPayload = editingOverride ? { row_id: editingOverride.rowId, col_id: editingOverride.colId } : {};
} else if (editing) {
editingPayload = { row_id: editing.rowId, col_id: editing.colId };
}
const result = await api('/api/fundraising/collab/heartbeat', {
method: 'POST',
body: JSON.stringify({
active_view: activeView,
selected,
editing: editingPayload,
ttl_seconds: 25
})
}, token);
const data = result?.data || {};
setCollabPresence(Array.isArray(data.presence) ? data.presence : []);
setCollabLocks(Array.isArray(data.locks) ? data.locks : []);
return data;
} catch (err) {
if (!quiet) onShowToast(getErrorMessage(err, 'Failed to sync collaboration state'), 'error');
return null;
}
}, [selectedCell, editing, activeView, token, onShowToast]);
const updateCell = (rowId, colId, value) => {
setRows((prev) => prev.map((r) => {
if (r.id !== rowId) return r;
const next = { ...r, [colId]: value };
if (colId === 'notes') {
next.notes_last_modified = new Date().toISOString().slice(0, 10);
}
return next;
}));
};
const buildEmptyRow = () => {
const base = { id: `inv-${Date.now()}`, contacts: [], investor_name: '', notes: '', lead_source: '', notes_last_modified: '', last_communication_date: '', lead: '', priority: false, follow_up: false, graveyard: false };
columns.forEach((c) => {
if (c.type === 'checkbox') base[c.id] = false;
if (c.type === 'currency' || c.type === 'number') base[c.id] = 0;
if (c.type === 'date' || c.type === 'text' || c.type === 'longtext' || c.type === 'select') base[c.id] = '';
if (c.type === 'contacts') base[c.id] = [];
});
return base;
};
const openLogCommunicationModal = (row, contact = null) => {
if (!row) return;
setLogCommContext({ rowId: row.id, investorName: row.investor_name || '', contact: contact || null });
setLogCommForm({ type: 'note', subject: '', body: '', outcome: '', next_action: '', next_action_date: '', append_note: true });
setShowLogCommModal(true);
};
const openContactCardModal = async (row, contact = null) => {
if (!row || !contact) return;
const norm = (v) => String(v || '').trim().toLowerCase();
const normName = (v) => norm(v).replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim();
const contactName = norm(contact.name);
const contactNameNorm = normName(contact.name);
const contactEmail = norm(contact.email);
const investorName = String(row.investor_name || '').trim();
const investorNameNorm = normName(investorName);
const cacheKey = `${contactEmail}|${contactNameNorm}|${investorNameNorm}`;
setContactCardContext({
investorName,
leadSource: row.lead_source || '',
contact: { ...contact }
});
setShowContactCardModal(true);
setContactCardLoading(true);
try {
if (contactLookupCacheRef.current.has(cacheKey)) {
const cached = contactLookupCacheRef.current.get(cacheKey);
if (cached) {
setContactCardContext({
investorName: cached.organization_name || cached.organization || investorName,
leadSource: cached.source || row.lead_source || '',
contact: {
name: `${cached.first_name || ''} ${cached.last_name || ''}`.trim() || contact.name || '',
email: cached.email || contact.email || '',
title: cached.title || contact.title || '',
city: cached.city || contact.city || '',
state: cached.state || contact.state || '',
country: cached.country || contact.country || '',
linkedin_url: cached.linkedin_url || '',
source: cached.source || row.lead_source || '',
organization_name: cached.organization_name || cached.organization || investorName
}
});
}
return;
}
const scoreCandidate = (c) => {
const cEmail = norm(c?.email);
const cNameNorm = normName(`${c?.first_name || ''} ${c?.last_name || ''}`);
const cOrgNorm = normName(c?.organization_name || c?.organization || '');
let score = 0;
if (contactEmail && cEmail && cEmail === contactEmail) score += 100;
if (contactNameNorm && cNameNorm && cNameNorm === contactNameNorm) score += 40;
if (investorNameNorm && cOrgNorm && cOrgNorm === investorNameNorm) score += 20;
if (contactName && norm(`${c?.first_name || ''} ${c?.last_name || ''}`) === contactName) score += 10;
return score;
};
const searchTerms = [];
if (contactEmail) searchTerms.push(contactEmail);
if (contactName) searchTerms.push(contactName);
if (investorName) searchTerms.push(investorName);
let candidates = [];
for (const term of searchTerms) {
if (!term) continue;
const result = await api(`/api/contacts?search=${encodeURIComponent(term)}&limit=200`, {}, token);
const rows = Array.isArray(result?.data) ? result.data : [];
candidates = candidates.concat(rows);
}
const dedup = [];
const seen = new Set();
candidates.forEach((c) => {
if (!c || !c.id || seen.has(c.id)) return;
seen.add(c.id);
dedup.push(c);
});
dedup.sort((a, b) => scoreCandidate(b) - scoreCandidate(a));
const matched = dedup.length > 0 && scoreCandidate(dedup[0]) >= 40 ? dedup[0] : null;
if (matched) {
contactLookupCacheRef.current.set(cacheKey, matched);
const merged = {
name: `${matched.first_name || ''} ${matched.last_name || ''}`.trim() || contact.name || '',
email: matched.email || contact.email || '',
title: matched.title || contact.title || '',
city: matched.city || contact.city || '',
state: matched.state || contact.state || '',
country: matched.country || contact.country || '',
linkedin_url: matched.linkedin_url || '',
source: matched.source || row.lead_source || '',
organization_name: matched.organization_name || matched.organization || investorName
};
setContactCardContext({
investorName: merged.organization_name || investorName,
leadSource: merged.source || '',
contact: merged
});
}
} catch (_) {
// Keep fallback row-level contact values if lookup fails.
} finally {
setContactCardLoading(false);
}
};
const submitLogCommunication = async () => {
if (!logCommContext?.rowId) return;
setLogCommSubmitting(true);
try {
const payload = {
row_id: logCommContext.rowId,
investor_name: logCommContext.investorName || '',
contact: logCommContext.contact,
type: logCommForm.type || 'note',
subject: logCommForm.subject || '',
body: logCommForm.body || '',
outcome: logCommForm.outcome || '',
next_action: logCommForm.next_action || '',
next_action_date: logCommForm.next_action_date || '',
append_note: !!logCommForm.append_note
};
const result = await api('/api/fundraising/log-communication', {
method: 'POST',
body: JSON.stringify(payload)
}, token);
const nextRow = result?.data?.row || null;
const nextVersion = Number(result?.data?.version);
if (nextRow && nextRow.id) {
setRows((prev) => prev.map((r) => (r.id === nextRow.id ? { ...r, ...nextRow } : r)));
}
if (Number.isFinite(nextVersion) && nextVersion > 0) {
setRemoteVersion(nextVersion);
}
onShowToast('Communication logged', 'success');
setShowLogCommModal(false);
setLogCommContext(null);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to log communication'), 'error');
} finally {
setLogCommSubmitting(false);
}
};
const splitFullName = (fullName) => {
const parts = String(fullName || '').trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return { first: 'Unknown', last: '' };
if (parts.length === 1) return { first: parts[0], last: '' };
return { first: parts[0], last: parts.slice(1).join(' ') };
};
const openCreateOpportunityModal = (row) => {
if (!row) return;
const contacts = Array.isArray(row.contacts) ? row.contacts : [];
if (contacts.length === 0) {
onShowToast('Add at least one contact to this investor row before adding it to the pipeline', 'error');
return;
}
const defaultName = `${row.investor_name || 'Investor'} — Pipeline`;
setCreateOppContext({ rowId: row.id, investorName: row.investor_name || '', contacts });
setCreateOppForm({
name: defaultName,
contactIndex: 0,
stage: 'lead',
expected_amount: '',
probability: row.priority ? 55 : 35,
fund_name: ''
});
setShowCreateOppModal(true);
};
// Grid is canonical: the server resolves the contact from the row's already-synced
// contact pill (no POST /api/contacts side-door) and links the opp durably, so the
// bot/board can never spawn a duplicate. We only pass the seed.
const submitCreateOpportunity = async () => {
if (!createOppContext?.rowId) return;
setCreateOppSubmitting(true);
const rowId = createOppContext.rowId;
const payload = {
source_row_id: rowId,
contact_index: Number(createOppForm.contactIndex) || 0,
name: String(createOppForm.name || '').trim(),
stage: createOppForm.stage || 'lead',
expected_amount: parseNumericInput(createOppForm.expected_amount),
probability: Number(createOppForm.probability) || 35,
fund_name: String(createOppForm.fund_name || '').trim()
};
const doLink = () => api('/api/fundraising/pipeline/link', { method: 'POST', body: JSON.stringify(payload) }, token);
try {
let resp;
try {
resp = await doLink();
} catch (err) {
// A row added moments ago may not have autosaved/synced yet — wait for the
// debounced save to flush, then retry once.
if (err?.status === 404) {
await new Promise((r) => setTimeout(r, 700));
resp = await doLink();
} else {
throw err;
}
}
const stage = resp?.data?.stage || payload.stage;
const already = resp?.already_linked;
setRows((prev) => prev.map((r) => (r.id === rowId ? { ...r, pipeline: true, pipeline_stage: stage } : r)));
onShowToast(already ? 'Already in the pipeline' : 'Added to the pipeline', already ? 'info' : 'success');
setShowCreateOppModal(false);
setCreateOppContext(null);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to add to the pipeline'), 'error');
} finally {
setCreateOppSubmitting(false);
}
};
// Remove from pipeline: archives (soft-deletes) the linked opportunity. The grid
// investor row — contacts, commitments, notes — is left fully intact.
const removeFromPipeline = async (row) => {
if (!row?.id) return;
const label = row.investor_name || 'this investor';
if (!window.confirm(`Remove ${label} from the pipeline? The deal is archived (recoverable); the grid row is untouched.`)) return;
try {
await api('/api/fundraising/pipeline/unlink', { method: 'POST', body: JSON.stringify({ source_row_id: row.id }) }, token);
setRows((prev) => prev.map((r) => (r.id === row.id ? { ...r, pipeline: false, pipeline_stage: '' } : r)));
onShowToast('Removed from the pipeline', 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to remove from the pipeline'), 'error');
}
};
const addRow = () => {
const base = buildEmptyRow();
setRows((prev) => [base, ...prev]);
};
const addRowBelow = (rowId) => {
const base = buildEmptyRow();
setRows((prev) => {
const idx = prev.findIndex((r) => r.id === rowId);
if (idx < 0) return [base, ...prev];
const next = [...prev];
next.splice(idx + 1, 0, base);
return next;
});
};
const addColumn = () => {
if (!newColumn.label.trim()) return;
if (newColumn.type === 'rollup') {
onShowToast('Use Formula for custom computed columns. Rollup is reserved for system columns.', 'error');
return;
}
if (newColumn.type === 'formula' && !String(newColumn.formula || '').trim()) {
onShowToast('Formula column requires a formula', 'error');
return;
}
const id = newColumn.label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') + '_' + Date.now().toString().slice(-4);
const col = {
id,
label: newColumn.label.trim(),
type: newColumn.type,
width: newColumn.type === 'longtext' ? 280 : 150,
isFund: newColumn.type === 'currency' ? !!newColumn.isFund : false,
options: newColumn.type === 'select' ? newColumn.options.split(',').map((s) => s.trim()).filter(Boolean) : undefined,
formula: newColumn.type === 'formula' ? newColumn.formula : undefined,
readOnly: newColumn.type === 'formula'
};
setColumns((prev) => {
const next = [...prev];
if (newColumnInsertIndex !== null && Number.isFinite(newColumnInsertIndex)) {
const idx = Math.max(0, Math.min(next.length, newColumnInsertIndex));
next.splice(idx, 0, col);
} else {
const rollupIdx = next.findIndex((c) => c.id === 'total_invested');
if (rollupIdx >= 0) next.splice(rollupIdx, 0, col);
else next.push(col);
}
return next;
});
setRows((prev) => prev.map((r) => ({ ...r, [id]: col.type === 'checkbox' ? false : col.type === 'currency' || col.type === 'number' ? 0 : col.type === 'contacts' ? [] : '' })));
setNewColumn({ label: '', type: 'text', options: '', isFund: false, formula: '' });
setNewColumnInsertIndex(null);
setShowColumnModal(false);
onShowToast('Column added', 'success');
};
const openAddColumnModal = (insertIndex = null) => {
setColumnModalMode('add');
setEditingColumnId(null);
setNewColumn({ label: '', type: 'text', options: '', isFund: false, formula: '' });
setNewColumnInsertIndex(insertIndex);
setShowColumnModal(true);
};
const openEditColumnModal = (colId) => {
const col = columns.find((c) => c.id === colId);
if (!col) return;
setColumnModalMode('edit');
setEditingColumnId(col.id);
setNewColumn({
label: col.label || '',
type: col.type || 'text',
options: Array.isArray(col.options) ? col.options.join(', ') : '',
isFund: !!col.isFund,
formula: col.id === 'total_invested'
? String(col.formula || defaultTotalInvestedFormula)
: String(col.formula || '')
});
setNewColumnInsertIndex(null);
setShowColumnModal(true);
};
const saveColumnModal = () => {
if (columnModalMode === 'add') {
addColumn();
return;
}
if (!editingColumnId) return;
if (!newColumn.label.trim()) {
onShowToast('Column name is required', 'error');
return;
}
if ((newColumn.type === 'formula' || editingColumnId === 'total_invested') && !String(newColumn.formula || '').trim()) {
onShowToast('Formula is required for this column', 'error');
return;
}
setColumns((prev) => prev.map((c) => {
if (c.id !== editingColumnId) return c;
const next = { ...c, label: newColumn.label.trim() };
if (c.id === 'total_invested') {
next.type = 'rollup';
next.readOnly = true;
next.formula = String(newColumn.formula || '').trim();
return next;
}
next.type = newColumn.type;
next.options = newColumn.type === 'select'
? newColumn.options.split(',').map((s) => s.trim()).filter(Boolean)
: undefined;
if (newColumn.type === 'formula') {
next.readOnly = true;
next.formula = String(newColumn.formula || '').trim();
} else {
next.readOnly = false;
if (Object.prototype.hasOwnProperty.call(next, 'formula')) delete next.formula;
}
if (newColumn.type === 'currency') {
next.isFund = !!newColumn.isFund;
} else if (Object.prototype.hasOwnProperty.call(next, 'isFund')) {
next.isFund = false;
}
return next;
}));
setShowColumnModal(false);
setEditingColumnId(null);
setColumnModalMode('add');
onShowToast('Column updated', 'success');
};
const ensureUniqueColumnId = (base, existingCols) => {
let candidate = base;
let i = 1;
const existingIds = new Set(existingCols.map((c) => c.id));
while (existingIds.has(candidate)) {
candidate = `${base}_${i}`;
i += 1;
}
return candidate;
};
const handleImportAirtableCsv = async () => {
setImportError('');
const records = parseCsvRecords(importCsvText);
if (records.length === 0) {
setImportError('No rows detected. Paste Airtable CSV export data or upload a CSV file.');
return;
}
pushImportDebugEvent('import-start', {
csv_rows: records.length,
existing_rows: rows.length,
existing_columns: columns.length
});
const existingColumns = [...columns];
const headers = Object.keys(records[0] || {});
const knownByNormalized = new Map();
existingColumns.forEach((c) => {
knownByNormalized.set(normalizeKey(c.label), c);
knownByNormalized.set(normalizeKey(c.id), c);
});
const findColumnForHeader = (header) => {
const h = normalizeKey(header);
if (knownByNormalized.has(h)) return knownByNormalized.get(h);
if (h.includes('investor name')) return existingColumns.find((c) => c.id === 'investor_name');
if (h === 'contacts') return existingColumns.find((c) => c.id === 'contacts');
if (h.includes('notes')) return existingColumns.find((c) => c.id === 'notes');
if (h.includes('lead source')) return existingColumns.find((c) => c.id === 'lead_source');
if (h.includes('last communication')) return existingColumns.find((c) => c.id === 'last_communication_date');
if (h === 'priority') return existingColumns.find((c) => c.id === 'priority');
if (h.includes('follow up')) return existingColumns.find((c) => c.id === 'follow_up');
if (h === 'lead') return existingColumns.find((c) => c.id === 'lead');
if (h === 'graveyard') return existingColumns.find((c) => c.id === 'graveyard');
if (h.includes('total invested')) return existingColumns.find((c) => c.id === 'total_invested');
if (h.includes('commit date')) return existingColumns.find((c) => c.id === 'tactical_fund_commit_date');
return null;
};
const headerToColumn = {};
const newColumns = [];
headers.forEach((header) => {
const mapped = findColumnForHeader(header);
if (mapped) {
headerToColumn[header] = mapped;
return;
}
if (!createMissingColumns) return;
const baseId = normalizeKey(header).replace(/\s+/g, '_') || `col_${Date.now()}`;
const uniqueId = ensureUniqueColumnId(baseId, [...existingColumns, ...newColumns]);
const colType = normalizeKey(header).includes('date') ? 'date' : normalizeKey(header).includes('fund') ? 'currency' : 'text';
const col = { id: uniqueId, label: header.trim() || uniqueId, type: colType, width: colType === 'currency' ? 140 : 170, isFund: normalizeKey(header).includes('fund') };
newColumns.push(col);
headerToColumn[header] = col;
});
const combinedColumns = newColumns.length > 0
? (() => {
const next = [...existingColumns];
const rollupIdx = next.findIndex((c) => c.id === 'total_invested');
if (rollupIdx >= 0) next.splice(rollupIdx, 0, ...newColumns);
else next.push(...newColumns);
return next;
})()
: existingColumns;
const importedRows = records.map((record, idx) => {
const row = { id: `inv-import-${Date.now()}-${idx}`, contacts: [] };
combinedColumns.forEach((c) => {
if (c.type === 'checkbox') row[c.id] = false;
else if (c.type === 'currency' || c.type === 'number') row[c.id] = 0;
else row[c.id] = c.type === 'contacts' ? [] : '';
});
headers.forEach((header) => {
const col = headerToColumn[header];
if (!col || col.id === 'total_invested') return;
const raw = record[header];
if (col.type === 'checkbox') {
row[col.id] = parseBoolCell(raw);
return;
}
if (col.type === 'currency' || col.type === 'number') {
row[col.id] = parseNumericInput(raw);
return;
}
if (col.type === 'contacts') {
const names = String(raw || '').split(',').map((s) => s.trim()).filter(Boolean);
row[col.id] = names.map((name) => ({ name, email: '', title: '', city: '', state: '', country: '', location_query: '' }));
return;
}
row[col.id] = raw;
});
const emailHeader = headers.find((h) => normalizeKey(h).includes('contact email') || normalizeKey(h) === 'emails');
if (emailHeader && Array.isArray(row.contacts) && row.contacts.length > 0) {
const emails = String(record[emailHeader] || '').split(',').map((s) => s.trim()).filter(Boolean);
row.contacts = row.contacts.map((c, i) => ({ ...c, email: emails[i] || '' }));
}
return row;
});
const nextRows = [...importedRows, ...rows];
const nextViews = (() => {
const next = views.map((v) => {
if (v.id !== 'view-all') return v;
return {
...v,
filters: { includeGraveyard: true, graveyardOnly: false, followUpOnly: false, lead: '' },
quickSearch: '',
hiddenColumns: [],
columnFilters: [],
footerAggs: (v.footerAggs && typeof v.footerAggs === 'object') ? v.footerAggs : {},
rowDensity: v.rowDensity === 'compact' ? 'compact' : 'expanded'
};
});
if (!next.some((v) => v.id === 'view-all')) {
next.push({
id: 'view-all',
name: 'All Investors',
filters: { includeGraveyard: true, graveyardOnly: false, followUpOnly: false, lead: '' },
quickSearch: '',
hiddenColumns: [],
columnFilters: [],
footerAggs: {},
rowDensity: 'expanded',
columnOrder: [],
columnWidths: {}
});
}
return next;
})();
if (newColumns.length > 0) setColumns(combinedColumns);
setRows(nextRows);
setViews(nextViews);
lastAppliedViewIdRef.current = null;
setActiveView('view-all');
setFilters({ includeGraveyard: true, graveyardOnly: false, followUpOnly: false, lead: '' });
setQuickSearch('');
setColumnFilters([]);
setHiddenColumns([]);
setFooterAggs((nextViews.find((v) => v.id === 'view-all')?.footerAggs) || {});
setRowDensity((nextViews.find((v) => v.id === 'view-all')?.rowDensity) === 'compact' ? 'compact' : 'expanded');
setSortState({ colId: '', dir: 'asc' });
setImportCsvText('');
setShowImportModal(false);
onShowToast(`Imported ${importedRows.length} rows from Airtable CSV`, 'success');
pushImportDebugEvent('import-done', {
imported_rows: importedRows.length,
total_rows_after_local_set: nextRows.length,
active_view: 'view-all'
});
try {
let expectedVersion = Number(remoteVersion);
if (!Number.isFinite(expectedVersion) || expectedVersion <= 0) {
const latest = await api('/api/fundraising/state', {}, token);
expectedVersion = Number(latest?.data?.version);
}
const persistRows = stripComputedRows(nextRows);
const saveResponse = await api('/api/fundraising/state', {
method: 'PUT',
body: JSON.stringify({
grid: { columns: combinedColumns, rows: persistRows },
views: nextViews,
expected_version: Number.isFinite(expectedVersion) && expectedVersion > 0 ? expectedVersion : 1
})
}, token);
const persistedVersion = Number(saveResponse?.data?.version);
if (Number.isFinite(persistedVersion) && persistedVersion > 0) {
setRemoteVersion(persistedVersion);
}
lastSyncedSnapshotRef.current = JSON.stringify({ columns: combinedColumns, rows: persistRows, views: nextViews });
localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns: combinedColumns, rows: persistRows }));
localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(nextViews));
pushImportDebugEvent('import-persist-success', {
persisted_version: Number.isFinite(persistedVersion) ? persistedVersion : null,
rows: nextRows.length
});
} catch (persistErr) {
pushImportDebugEvent('import-persist-error', { message: getErrorMessage(persistErr, 'persist failed') });
onShowToast(`Imported locally but failed to persist: ${getErrorMessage(persistErr, 'save failed')}`, 'error');
}
setTimeout(() => {
loadImportDebugSnapshot();
}, 1200);
};
const handleCsvFileUpload = async (event) => {
const file = event.target.files && event.target.files[0];
if (!file) return;
try {
const text = await file.text();
setImportCsvText(text);
} catch (_) {
setImportError('Could not read selected file');
} finally {
event.target.value = '';
}
};
const saveCurrentFiltersAsView = () => {
if (!newViewName.trim()) return;
const id = `view-${Date.now()}`;
setViews((prev) => [...prev, {
id,
name: newViewName.trim(),
filters: { ...filters },
quickSearch,
hiddenColumns: hiddenColumns.filter((id) => id !== 'investor_name'),
columnFilters: [...columnFilters],
footerAggs: { ...footerAggs },
rowDensity,
columnOrder: columnsForActiveView.map((c) => c.id),
columnWidths: Object.fromEntries(columnsForActiveView.map((c) => [c.id, Math.max(90, Number(c.width) || 140)]))
}]);
setActiveView(id);
setNewViewName('');
setShowViewModal(false);
onShowToast('View saved', 'success');
};
const cellTabIndex = (rowIndex, colIndex) => rowIndex * visibleColumns.length + colIndex + 1;
const moveFocus = (rowIndex, colIndex) => {
const target = document.querySelector(`[data-cell-key="r${rowIndex}-c${colIndex}"]`);
if (target) target.focus();
};
const moveByKey = (rowIndex, colIndex, key, shiftKey = false) => {
if (visibleColumns.length === 0 || displayedRows.length === 0) return;
let nextRow = rowIndex;
let nextCol = colIndex;
if (key === 'Tab') {
nextCol = shiftKey ? colIndex - 1 : colIndex + 1;
if (nextCol >= visibleColumns.length) {
nextCol = 0;
nextRow = Math.min(displayedRows.length - 1, rowIndex + 1);
}
if (nextCol < 0) {
nextCol = visibleColumns.length - 1;
nextRow = Math.max(0, rowIndex - 1);
}
} else if (key === 'Enter') {
nextRow = Math.min(displayedRows.length - 1, rowIndex + 1);
}
moveFocus(nextRow, nextCol);
};
const tryBeginCellEdit = async (row, col) => {
if (!row || !col) return false;
if (col.type === 'action' || col.type === 'checkbox' || col.readOnly || col.id === 'total_invested') return false;
const existing = getCellLockByOther(row.id, col.id);
if (existing) {
onShowToast(`Locked by ${existing.locked_by_full_name || existing.locked_by_username || 'another user'}`, 'error');
return false;
}
const heartbeat = await sendCollabHeartbeat({
selectedOverride: { row_id: row.id, col_id: col.id },
editingOverride: { rowId: row.id, colId: col.id },
quiet: true
});
const conflict = heartbeat?.lock_conflict;
if (conflict && conflict.locked_by_user_id !== user?.id) {
onShowToast(`Locked by ${conflict.locked_by_full_name || conflict.locked_by_username || 'another user'}`, 'error');
return false;
}
setSelectedCell({ rowId: row.id, colId: col.id });
setEditing({ rowId: row.id, colId: col.id });
return true;
};
useEffect(() => {
if (!resizing) return undefined;
const onMouseMove = (event) => {
const deltaX = event.clientX - resizing.startX;
const nextWidth = Math.max(90, resizing.startWidth + deltaX);
setViews((prev) => prev.map((v) => {
if (v.id !== activeView) return v;
return {
...v,
columnWidths: {
...(v.columnWidths || {}),
[resizing.colId]: nextWidth
}
};
}));
};
const onMouseUp = () => {
suppressNextHeaderSortRef.current = true;
setResizing(null);
window.setTimeout(() => {
suppressNextHeaderSortRef.current = false;
}, 200);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
}, [resizing, activeView, setViews]);
useEffect(() => {
if (!contextMenu) return undefined;
const close = () => setContextMenu(null);
const onKeyDown = (event) => {
if (event.key === 'Escape') close();
};
window.addEventListener('click', close);
window.addEventListener('contextmenu', close);
window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('click', close);
window.removeEventListener('contextmenu', close);
window.removeEventListener('keydown', onKeyDown);
};
}, [contextMenu]);
const startColumnResize = (event, col) => {
event.preventDefault();
event.stopPropagation();
suppressNextHeaderSortRef.current = true;
setResizing({
colId: col.id,
startX: event.clientX,
startWidth: col.width || 140
});
};
const moveColumnBefore = (fromColId, targetColId) => {
if (!fromColId || !targetColId || fromColId === targetColId) return;
if (fromColId === 'investor_name' || targetColId === 'investor_name') return;
const currentOrder = columnsForActiveView.map((c) => c.id);
const fromIndex = currentOrder.indexOf(fromColId);
const targetIndex = currentOrder.indexOf(targetColId);
if (fromIndex < 0 || targetIndex < 0 || fromIndex === targetIndex) return;
const nextOrder = [...currentOrder];
const [moved] = nextOrder.splice(fromIndex, 1);
const insertIndex = fromIndex < targetIndex ? targetIndex : targetIndex;
nextOrder.splice(insertIndex, 0, moved);
setViews((prev) => prev.map((v) => (
v.id === activeView ? { ...v, columnOrder: nextOrder } : v
)));
};
const openGridContextMenu = (event, payload = {}) => {
event.preventDefault();
event.stopPropagation();
setContextMenu({
x: event.clientX,
y: event.clientY,
...payload
});
};
const handleContextAction = (action) => {
if (!contextMenu) return;
if (action === 'add-investor-top') {
addRow();
} else if (action === 'add-investor-below' && contextMenu.rowId) {
addRowBelow(contextMenu.rowId);
} else if (action === 'insert-col-left') {
const realIndex = columns.findIndex((c) => c.id === contextMenu.colId);
openAddColumnModal(Math.max(0, realIndex >= 0 ? realIndex : 0));
} else if (action === 'insert-col-right') {
const realIndex = columns.findIndex((c) => c.id === contextMenu.colId);
openAddColumnModal(Math.max(0, (realIndex >= 0 ? realIndex : 0) + 1));
} else if (action === 'add-column-end') {
openAddColumnModal(null);
} else if (action === 'edit-column' && contextMenu.colId) {
openEditColumnModal(contextMenu.colId);
} else if (action === 'manage-columns') {
setShowColumnVisibilityModal(true);
} else if (action === 'add-filter') {
setShowFilterModal(true);
} else if (action === 'save-view') {
setShowViewModal(true);
} else if (action === 'log-communication' && contextMenu.rowId) {
const target = rows.find((r) => r.id === contextMenu.rowId);
if (target) openLogCommunicationModal(target, null);
} else if (action === 'create-opportunity' && contextMenu.rowId) {
const target = rows.find((r) => r.id === contextMenu.rowId);
if (target) openCreateOpportunityModal(target);
} else if (action === 'delete-row' && contextMenu.rowId) {
const target = rows.find((r) => r.id === contextMenu.rowId);
const label = String(target?.investor_name || 'this investor').trim() || 'this investor';
const confirmed = window.confirm(`Delete row for "${label}"? This removes the investor from the fundraising grid.`);
if (confirmed) {
setRows((prev) => prev.filter((r) => r.id !== contextMenu.rowId));
onShowToast(`Deleted investor row: ${label}`, 'success');
}
}
setContextMenu(null);
};
const getRuleOpsForColumn = (col) => {
if (!col) return [{ value: 'contains', label: 'contains' }, { value: 'equals', label: 'equals' }];
if (col.type === 'checkbox') return [{ value: 'is_true', label: 'is checked' }, { value: 'is_false', label: 'is unchecked' }];
if (col.type === 'currency' || col.type === 'number' || col.id === 'total_invested') {
return [{ value: 'gt', label: '>' }, { value: 'lt', label: '<' }, { value: 'equals', label: '=' }];
}
if (col.type === 'date') {
return [{ value: 'on_or_after', label: 'on or after' }, { value: 'on_or_before', label: 'on or before' }, { value: 'equals', label: 'on date' }];
}
return [
{ value: 'contains', label: 'contains' },
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'not equals' }
];
};
const addFilterRule = () => {
if (!newRule.colId) return;
const col = getColumnById(newRule.colId);
const ops = getRuleOpsForColumn(col).map((o) => o.value);
const op = ops.includes(newRule.op) ? newRule.op : ops[0];
const value = (op === 'is_true' || op === 'is_false') ? '' : newRule.value;
setColumnFilters((prev) => [...prev, { id: `rule-${Date.now()}`, colId: newRule.colId, op, value }]);
setNewRule({ colId: '', op: 'contains', value: '' });
setShowFilterModal(false);
};
const toggleRowDensity = () => {
const next = rowDensity === 'compact' ? 'expanded' : 'compact';
setRowDensity(next);
setViews((prev) => prev.map((v) => (
v.id === activeView
? { ...v, rowDensity: next }
: v
)));
};
const renderCellValue = (row, col) => {
const value = row[col.id];
const compactPreview = (text) => (
<span className="compact-cell-preview" title={String(text || '')}>{String(text || '')}</span>
);
if (col.id === 'total_invested') {
if (col.formula) {
const computed = evaluateFormulaCell(row, col);
return formatCurrencyLong(parseNumericInput(computed));
}
return formatCurrencyLong(computeRollup(row));
}
if (col.type === 'formula') {
const computed = evaluateFormulaCell(row, col);
if (typeof computed === 'number' && Number.isFinite(computed)) return computed.toLocaleString();
return String(computed ?? '');
}
if (col.id === 'pipeline') {
if (row.pipeline) {
return (
<button
type="button"
className="button-secondary"
style={{ padding: '5px 10px', fontSize: '12px', borderColor: '#2f6f4f', color: '#7fd3a3' }}
title="In the deal pipeline — click to remove (archives the deal; grid row stays)"
onClick={(e) => { e.stopPropagation(); removeFromPipeline(row); }}
>
In pipeline
</button>
);
}
return (
<button
type="button"
className="button-secondary"
style={{ padding: '5px 10px', fontSize: '12px' }}
title="Add this investor to the deal pipeline"
onClick={(e) => { e.stopPropagation(); openCreateOpportunityModal(row); }}
>
+ Pipeline
</button>
);
}
if (col.id === 'pipeline_stage') {
const stage = String(row.pipeline_stage || '');
if (!stage) return <span style={{ color: '#70859b' }}></span>;
return <span style={{ textTransform: 'capitalize' }}>{stage.replace(/_/g, ' ')}</span>;
}
if (col.type === 'action' || col.id === 'log_action') {
return (
<button
type="button"
className="button-secondary"
style={{ padding: '5px 10px', fontSize: '12px' }}
onClick={(e) => {
e.stopPropagation();
openLogCommunicationModal(row, null);
}}
>
Log
</button>
);
}
if (col.type === 'checkbox') return value ? '✓' : '';
if (col.type === 'currency') return value ? formatCurrencyLong(value) : '$0';
if (col.type === 'contacts') {
const contacts = Array.isArray(value) ? value : [];
if (contacts.length === 0) return <span style={{ color: '#70859b' }}>+ Add contacts</span>;
if (rowDensity === 'compact') {
return (
<div style={{ display: 'flex', gap: '6px', flexWrap: 'nowrap', overflow: 'hidden', maxWidth: '100%' }}>
{contacts.map((c, i) => (
<button
key={i}
type="button"
onClick={(e) => {
e.stopPropagation();
openContactCardModal(row, c);
}}
style={{
background: '#263548',
border: '1px solid #32485f',
color: '#c7d3e0',
borderRadius: '10px',
padding: '2px 8px',
fontSize: '12px',
cursor: 'pointer',
flex: '0 0 auto',
maxWidth: '180px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
title={c.name || 'Unnamed'}
>
{c.name || 'Unnamed'}
</button>
))}
</div>
);
}
return (
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{contacts.map((c, i) => (
<button
key={i}
type="button"
onClick={(e) => {
e.stopPropagation();
openContactCardModal(row, c);
}}
style={{ background: '#263548', border: '1px solid #32485f', color: '#c7d3e0', borderRadius: '10px', padding: '2px 8px', fontSize: '12px', cursor: 'pointer' }}
title="View contact details"
>
{c.name || 'Unnamed'}
</button>
))}
</div>
);
}
if (rowDensity === 'compact' && (col.type === 'longtext' || col.type === 'text' || col.type === 'date' || col.type === 'select')) {
return compactPreview(value || '');
}
return value || '';
};
const renderEditor = (row, col, rowIndex, colIndex) => {
const value = row[col.id];
const placeCaretAtEnd = (el) => {
if (!el) return;
window.requestAnimationFrame(() => {
try {
const text = String(el.value ?? '');
const end = text.length;
el.setSelectionRange(end, end);
} catch (_) {
// Ignore unsupported input types (e.g. date/select).
}
});
};
if (col.id === 'total_invested' || col.readOnly) return null;
if (col.type === 'action') return null;
if (col.type === 'checkbox') {
return (
<input
autoFocus
type="checkbox"
checked={!!value}
onChange={(e) => updateCell(row.id, col.id, e.target.checked)}
onBlur={() => setEditing(null)}
onKeyDown={(e) => {
if (e.key === 'Tab' || e.key === 'Enter') {
e.preventDefault();
setEditing(null);
moveByKey(rowIndex, colIndex, e.key, e.shiftKey);
}
}}
/>
);
}
if (col.type === 'contacts') {
const contacts = Array.isArray(value) ? [...value] : [];
const updateContacts = (next) => updateCell(row.id, col.id, next);
return (
<div style={{ minWidth: '480px', background: '#0b1118', border: '1px solid #263548', borderRadius: '8px', padding: '8px' }}>
{contacts.map((c, idx) => (
<div key={idx} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr auto', gap: '6px', marginBottom: '6px' }}>
<input className="text-input" placeholder="Name" value={c.name || ''} onChange={(e) => {
const next = [...contacts];
next[idx] = { ...next[idx], name: e.target.value };
updateContacts(next);
}} onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
setEditing(null);
moveByKey(rowIndex, colIndex, 'Enter');
}
}} />
<input className="text-input" placeholder="Email" value={c.email || ''} onChange={(e) => {
const next = [...contacts];
next[idx] = { ...next[idx], email: e.target.value };
updateContacts(next);
}} onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
setEditing(null);
moveByKey(rowIndex, colIndex, 'Enter');
}
}} />
<input className="text-input" placeholder="Title (optional)" value={c.title || ''} onChange={(e) => {
const next = [...contacts];
next[idx] = { ...next[idx], title: e.target.value };
updateContacts(next);
}} onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
setEditing(null);
moveByKey(rowIndex, colIndex, 'Enter');
}
}} />
<input className="text-input" placeholder="LinkedIn URL" value={c.linkedin_url || ''} onChange={(e) => {
const next = [...contacts];
next[idx] = { ...next[idx], linkedin_url: e.target.value };
updateContacts(next);
}} onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
setEditing(null);
moveByKey(rowIndex, colIndex, 'Enter');
}
}} />
<input
className="text-input"
list="location-suggestions"
placeholder="City / Location"
value={c.location_query || ''}
onChange={(e) => {
const query = e.target.value;
const matched = applyLocationAutoFill(query);
const next = [...contacts];
next[idx] = {
...next[idx],
location_query: query,
city: matched ? matched.city : next[idx].city || '',
state: matched ? matched.state : next[idx].state || '',
country: matched ? matched.country : next[idx].country || ''
};
updateContacts(next);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
setEditing(null);
moveByKey(rowIndex, colIndex, 'Enter');
}
}}
/>
<button className="button-danger" type="button" onClick={() => {
const next = contacts.filter((_, i) => i !== idx);
updateContacts(next);
}}>×</button>
<div style={{ gridColumn: '1 / span 6', fontSize: '11px', color: '#8ea2b7' }}>
{c.city || c.state || c.country ? `${c.city || '-'}, ${c.state || '-'}, ${c.country || '-'}` : 'No location set'}
</div>
</div>
))}
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<button type="button" className="button-secondary" onClick={() => updateContacts([...contacts, { name: '', email: '', title: '', linkedin_url: '', city: '', state: '', country: '', location_query: '' }])}>+ Contact</button>
<button type="button" onClick={() => { setEditing(null); moveFocus(rowIndex + 1, colIndex); }}>Done</button>
</div>
<datalist id="location-suggestions">
{locationCatalog.map((loc) => <option key={loc.label} value={loc.label} />)}
</datalist>
</div>
);
}
if (col.type === 'select') {
const options = col.options || [];
return (
<select
autoFocus
className="select-input"
value={value || ''}
onChange={(e) => updateCell(row.id, col.id, e.target.value)}
onBlur={() => setEditing(null)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
setEditing(null);
moveByKey(rowIndex, colIndex, e.key, e.shiftKey);
}
}}
>
<option value="">--</option>
{options.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
);
}
if (col.type === 'longtext') {
return (
<textarea
autoFocus
className="text-input"
rows="3"
value={value || ''}
onFocus={(e) => placeCaretAtEnd(e.target)}
onChange={(e) => updateCell(row.id, col.id, e.target.value)}
onBlur={() => setEditing(null)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
setEditing(null);
moveByKey(rowIndex, colIndex, 'Enter');
} else if (e.key === 'Tab') {
e.preventDefault();
setEditing(null);
moveByKey(rowIndex, colIndex, 'Tab', e.shiftKey);
}
}}
style={{ width: '100%', minWidth: 0, display: 'block', resize: 'vertical' }}
/>
);
}
const inputType = col.type === 'date' ? 'date' : 'text';
const inputMode = col.type === 'currency' || col.type === 'number' ? 'decimal' : 'text';
const displayValue = col.type === 'currency' || col.type === 'number' ? String(value ?? '') : (value ?? '');
return (
<input
autoFocus
type={inputType}
inputMode={inputMode}
className="text-input"
value={displayValue}
onFocus={(e) => {
if (inputType === 'text') placeCaretAtEnd(e.target);
}}
onChange={(e) => {
const raw = e.target.value;
if (col.type === 'currency' || col.type === 'number') updateCell(row.id, col.id, raw);
else updateCell(row.id, col.id, raw);
}}
onBlur={() => {
if (col.type === 'currency' || col.type === 'number') updateCell(row.id, col.id, parseNumericInput(row[col.id]));
setEditing(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
if (col.type === 'currency' || col.type === 'number') updateCell(row.id, col.id, parseNumericInput(row[col.id]));
setEditing(null);
moveByKey(rowIndex, colIndex, e.key, e.shiftKey);
}
}}
style={{ MozAppearance: 'textfield', width: '100%', minWidth: 0, display: 'block' }}
/>
);
};
const activeCollaborators = useMemo(() => {
const all = Array.isArray(collabPresence) ? collabPresence : [];
return all.filter((p) => p && p.user_id && p.user_id !== user?.id);
}, [collabPresence, user?.id]);
return (
<div className="page-container" style={{ maxWidth: '100%' }}>
<div className="section" style={{ overflow: 'hidden' }}>
<div className="controls">
<input className="search-input" placeholder="Filter investors, contacts, notes..." value={quickSearch} onChange={(e) => setQuickSearch(e.target.value)} />
<select className="select-input" value={filters.lead} onChange={(e) => setFilters((f) => ({ ...f, lead: e.target.value }))}>
<option value="">All Leads</option>
{teamMembers.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
<button onClick={addRow}>+ Investor</button>
<button className="button-secondary" onClick={() => setShowViewModal(true)}>+ Save View</button>
<button className="button-secondary" onClick={toggleRowDensity}>{rowDensity === 'compact' ? 'Expand Rows' : 'Compact Rows'}</button>
<button className="button-secondary" onClick={() => setShowColumnVisibilityModal(true)}>Columns</button>
<button className="button-secondary" onClick={() => setShowFilterModal(true)}>Filters</button>
<button className="button-secondary" onClick={() => openAddColumnModal(null)}>+ Column</button>
<button className="button-secondary" onClick={() => setImportDebugOpen((v) => !v)}>{importDebugOpen ? 'Hide Debug' : 'Show Debug'}</button>
</div>
{importDebugOpen && (
<div style={{ margin: '6px 0 10px', border: '1px solid #34506a', borderRadius: '8px', padding: '10px', background: '#0e1725' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<strong style={{ fontSize: '12px' }}>Import Debug</strong>
<div style={{ display: 'flex', gap: '8px' }}>
<button type="button" className="button-secondary" onClick={() => loadImportDebugSnapshot()}>Refresh Server Snapshot</button>
<button type="button" className="button-secondary" onClick={() => setImportDebugEvents([])}>Clear Log</button>
</div>
</div>
<div style={{ fontSize: '12px', color: '#c7d3e0', marginBottom: '8px' }}>
Local rows: {rows.length} · Displayed rows: {displayedRows.length} · Active view: {activeView} · Remote version: {Number.isFinite(remoteVersion) ? remoteVersion : 'n/a'} · Hydrated: {stateHydrated ? 'yes' : 'no'}
</div>
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '8px' }}>
Filters: includeGraveyard={String(!!filters.includeGraveyard)} graveyardOnly={String(!!filters.graveyardOnly)} followUpOnly={String(!!filters.followUpOnly)} lead={filters.lead || '-'} quickSearch="{quickSearch}"
</div>
{importDebugServerSnapshot && (
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '8px' }}>
Server snapshot ({importDebugServerSnapshot.fetched_at}): grid rows={importDebugServerSnapshot.server_rows_count}, relational investors={importDebugServerSnapshot.relational_investors_count}, version={importDebugServerSnapshot.server_version}
</div>
)}
<div style={{ maxHeight: '170px', overflowY: 'auto', borderTop: '1px solid #263548', paddingTop: '8px', fontSize: '11px', color: '#b7c6d8' }}>
{importDebugEvents.length === 0 && <div>No events yet.</div>}
{importDebugEvents.map((evt, idx) => (
<div key={`${evt.ts}-${idx}`} style={{ marginBottom: '4px', whiteSpace: 'pre-wrap' }}>
[{evt.ts}] {evt.kind}: {JSON.stringify(evt.payload)}
</div>
))}
</div>
</div>
)}
{activeCollaborators.length > 0 && (
<div style={{ margin: '4px 0 10px', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{activeCollaborators.map((p) => {
const rowLabel = rows.find((r) => r.id === p.row_id)?.investor_name || '';
const colLabel = columns.find((c) => c.id === p.col_id)?.label || '';
const name = p.full_name || p.username || 'User';
const detail = p.is_editing ? `editing ${rowLabel || 'row'} ${colLabel ? `${colLabel}` : ''}` : 'viewing';
return (
<span key={`${p.user_id}-${p.last_seen_at}`} className="badge badge-other" title={detail}>
{name}: {detail}
</span>
);
})}
</div>
)}
<div className="grid-table-shell" onContextMenu={(e) => openGridContextMenu(e, { area: 'table' })}>
<table ref={gridTableRef} className={`table fundraising-table ${rowDensity === 'compact' ? 'compact-rows' : ''}`} style={{ minWidth: '1600px' }}>
<thead>
<tr>
{visibleColumns.map((col, colIndex) => (
<th
key={col.id}
className={`${draggingColId === col.id ? 'column-dragging' : ''} ${dragOverColId === col.id ? 'column-drag-over' : ''} ${col.id === 'investor_name' ? 'sticky-col' : ''}`.trim()}
draggable={resizing === null && col.id !== 'investor_name'}
onDragStart={(e) => {
if (resizing !== null) return;
if (col.id === 'investor_name') return;
setDraggingColId(col.id);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', col.id);
}}
onDragOver={(e) => {
e.preventDefault();
if (col.id === 'investor_name') return;
if (!draggingColId || draggingColId === col.id) return;
setDragOverColId(col.id);
e.dataTransfer.dropEffect = 'move';
}}
onDrop={(e) => {
e.preventDefault();
if (col.id === 'investor_name') return;
const droppedColId = e.dataTransfer.getData('text/plain') || draggingColId;
moveColumnBefore(droppedColId, col.id);
setDragOverColId(null);
setDraggingColId(null);
}}
onDragEnd={() => {
setDragOverColId(null);
setDraggingColId(null);
}}
onContextMenu={(e) => openGridContextMenu(e, { area: 'header', colId: col.id, colIndex })}
onClick={() => {
if (suppressNextHeaderSortRef.current) {
return;
}
if (resizing !== null) return;
setSortState((prev) => {
if (prev.colId !== col.id) return { colId: col.id, dir: 'asc' };
return { colId: col.id, dir: prev.dir === 'asc' ? 'desc' : 'asc' };
});
}}
style={{ minWidth: `${col.width || 140}px`, width: `${col.width || 140}px` }}
>
<div className="column-header-inner">
<span className="column-drag-indicator" title="Drag to reorder"></span>
{col.label}
{sortState.colId === col.id && (
<span style={{ marginLeft: '6px', fontSize: '11px', color: '#8ea2b7' }}>
{sortState.dir === 'asc' ? '▲' : '▼'}
</span>
)}
<span className="column-resize-handle" onMouseDown={(e) => startColumnResize(e, col)} />
</div>
</th>
))}
</tr>
</thead>
<tbody>
{displayedRows.map((row, rowIndex) => (
<tr key={row.id}>
{visibleColumns.map((col, colIndex) => {
const isEditing = editing && editing.rowId === row.id && editing.colId === col.id;
const isSelected = selectedCell && selectedCell.rowId === row.id && selectedCell.colId === col.id;
const activeLock = getCellLockByOther(row.id, col.id);
const isLockedByOther = !!activeLock;
const lockTitle = isLockedByOther
? `Locked by ${activeLock.locked_by_full_name || activeLock.locked_by_username || 'another user'}`
: '';
return (
<td
key={col.id}
tabIndex={cellTabIndex(rowIndex, colIndex)}
data-cell-key={`r${rowIndex}-c${colIndex}`}
className={`${isSelected ? 'grid-cell-selected' : ''} ${col.id === 'investor_name' ? 'sticky-col' : ''}`.trim()}
title={lockTitle}
onContextMenu={(e) => openGridContextMenu(e, { area: 'cell', rowId: row.id, colId: col.id, colIndex })}
onClick={() => {
setSelectedCell({ rowId: row.id, colId: col.id });
if (col.type === 'checkbox' && !col.readOnly) {
if (isLockedByOther) {
onShowToast(lockTitle, 'error');
return;
}
updateCell(row.id, col.id, !Boolean(row[col.id]));
}
}}
onDoubleClick={async () => {
if (isLockedByOther) {
onShowToast(lockTitle, 'error');
return;
}
await tryBeginCellEdit(row, col);
}}
onKeyDown={async (e) => {
if (isEditing) return;
if (col.type === 'action' && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
openLogCommunicationModal(row, null);
return;
}
if (e.key === 'F2' && !col.readOnly) {
e.preventDefault();
if (isLockedByOther) {
onShowToast(lockTitle, 'error');
return;
}
await tryBeginCellEdit(row, col);
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
setSelectedCell({ rowId: row.id, colId: col.id });
moveByKey(rowIndex, colIndex, e.key, e.shiftKey);
}
}}
style={{
verticalAlign: 'top',
cursor: col.readOnly ? 'default' : (isLockedByOther ? 'not-allowed' : 'cell'),
boxShadow: isLockedByOther ? 'inset 0 0 0 1px #8f4b4b' : undefined,
background: isLockedByOther ? 'rgba(143, 75, 75, 0.12)' : undefined,
minWidth: `${col.width || 140}px`,
width: `${col.width || 140}px`
}}
>
{isEditing ? renderEditor(row, col, rowIndex, colIndex) : (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '6px' }}>
<span style={{ flex: 1, minWidth: 0 }}>{renderCellValue(row, col)}</span>
{isLockedByOther && <span style={{ fontSize: '10px', color: '#d38f8f' }}>LOCKED</span>}
</div>
)}
</td>
);
})}
</tr>
))}
</tbody>
<tfoot>
<tr>
{visibleColumns.map((col, colIndex) => {
const numeric = isNumericColumn(col);
const agg = numeric ? getAggregateForColumn(col) : null;
const footerValue = numeric ? formatAggregateValue(col, agg.value) : '';
return (
<td
key={`footer-${col.id}`}
className={col.id === 'investor_name' ? 'sticky-col' : ''}
style={{ verticalAlign: 'top', minWidth: `${col.width || 140}px`, width: `${col.width || 140}px` }}
>
{colIndex === 0 ? (
<span style={{ color: '#8ea2b7', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Summary</span>
) : null}
{numeric && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ fontSize: '12px', color: '#c7d3e0', minHeight: '16px' }}>
{agg.op === 'none' ? '' : `${agg.label} ${footerValue}`}
</div>
<select
className="select-input"
style={{ fontSize: '11px', minWidth: '110px', padding: '4px 6px', height: '28px' }}
value={agg.op}
onChange={(e) => {
const op = e.target.value;
setFooterAggs((prev) => ({ ...prev, [col.id]: op }));
setViews((prev) => prev.map((v) => (v.id === activeView ? { ...v, footerAggs: { ...(v.footerAggs || {}), [col.id]: op } } : v)));
}}
>
{FOOTER_AGGREGATE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
)}
</td>
);
})}
</tr>
</tfoot>
</table>
</div>
{displayedRows.length === 0 && rows.length > 0 && (
<div style={{ marginTop: '10px', fontSize: '12px', color: '#8ea2b7', display: 'flex', gap: '10px', alignItems: 'center', flexWrap: 'wrap' }}>
<span>{rows.length} row(s) exist but are hidden by current view/search/filter settings.</span>
<button
type="button"
className="button-secondary"
onClick={() => {
setActiveView('view-all');
setFilters({ includeGraveyard: true, graveyardOnly: false, followUpOnly: false, lead: '' });
setQuickSearch('');
setColumnFilters([]);
setHiddenColumns([]);
}}
>
Show All Rows
</button>
</div>
)}
</div>
{contextMenu && (
<div className="context-menu" style={{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }} onClick={(e) => e.stopPropagation()}>
<button className="context-menu-item" onClick={() => handleContextAction('add-investor-top')}>Add Investor Row</button>
{contextMenu.rowId && (
<button className="context-menu-item" onClick={() => handleContextAction('add-investor-below')}>Add Investor Below</button>
)}
{contextMenu.rowId && (
<button className="context-menu-item" onClick={() => handleContextAction('log-communication')}>Log Communication</button>
)}
{contextMenu.rowId && (
<button className="context-menu-item" onClick={() => handleContextAction('create-opportunity')}>Create Pipeline Opportunity</button>
)}
{contextMenu.rowId && (
<button className="context-menu-item context-menu-danger" onClick={() => handleContextAction('delete-row')}>Delete Investor Row</button>
)}
<div className="context-menu-sep" />
{typeof contextMenu.colIndex === 'number' && (
<>
<button className="context-menu-item" onClick={() => handleContextAction('insert-col-left')}>Insert Column Left</button>
<button className="context-menu-item" onClick={() => handleContextAction('insert-col-right')}>Insert Column Right</button>
<button className="context-menu-item" onClick={() => handleContextAction('edit-column')}>Column Settings</button>
</>
)}
<button className="context-menu-item" onClick={() => handleContextAction('add-column-end')}>Add Column</button>
<button className="context-menu-item" onClick={() => handleContextAction('manage-columns')}>Show / Hide Columns</button>
<button className="context-menu-item" onClick={() => handleContextAction('add-filter')}>Add Filter Rule</button>
<div className="context-menu-sep" />
<button className="context-menu-item" onClick={() => handleContextAction('save-view')}>Save Current View</button>
</div>
)}
{(columnFilters.length > 0 || hiddenColumns.length > 0) && (
<div className="section" style={{ marginTop: '12px' }}>
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
{hiddenColumns.length > 0 && (
<span className="badge badge-other">Hidden columns: {hiddenColumns.length}</span>
)}
{columnFilters.map((rule) => {
const col = getColumnById(rule.colId);
if (!col) return null;
return (
<span key={rule.id} className="badge badge-prospect" style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
{col.label} {rule.op} {rule.value || ''}
<button
type="button"
style={{ border: 'none', background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
onClick={() => setColumnFilters((prev) => prev.filter((r) => r.id !== rule.id))}
>
×
</button>
</span>
);
})}
</div>
</div>
)}
{showColumnVisibilityModal && (
<div className="modal-overlay">
<div className="modal">
<div className="modal-header">Show / Hide Columns</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', maxHeight: '55vh', overflowY: 'auto' }}>
{columns.map((col) => (
<label key={col.id} className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px', justifyContent: 'space-between' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={!hiddenColumns.includes(col.id)}
onChange={(e) => {
const checked = e.target.checked;
setHiddenColumns((prev) => {
if (checked) return prev.filter((id) => id !== col.id);
if (col.id === 'investor_name') return prev;
return [...prev, col.id];
});
}}
/>
{col.label}
{col.id === 'investor_name' && <span style={{ color: '#70859b', fontSize: '12px' }}>(always visible)</span>}
</span>
<button type="button" className="button-secondary" style={{ padding: '4px 8px', fontSize: '11px' }} onClick={() => openEditColumnModal(col.id)}>Edit</button>
</label>
))}
</div>
<div className="form-actions">
<button type="button" className="button-secondary" onClick={() => setHiddenColumns([])}>Show all</button>
<button type="button" onClick={() => setShowColumnVisibilityModal(false)}>Done</button>
</div>
</div>
</div>
)}
{showFilterModal && (
<div className="modal-overlay">
<div className="modal">
<div className="modal-header">Add Filter Rule</div>
<div className="form-group">
<label className="form-label">Column</label>
<select
className="select-input"
value={newRule.colId}
onChange={(e) => {
const colId = e.target.value;
const ops = getRuleOpsForColumn(getColumnById(colId));
setNewRule({ colId, op: ops[0]?.value || 'contains', value: '' });
}}
>
<option value="">Select column</option>
{columns.map((col) => <option key={col.id} value={col.id}>{col.label}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Condition</label>
<select
className="select-input"
value={newRule.op}
onChange={(e) => setNewRule((prev) => ({ ...prev, op: e.target.value }))}
>
{getRuleOpsForColumn(getColumnById(newRule.colId)).map((op) => (
<option key={op.value} value={op.value}>{op.label}</option>
))}
</select>
</div>
{!['is_true', 'is_false'].includes(newRule.op) && (
<div className="form-group">
<label className="form-label">Value</label>
<input
className="text-input"
type={getColumnById(newRule.colId)?.type === 'date' ? 'date' : 'text'}
value={newRule.value}
onChange={(e) => setNewRule((prev) => ({ ...prev, value: e.target.value }))}
/>
</div>
)}
<div className="form-actions">
<button type="button" className="button-secondary" onClick={() => setShowFilterModal(false)}>Cancel</button>
<button type="button" onClick={addFilterRule}>Add Rule</button>
</div>
</div>
</div>
)}
{showColumnModal && (
<div className="modal-overlay">
<div className="modal">
<div className="modal-header">{columnModalMode === 'edit' ? 'Column Settings' : 'Add New Column'}</div>
<div className="form-group">
<label className="form-label">Column name</label>
<input className="text-input" value={newColumn.label} onChange={(e) => setNewColumn((c) => ({ ...c, label: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">Data type</label>
<select
className="select-input"
value={newColumn.type}
disabled={editingColumnId === 'total_invested'}
onChange={(e) => setNewColumn((c) => ({ ...c, type: e.target.value }))}
>
<option value="text">Single line text</option>
<option value="longtext">Long text</option>
<option value="number">Number</option>
<option value="currency">Currency</option>
<option value="rollup">Rollup (computed)</option>
<option value="date">Date</option>
<option value="checkbox">Checkbox</option>
<option value="select">Single select</option>
<option value="formula">Formula</option>
</select>
<div style={{ fontSize: '12px', color: '#8ea2b7', marginTop: '6px' }}>
{newColumn.type === 'text' && 'Short text values (names, sources, labels).'}
{newColumn.type === 'longtext' && 'Multi-line notes or communication history.'}
{newColumn.type === 'number' && 'Numeric values (no currency formatting).'}
{newColumn.type === 'currency' && 'Currency values; optional inclusion in Total invested rollup.'}
{(newColumn.type === 'rollup' || editingColumnId === 'total_invested') && 'Rollup column computed from other numeric columns.'}
{newColumn.type === 'date' && 'Calendar date values.'}
{newColumn.type === 'checkbox' && 'True/false flag for quick filtering and views.'}
{newColumn.type === 'select' && 'One option from a custom list.'}
{newColumn.type === 'formula' && 'Read-only computed values. Use {Column Name} or {column_id} with IF, AND, OR, NOT, MIN, MAX, ABS, ROUND, SUM, COALESCE, CONCAT.'}
</div>
</div>
{newColumn.type === 'select' && (
<div className="form-group">
<label className="form-label">Options (comma separated)</label>
<input className="text-input" value={newColumn.options} onChange={(e) => setNewColumn((c) => ({ ...c, options: e.target.value }))} />
</div>
)}
{(newColumn.type === 'formula' || editingColumnId === 'total_invested') && (
<div className="form-group">
<label className="form-label">Formula</label>
<textarea
className="text-input"
rows="4"
value={newColumn.formula}
onChange={(e) => setNewColumn((c) => ({ ...c, formula: e.target.value }))}
placeholder={'Examples:\n{Fund I} + {Fund II}\nIF({Priority}, "Hot", "Normal")\nIF(AND({Follow up}, !{Graveyard}), "Act", "Wait")'}
/>
<div style={{ fontSize: '12px', color: '#8ea2b7' }}>
Supported: `{`field`}`, `IF(condition,a,b)`, `AND(...)`, `OR(...)`, `+ - * /`, `&` for text concat.
</div>
</div>
)}
{newColumn.type === 'currency' && editingColumnId !== 'total_invested' && (
<div className="form-group">
<label className="form-label"><input type="checkbox" checked={newColumn.isFund} onChange={(e) => setNewColumn((c) => ({ ...c, isFund: e.target.checked }))} /> Include in Total invested rollup</label>
</div>
)}
<div className="form-actions">
<button type="button" className="button-secondary" onClick={() => { setShowColumnModal(false); setColumnModalMode('add'); setEditingColumnId(null); }}>Cancel</button>
<button type="button" onClick={saveColumnModal}>{columnModalMode === 'edit' ? 'Save Changes' : 'Add Column'}</button>
</div>
</div>
</div>
)}
{showViewModal && (
<div className="modal-overlay">
<div className="modal">
<div className="modal-header">Save Current Filters as View</div>
<div className="form-group">
<label className="form-label">View name</label>
<input className="text-input" value={newViewName} onChange={(e) => setNewViewName(e.target.value)} />
</div>
<div className="form-actions">
<button type="button" className="button-secondary" onClick={() => setShowViewModal(false)}>Cancel</button>
<button type="button" onClick={saveCurrentFiltersAsView}>Save View</button>
</div>
</div>
</div>
)}
{showLogCommModal && (
<div className="modal-overlay">
<div className="modal">
<div className="modal-header">Log Communication</div>
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '12px' }}>
{logCommContext?.investorName || 'Investor'} {logCommContext?.contact?.name ? `· ${logCommContext.contact.name}` : ''}
</div>
<div className="form-help" style={{ marginBottom: '14px', padding: '10px', border: '1px solid #263548', borderRadius: '8px', background: '#0d1622' }}>
This saves a communication record to the shared timeline and updates this investor row. If enabled below, it also appends a one-line summary into <strong>Notes / Communication / Outreach</strong>.
</div>
<div className="form-group">
<label className="form-label">Type</label>
<select className="select-input" value={logCommForm.type} onChange={(e) => setLogCommForm((f) => ({ ...f, type: e.target.value }))}>
<option value="email">Email</option>
<option value="call">Call</option>
<option value="meeting">Meeting</option>
<option value="note">Note</option>
<option value="text">Text</option>
</select>
<div className="form-help">Category used in Communications timeline and filters.</div>
</div>
<div className="form-group">
<label className="form-label">Summary</label>
<input className="text-input" placeholder="Short summary (headline)" value={logCommForm.subject} onChange={(e) => setLogCommForm((f) => ({ ...f, subject: e.target.value }))} />
<div className="form-help">Headline summary shown in Communications. This is not an email subject line.</div>
</div>
<div className="form-group">
<label className="form-label">Details</label>
<textarea className="text-input" rows="4" placeholder="Full details and context" value={logCommForm.body} onChange={(e) => setLogCommForm((f) => ({ ...f, body: e.target.value }))} />
<div className="form-help">Long-form details kept in communications history.</div>
</div>
<div className="form-group">
<label className="form-label">Outcome</label>
<input className="text-input" value={logCommForm.outcome} onChange={(e) => setLogCommForm((f) => ({ ...f, outcome: e.target.value }))} />
<div className="form-help">Result or status after this interaction.</div>
</div>
<div className="form-group">
<label className="form-label">Next Action</label>
<input className="text-input" value={logCommForm.next_action} onChange={(e) => setLogCommForm((f) => ({ ...f, next_action: e.target.value }))} />
<div className="form-help">Specific follow-up task.</div>
</div>
<div className="form-group">
<label className="form-label">Next Action Date</label>
<input type="date" className="text-input" value={logCommForm.next_action_date} onChange={(e) => setLogCommForm((f) => ({ ...f, next_action_date: e.target.value }))} />
<div className="form-help">Target due date for the next step.</div>
</div>
<div className="form-group">
<label className="form-label">
<input type="checkbox" checked={!!logCommForm.append_note} onChange={(e) => setLogCommForm((f) => ({ ...f, append_note: e.target.checked }))} />
{' '}Append summary to Fundraising Grid notes
</label>
<div className="form-help">Adds one line to <strong>Notes / Communication / Outreach</strong>: date + type + contact + summary.</div>
</div>
<div className="form-actions">
<button type="button" className="button-secondary" onClick={() => setShowLogCommModal(false)}>Cancel</button>
<button type="button" onClick={submitLogCommunication} disabled={logCommSubmitting}>
{logCommSubmitting ? <Spinner /> : 'Log'}
</button>
</div>
</div>
</div>
)}
{showContactCardModal && (
<div className="modal-overlay">
<div className="modal">
<div className="modal-header">Contact Details</div>
{contactCardLoading ? (
<div style={{ padding: '4px 0 12px' }}><Spinner /></div>
) : (
<>
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '12px' }}>
{contactCardContext?.investorName || 'Investor'}
</div>
<div className="detail-row">
<span className="detail-label">Name</span>
<span className="detail-value">{contactCardContext?.contact?.name || '-'}</span>
</div>
<div className="detail-row">
<span className="detail-label">Email</span>
<span className="detail-value">{contactCardContext?.contact?.email || '-'}</span>
</div>
<div className="detail-row">
<span className="detail-label">Title</span>
<span className="detail-value">{contactCardContext?.contact?.title || '-'}</span>
</div>
<div className="detail-row">
<span className="detail-label">City</span>
<span className="detail-value">{contactCardContext?.contact?.city || '-'}</span>
</div>
<div className="detail-row">
<span className="detail-label">State</span>
<span className="detail-value">{contactCardContext?.contact?.state || '-'}</span>
</div>
<div className="detail-row">
<span className="detail-label">Country</span>
<span className="detail-value">{contactCardContext?.contact?.country || '-'}</span>
</div>
<div className="detail-row">
<span className="detail-label">Lead Source</span>
<span className="detail-value">{contactCardContext?.leadSource || '-'}</span>
</div>
<div className="detail-row">
<span className="detail-label">LinkedIn</span>
<span className="detail-value">{contactCardContext?.contact?.linkedin_url || '-'}</span>
</div>
</>
)}
<div className="form-actions">
<button type="button" className="button-secondary" onClick={() => setShowContactCardModal(false)}>Close</button>
</div>
</div>
</div>
)}
{showCreateOppModal && (
<div className="modal-overlay">
<div className="modal">
<div className="modal-header">Add to Pipeline</div>
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '12px' }}>
{createOppContext?.investorName || 'Investor'}
</div>
<div className="form-group">
<label className="form-label">Opportunity Name</label>
<input className="text-input" value={createOppForm.name} onChange={(e) => setCreateOppForm((f) => ({ ...f, name: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">Contact</label>
<select
className="select-input"
value={String(createOppForm.contactIndex)}
onChange={(e) => setCreateOppForm((f) => ({ ...f, contactIndex: Number(e.target.value) || 0 }))}
>
{(createOppContext?.contacts || []).map((c, i) => (
<option key={`${c?.name || 'contact'}-${i}`} value={String(i)}>
{c?.name || 'Unnamed'}{c?.email ? ` (${c.email})` : ''}
</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">Stage</label>
<select className="select-input" value={createOppForm.stage} onChange={(e) => setCreateOppForm((f) => ({ ...f, stage: e.target.value }))}>
<option value="lead">Lead</option>
<option value="outreach">Outreach</option>
<option value="meeting">Meeting</option>
<option value="due_diligence">Due Diligence</option>
<option value="committed">Committed</option>
<option value="funded">Funded</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Expected Amount</label>
<input className="text-input" inputMode="decimal" value={createOppForm.expected_amount} onChange={(e) => setCreateOppForm((f) => ({ ...f, expected_amount: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">Probability (%)</label>
<input type="number" min="0" max="100" className="text-input" value={createOppForm.probability} onChange={(e) => setCreateOppForm((f) => ({ ...f, probability: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">Fund (optional)</label>
<select className="select-input" value={createOppForm.fund_name} onChange={(e) => setCreateOppForm((f) => ({ ...f, fund_name: e.target.value }))}>
<option value=""> None </option>
{columns.filter((c) => c.isFund).map((c) => (
<option key={c.id} value={c.label}>{c.label}</option>
))}
</select>
</div>
<div className="form-actions">
<button type="button" className="button-secondary" onClick={() => setShowCreateOppModal(false)}>Cancel</button>
<button type="button" onClick={submitCreateOpportunity} disabled={createOppSubmitting}>
{createOppSubmitting ? <Spinner /> : 'Add to Pipeline'}
</button>
</div>
</div>
</div>
)}
{showImportModal && (
<div className="modal-overlay">
<div className="modal" style={{ maxWidth: '760px' }}>
<div className="modal-header">Import Airtable CSV</div>
{importError && <div className="toast error" style={{ position: 'static', marginBottom: '12px' }}>{importError}</div>}
<div className="form-group">
<label className="form-label">Upload CSV export file</label>
<input type="file" accept=".csv,text/csv" onChange={handleCsvFileUpload} />
</div>
<div className="form-group">
<label className="form-label">Or paste CSV data</label>
<textarea
className="text-input"
rows="10"
value={importCsvText}
onChange={(e) => setImportCsvText(e.target.value)}
placeholder={'Investor Name,Contacts,Notes / Communication / Outreach,Priority,Follow up,Lead,Graveyard,Fund I,Fund II\nExample Investor,"Jane Doe, John Doe","Met on 3/1",true,false,JK,false,1000000,0'}
/>
</div>
<div className="form-group">
<label className="form-label">
<input
type="checkbox"
checked={createMissingColumns}
onChange={(e) => setCreateMissingColumns(e.target.checked)}
/>
{' '}Create missing columns automatically from CSV headers
</label>
</div>
<div className="form-actions">
<button type="button" className="button-secondary" onClick={() => { setShowImportModal(false); setImportError(''); }}>Cancel</button>
<button type="button" onClick={handleImportAirtableCsv}>Import</button>
</div>
</div>
</div>
)}
</div>
);
};
const InstructionsPage = () => {
return (
<div className="page-container">
<h2 className="section-title">Instructions</h2>
<div className="section">
<div className="section-title">Purpose</div>
<div style={{ color: '#c7d3e0', fontSize: '14px', lineHeight: 1.6 }}>
Use Fundraising Grid as the master list of investor relationships, then use Contacts, Communications, and Pipeline as deeper operating layers when a relationship becomes active.
</div>
</div>
<div className="section">
<div className="section-title">Daily Workflow</div>
<ol style={{ marginLeft: '20px', color: '#c7d3e0', lineHeight: 1.8 }}>
<li>Capture new leads in Fundraising Grid first.</li>
<li>Add or verify contacts on that row (name, email, title, location).</li>
<li>Set Lead owner and relevant flags (Priority, Follow up).</li>
<li>Log communications after each meaningful touchpoint.</li>
<li>Use Next Action and Next Action Date for commitments and reminders.</li>
</ol>
</div>
<div className="section">
<div className="section-title">How To Add New Leads</div>
<ol style={{ marginLeft: '20px', color: '#c7d3e0', lineHeight: 1.8 }}>
<li>Create a new row in Fundraising Grid.</li>
<li>Fill Investor Name and at least one contact.</li>
<li>Add context in Notes / Communication / Outreach.</li>
<li>Assign Lead and mark Priority only if truly high-attention.</li>
<li>Use Follow up for active near-term tracking views.</li>
</ol>
</div>
<div className="section">
<div className="section-title">Communication Logging Best Practices</div>
<ol style={{ marginLeft: '20px', color: '#c7d3e0', lineHeight: 1.8 }}>
<li>Log communication from Fundraising Grid via contact chip or row right-click.</li>
<li>Always set type and a concise subject/body.</li>
<li>Use Outcome for what happened; use Next Action for what will happen.</li>
<li>If you want timeline text in grid notes, keep Append note checked.</li>
<li>Use Communications page to audit and manage all logged interactions.</li>
</ol>
</div>
<div className="section">
<div className="section-title">Priority vs Pipeline</div>
<div style={{ color: '#c7d3e0', fontSize: '14px', lineHeight: 1.7 }}>
Priority is a relationship-level attention flag in the Fundraising Grid. Pipeline is for specific active opportunities with stage/probability/amount tracking. Keep Priority broad and Pipeline selective.
</div>
</div>
<div className="section">
<div className="section-title">When An Opportunity Is Concrete</div>
<ol style={{ marginLeft: '20px', color: '#c7d3e0', lineHeight: 1.8 }}>
<li>Right-click the investor row in Fundraising Grid.</li>
<li>Select <strong>Create Pipeline Opportunity</strong>.</li>
<li>Pick contact, stage, expected amount, and probability.</li>
<li>Track progress in Pipeline while keeping relationship notes in Fundraising Grid.</li>
<li>Continue logging communications so follow-ups and timelines stay current.</li>
</ol>
</div>
<div className="section">
<div className="section-title">Data Flow</div>
<ol style={{ marginLeft: '20px', color: '#c7d3e0', lineHeight: 1.8 }}>
<li>Fundraising Grid saves to a master fundraising state and relational fundraising tables.</li>
<li>Contacts in Fundraising Grid sync bi-directionally with the Contacts database.</li>
<li>Logging communication creates a communications record and updates fundraising row dates.</li>
<li>Notes Last Modified and Last Communication Date update automatically from activity.</li>
<li>Saved Views filter the same shared master dataset, they do not duplicate records.</li>
</ol>
</div>
</div>
);
};
const SettingsPage = ({ token, onShowToast, user, onOpenAirtableImport }) => {
const [loading, setLoading] = useState(true);
const [inviteForm, setInviteForm] = useState({
username: '',
full_name: '',
email: '',
password: '',
role: 'member'
});
const [inviteLoading, setInviteLoading] = useState(false);
const [backupLoading, setBackupLoading] = useState(false);
const [exportLoading, setExportLoading] = useState(false);
const [restoreLoading, setRestoreLoading] = useState(false);
const [previewLoading, setPreviewLoading] = useState(false);
const [lastBackupInfo, setLastBackupInfo] = useState(null);
const [restorePayload, setRestorePayload] = useState(null);
const [restoreFileName, setRestoreFileName] = useState('');
const [restorePreview, setRestorePreview] = useState(null);
const [backupHistory, setBackupHistory] = useState([]);
const [backupHistoryLoading, setBackupHistoryLoading] = useState(false);
const [users, setUsers] = useState([]);
const [usersLoading, setUsersLoading] = useState(false);
const [userActionLoadingId, setUserActionLoadingId] = useState(null);
const [testEmailLoading, setTestEmailLoading] = useState(false);
const [sendDigestLoading, setSendDigestLoading] = useState(false);
const [digestPolicy, setDigestPolicy] = useState({ enabled: false, send_hour: 18 });
const [digestPolicyLoading, setDigestPolicyLoading] = useState(false);
const [digestPolicySaving, setDigestPolicySaving] = useState(false);
// Manual run: window selector + in-panel preview before pushing to email.
const [digestWindowMode, setDigestWindowMode] = useState('24h'); // '24h' | 'since'
const [digestSince, setDigestSince] = useState(() =>
new Date(Date.now() - 30 * 864e5).toISOString().slice(0, 10));
const [digestPreview, setDigestPreview] = useState(null);
const [digestPreviewLoading, setDigestPreviewLoading] = useState(false);
const [auditLogs, setAuditLogs] = useState([]);
const [auditLoading, setAuditLoading] = useState(false);
const [automationRules, setAutomationRules] = useState([]);
const [automationRuns, setAutomationRuns] = useState([]);
const [automationsLoading, setAutomationsLoading] = useState(false);
const [activityFeed, setActivityFeed] = useState([]);
const [activityLoading, setActivityLoading] = useState(false);
const [backupVerifyLoading, setBackupVerifyLoading] = useState(false);
const [backupVerifyResult, setBackupVerifyResult] = useState(null);
const [securityStatus, setSecurityStatus] = useState(null);
const [securityLoading, setSecurityLoading] = useState(false);
const [auditFilters, setAuditFilters] = useState({
entity_type: '',
action: '',
user_id: '',
date_from: '',
date_to: '',
search: '',
limit: 100
});
const [backupPolicy, setBackupPolicy] = useState(null);
const [backupPolicyDraft, setBackupPolicyDraft] = useState({
enabled: true,
interval_hours: 24,
retention_days: 30,
max_backups: 60
});
const [backupPolicyLoading, setBackupPolicyLoading] = useState(false);
const [contactsCsvText, setContactsCsvText] = useState('');
const [contactsImportLoading, setContactsImportLoading] = useState(false);
const [contactsImportError, setContactsImportError] = useState('');
const [contactsUpdateExisting, setContactsUpdateExisting] = useState(true);
const [contactsImportPreview, setContactsImportPreview] = useState(null);
const [contactsImportOverrides, setContactsImportOverrides] = useState({});
const [resetAllDataConfirm, setResetAllDataConfirm] = useState('');
const [resetAllDataLoading, setResetAllDataLoading] = useState(false);
useEffect(() => {
setLoading(false);
}, [token, onShowToast]);
useEffect(() => {
if (user?.role !== 'admin') return;
const fetchUsers = async () => {
setUsersLoading(true);
try {
const result = await api('/api/users', {}, token);
setUsers(Array.isArray(result?.data) ? result.data : []);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to load users'), 'error');
} finally {
setUsersLoading(false);
}
};
fetchUsers();
}, [token, user?.role, onShowToast]);
const fetchBackupHistory = useCallback(async () => {
if (user?.role !== 'admin') return;
setBackupHistoryLoading(true);
try {
const result = await api('/api/fundraising/backups', {}, token);
setBackupHistory(Array.isArray(result?.data) ? result.data : []);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to load backup history'), 'error');
} finally {
setBackupHistoryLoading(false);
}
}, [token, user?.role, onShowToast]);
const fetchBackupPolicy = useCallback(async () => {
if (user?.role !== 'admin') return;
setBackupPolicyLoading(true);
try {
const result = await api('/api/fundraising/backup-policy', {}, token);
const policy = result?.data || null;
setBackupPolicy(policy);
if (policy) {
setBackupPolicyDraft({
enabled: Boolean(policy.enabled),
interval_hours: Number(policy.interval_hours) || 24,
retention_days: Number(policy.retention_days) || 30,
max_backups: Number(policy.max_backups) || 60
});
}
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to load backup policy'), 'error');
} finally {
setBackupPolicyLoading(false);
}
}, [token, user?.role, onShowToast]);
const fetchDigestPolicy = useCallback(async () => {
if (user?.role !== 'admin') return;
setDigestPolicyLoading(true);
try {
const result = await api('/api/admin/digest/policy', {}, token);
const p = result?.data;
if (p) setDigestPolicy({ enabled: Boolean(p.enabled), send_hour: Number(p.send_hour) || 18 });
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to load digest settings'), 'error');
} finally {
setDigestPolicyLoading(false);
}
}, [token, user?.role, onShowToast]);
const handleSaveDigestPolicy = async (patch) => {
setDigestPolicy((p) => ({ ...p, ...patch })); // optimistic
setDigestPolicySaving(true);
try {
const result = await api('/api/admin/digest/policy', {
method: 'PATCH',
body: JSON.stringify(patch)
}, token);
const p = result?.data;
if (p) setDigestPolicy({ enabled: Boolean(p.enabled), send_hour: Number(p.send_hour) || 18 });
onShowToast('Digest settings saved', 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to save digest settings'), 'error');
fetchDigestPolicy(); // revert to server truth
} finally {
setDigestPolicySaving(false);
}
};
const fetchAuditLogs = useCallback(async () => {
if (user?.role !== 'admin') return;
setAuditLoading(true);
try {
const params = new URLSearchParams();
Object.entries(auditFilters).forEach(([key, value]) => {
if (value === '' || value === null || value === undefined) return;
if (key === 'date_from' && String(value).length === 10) {
params.set(key, `${value}T00:00:00Z`);
return;
}
if (key === 'date_to' && String(value).length === 10) {
params.set(key, `${value}T23:59:59Z`);
return;
}
params.set(key, String(value));
});
const endpoint = `/api/audit-log?${params.toString()}`;
const result = await api(endpoint, {}, token);
setAuditLogs(Array.isArray(result?.data) ? result.data : []);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to load audit log'), 'error');
} finally {
setAuditLoading(false);
}
}, [token, user?.role, onShowToast, auditFilters]);
const fetchAutomations = useCallback(async () => {
if (user?.role !== 'admin') return;
setAutomationsLoading(true);
try {
const [rules, runs] = await Promise.all([
api('/api/fundraising/automations', {}, token),
api('/api/fundraising/automation-runs?limit=60', {}, token)
]);
setAutomationRules(Array.isArray(rules?.data) ? rules.data : []);
setAutomationRuns(Array.isArray(runs?.data) ? runs.data : []);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to load automations'), 'error');
} finally {
setAutomationsLoading(false);
}
}, [token, user?.role, onShowToast]);
const fetchActivityFeed = useCallback(async () => {
if (user?.role !== 'admin') return;
setActivityLoading(true);
try {
const result = await api('/api/fundraising/activity?limit=80', {}, token);
setActivityFeed(Array.isArray(result?.data) ? result.data : []);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to load activity feed'), 'error');
} finally {
setActivityLoading(false);
}
}, [token, user?.role, onShowToast]);
const fetchSecurityStatus = useCallback(async () => {
if (user?.role !== 'admin') return;
setSecurityLoading(true);
try {
const result = await api('/api/security/status', {}, token);
setSecurityStatus(result?.data || null);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to load security status'), 'error');
} finally {
setSecurityLoading(false);
}
}, [token, user?.role, onShowToast]);
useEffect(() => {
if (user?.role !== 'admin') return;
fetchBackupHistory();
fetchBackupPolicy();
fetchDigestPolicy();
fetchAuditLogs();
fetchAutomations();
fetchActivityFeed();
fetchSecurityStatus();
}, [user?.role, fetchBackupHistory, fetchBackupPolicy, fetchDigestPolicy, fetchAuditLogs, fetchAutomations, fetchActivityFeed, fetchSecurityStatus]);
const handleInviteUser = async (e) => {
e.preventDefault();
setInviteLoading(true);
try {
const result = await api('/api/admin/users', {
method: 'POST',
body: JSON.stringify(inviteForm)
}, token);
const created = result?.data;
onShowToast(`User invited: ${created?.username || inviteForm.username}`, 'success');
setInviteForm({ username: '', full_name: '', email: '', password: '', role: 'member' });
const usersResult = await api('/api/users', {}, token);
setUsers(Array.isArray(usersResult?.data) ? usersResult.data : []);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to invite user'), 'error');
} finally {
setInviteLoading(false);
}
};
const handleToggleUserActive = async (target) => {
setUserActionLoadingId(target.id);
try {
const result = await api(`/api/admin/users/${target.id}`, {
method: 'PATCH',
body: JSON.stringify({ is_active: !Boolean(target.is_active) })
}, token);
const updated = result?.data;
setUsers((prev) => prev.map((u) => (u.id === target.id ? { ...u, ...updated } : u)));
onShowToast(`${updated?.username || target.username} ${updated?.is_active ? 'activated' : 'deactivated'}`, 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to update user status'), 'error');
} finally {
setUserActionLoadingId(null);
}
};
const handleUpdateUserRole = async (target, role) => {
setUserActionLoadingId(`${target.id}:role`);
try {
const result = await api(`/api/admin/users/${target.id}`, {
method: 'PATCH',
body: JSON.stringify({ role })
}, token);
const updated = result?.data;
setUsers((prev) => prev.map((u) => (u.id === target.id ? { ...u, ...updated } : u)));
onShowToast(`${updated?.username || target.username} role set to ${updated?.role || role}`, 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to update user role'), 'error');
} finally {
setUserActionLoadingId(null);
}
};
const handleResetUserPassword = async (target) => {
const nextPassword = window.prompt(`Set a new temporary password for ${target.username} (min 8 chars):`);
if (nextPassword === null) return;
const trimmed = String(nextPassword).trim();
if (trimmed.length < 8) {
onShowToast('Password must be at least 8 characters', 'error');
return;
}
setUserActionLoadingId(`${target.id}:password`);
try {
await api(`/api/admin/users/${target.id}`, {
method: 'PATCH',
body: JSON.stringify({ password: trimmed })
}, token);
onShowToast(`Password reset for ${target.username}`, 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to reset password'), 'error');
} finally {
setUserActionLoadingId(null);
}
};
const handleBackupFundraising = async () => {
setBackupLoading(true);
try {
const result = await api('/api/fundraising/backup', { method: 'POST' }, token);
const info = result?.data || null;
setLastBackupInfo(info);
onShowToast(`Backup created: ${info?.filename || 'ok'}`, 'success');
fetchBackupHistory();
fetchAuditLogs();
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to create backup'), 'error');
} finally {
setBackupLoading(false);
}
};
const handleSaveBackupPolicy = async () => {
setBackupPolicyLoading(true);
try {
const payload = {
enabled: Boolean(backupPolicyDraft.enabled),
interval_hours: Math.max(1, Number(backupPolicyDraft.interval_hours) || 24),
retention_days: Math.max(1, Number(backupPolicyDraft.retention_days) || 30),
max_backups: Math.max(1, Number(backupPolicyDraft.max_backups) || 60)
};
const result = await api('/api/fundraising/backup-policy', {
method: 'PATCH',
body: JSON.stringify(payload)
}, token);
setBackupPolicy(result?.data || payload);
onShowToast('Backup policy updated', 'success');
fetchBackupHistory();
fetchBackupPolicy();
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to update backup policy'), 'error');
} finally {
setBackupPolicyLoading(false);
}
};
const handleExportFundraising = async () => {
setExportLoading(true);
try {
const result = await api('/api/fundraising/export', {}, token);
const payload = result?.data || {};
const filename = `fundraising_state_export_${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
onShowToast('Fundraising export downloaded', 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to export fundraising state'), 'error');
} finally {
setExportLoading(false);
}
};
const handleRestoreFileSelected = async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const parsed = JSON.parse(text);
setRestorePayload(parsed);
setRestoreFileName(file.name);
setRestorePreview(null);
onShowToast(`Loaded restore file: ${file.name}`, 'success');
} catch (_) {
setRestorePayload(null);
setRestoreFileName('');
setRestorePreview(null);
onShowToast('Invalid JSON file', 'error');
}
};
const handlePreviewRestore = async () => {
if (!restorePayload) {
onShowToast('Select a restore JSON file first', 'error');
return;
}
setPreviewLoading(true);
try {
const result = await api('/api/fundraising/restore-preview', {
method: 'POST',
body: JSON.stringify({ payload: restorePayload })
}, token);
setRestorePreview(result?.data || null);
onShowToast('Restore preview ready', 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to preview restore'), 'error');
} finally {
setPreviewLoading(false);
}
};
const handleRestoreFundraising = async () => {
if (!restorePayload) {
onShowToast('Select a restore JSON file first', 'error');
return;
}
const confirmed = window.confirm('Restore fundraising state from file? This will overwrite current grid and views.');
if (!confirmed) return;
setRestoreLoading(true);
try {
const result = await api('/api/fundraising/restore', {
method: 'POST',
body: JSON.stringify({ payload: restorePayload })
}, token);
const info = result?.data?.pre_restore_backup;
onShowToast(`Restore complete${info?.filename ? ` (pre-backup: ${info.filename})` : ''}`, 'success');
fetchBackupHistory();
fetchAuditLogs();
setTimeout(() => window.location.reload(), 500);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to restore fundraising state'), 'error');
} finally {
setRestoreLoading(false);
}
};
const handleRestoreFromBackup = async (filename) => {
const confirmed = window.confirm(`Restore from backup file ${filename}? This will overwrite current grid and views.`);
if (!confirmed) return;
setRestoreLoading(true);
try {
const result = await api('/api/fundraising/restore', {
method: 'POST',
body: JSON.stringify({ filename })
}, token);
const info = result?.data?.pre_restore_backup;
onShowToast(`Restore complete${info?.filename ? ` (pre-backup: ${info.filename})` : ''}`, 'success');
fetchBackupHistory();
fetchAuditLogs();
setTimeout(() => window.location.reload(), 500);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to restore from backup'), 'error');
} finally {
setRestoreLoading(false);
}
};
const handleToggleAutomationRule = async (rule) => {
try {
await api(`/api/fundraising/automations/${rule.id}`, {
method: 'PATCH',
body: JSON.stringify({ enabled: !Boolean(rule.enabled) })
}, token);
onShowToast(`${rule.name} ${rule.enabled ? 'disabled' : 'enabled'}`, 'success');
fetchAutomations();
fetchActivityFeed();
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to update automation rule'), 'error');
}
};
const handleVerifyBackups = async () => {
setBackupVerifyLoading(true);
try {
const result = await api('/api/fundraising/backup-verify', { method: 'POST' }, token);
setBackupVerifyResult(result?.data || null);
onShowToast('Backup verification complete', 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to verify backups'), 'error');
} finally {
setBackupVerifyLoading(false);
}
};
const handleResetAllData = async () => {
const phrase = 'RESET ALL DATA';
if (resetAllDataConfirm.trim() !== phrase) {
onShowToast(`Type exactly "${phrase}" to continue`, 'error');
return;
}
const confirmed = window.confirm('This will permanently clear contacts, organizations, pipeline, communications, feature requests, and reset the fundraising grid. Continue?');
if (!confirmed) return;
setResetAllDataLoading(true);
try {
const result = await api('/api/admin/reset-all-data', {
method: 'POST',
body: JSON.stringify({ confirm_phrase: phrase })
}, token);
const pre = result?.data?.pre_backup?.filename;
onShowToast(`All data reset complete${pre ? ` (backup: ${pre})` : ''}`, 'success');
setResetAllDataConfirm('');
setTimeout(() => window.location.reload(), 800);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to reset all data'), 'error');
} finally {
setResetAllDataLoading(false);
}
};
const handleSendTestDigestEmail = async () => {
setTestEmailLoading(true);
try {
const result = await api('/api/admin/digest/test-email', {
method: 'POST',
body: JSON.stringify({})
}, token);
const to = (result?.data?.sent_to || []).join(', ');
onShowToast(`Test digest email sent${to ? ` to ${to}` : ''}`, 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to send test email — is SMTP configured? (Start9: Configure Digest SMTP, then restart)'), 'error');
} finally {
setTestEmailLoading(false);
}
};
// The selected manual-run window: a specific start date, or the last 24h.
const digestWindowBody = () =>
digestWindowMode === 'since' && digestSince ? { since: digestSince } : { hours: 24 };
const digestSummary = (d) => d.has_activity
? `${d.user_count} member(s), ${d.email_count} email(s), ${d.investor_count} investor(s)`
: 'no activity in this window';
const handlePreviewDigest = async () => {
setDigestPreviewLoading(true);
try {
const result = await api('/api/admin/digest/preview', {
method: 'POST',
body: JSON.stringify(digestWindowBody())
}, token);
setDigestPreview(result?.data || null);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to build preview — is Spark Control reachable?'), 'error');
} finally {
setDigestPreviewLoading(false);
}
};
const handleSendDigestNow = async () => {
setSendDigestLoading(true);
try {
const result = await api('/api/admin/digest/send-now', {
method: 'POST',
body: JSON.stringify(digestWindowBody())
}, token);
const d = result?.data || {};
const to = (d.recipients || []).join(', ');
onShowToast(`Digest sent (${digestSummary(d)})${to ? ` to ${to}` : ''}`, 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to send digest — is a transport (Gmail/DWD or SMTP) configured?'), 'error');
} finally {
setSendDigestLoading(false);
}
};
const handleContactsCsvFileUpload = async (event) => {
const file = event.target.files && event.target.files[0];
if (!file) return;
try {
const text = await file.text();
setContactsCsvText(text);
setContactsImportError('');
setContactsImportPreview(null);
setContactsImportOverrides({});
} catch (_) {
setContactsImportError('Could not read selected CSV file');
} finally {
event.target.value = '';
}
};
const buildContactsImportMapping = (headers) => {
const mapping = {};
headers.forEach((header) => {
const h = normalizeKey(header);
if (!h) return;
if (h === 'first name' || h.includes('first name')) mapping[header] = 'first_name';
else if (h === 'last name' || h.includes('last name')) mapping[header] = 'last_name';
else if (h === 'name' || h === 'combined' || h.includes('combined')) mapping[header] = 'name';
else if (h.includes('email')) mapping[header] = 'email';
else if (h.includes('title')) mapping[header] = 'title';
else if (h.includes('investor entity name') || h.includes('investor name') || h === 'organization' || h.includes('employer') || h.includes('company')) mapping[header] = 'organization';
else if (h.includes('linkedin')) mapping[header] = 'linkedin_url';
else if (h === 'city' || h === 'city location' || h === 'city/location' || h.includes('location')) mapping[header] = 'location';
else if (h === 'state') mapping[header] = 'state';
else if (h === 'country') mapping[header] = 'country';
else if (h.includes('lead source') || h === 'source') mapping[header] = 'source';
});
return mapping;
};
const runContactsImport = async ({ dryRun }) => {
setContactsImportError('');
const records = parseCsvRecords(contactsCsvText);
if (records.length === 0) {
setContactsImportError('No rows detected. Upload or paste a contacts CSV first.');
return null;
}
const headers = Object.keys(records[0] || {});
const mapping = buildContactsImportMapping(headers);
setContactsImportLoading(true);
try {
const result = await api('/api/import/csv', {
method: 'POST',
body: JSON.stringify({
entity_type: 'contacts',
data: records,
mapping,
dry_run: !!dryRun,
update_existing: !!contactsUpdateExisting,
action_overrides: contactsImportOverrides
})
}, token);
const stats = result?.data || {};
if (dryRun) {
setContactsImportPreview(stats);
const nextOverrides = {};
if (Array.isArray(stats.matches)) {
stats.matches.forEach((match) => {
const key = String(match.row || '');
if (!key) return;
const existingOverride = contactsImportOverrides[key];
if (existingOverride === 'update' || existingOverride === 'skip' || existingOverride === 'create_duplicate') {
nextOverrides[key] = existingOverride;
} else if (match.action === 'update' || match.action === 'skip' || match.action === 'create_duplicate') {
nextOverrides[key] = match.action;
} else if (match.default_action === 'update' || match.default_action === 'skip' || match.default_action === 'create_duplicate') {
nextOverrides[key] = match.default_action;
} else {
nextOverrides[key] = contactsUpdateExisting ? 'update' : 'skip';
}
});
}
setContactsImportOverrides(nextOverrides);
} else {
setContactsImportPreview(null);
setContactsImportOverrides({});
}
onShowToast(`${dryRun ? 'Dry run' : 'Contacts import'}: ${stats.created || 0} created, ${stats.updated || 0} updated, ${stats.skipped || 0} skipped`, 'success');
if (!dryRun) {
setContactsCsvText('');
}
return stats;
} catch (err) {
setContactsImportError(getErrorMessage(err, 'Contacts import failed'));
return null;
} finally {
setContactsImportLoading(false);
}
};
const handleImportContactsCsv = async (dryRun = false) => {
return runContactsImport({ dryRun: !!dryRun });
};
const handlePreviewThenConfirmContactsImport = async () => {
const stats = await runContactsImport({ dryRun: true });
if (!stats) return;
};
return (
<div className="page-container">
<h2 className="section-title">Settings</h2>
<div className="section">
<div className="section-title">Your Profile</div>
<div className="detail-row">
<span className="detail-label">Username</span>
<span className="detail-value">{user?.username || '-'}</span>
</div>
<div className="detail-row">
<span className="detail-label">Email</span>
<span className="detail-value">{user?.email || '-'}</span>
</div>
<div className="detail-row">
<span className="detail-label">Name</span>
<span className="detail-value">{user?.full_name || '-'}</span>
</div>
</div>
{user?.role === 'admin' && (
<div className="section">
<div className="section-title">Migration</div>
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '12px' }}>
One-time import for existing Airtable fundraising data.
</div>
<button
type="button"
className="button-secondary"
onClick={() => onOpenAirtableImport && onOpenAirtableImport()}
>
Import from Airtable CSV
</button>
<div style={{ marginTop: '16px', borderTop: '1px solid #263548', paddingTop: '14px' }}>
<div style={{ fontWeight: 600, marginBottom: '8px' }}>Import Contacts CSV</div>
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '10px' }}>
Supports Airtable contact exports (first/last name, investor entity, email, LinkedIn, and city/location).
</div>
{contactsImportError && (
<div className="toast error" style={{ position: 'static', marginBottom: '10px' }}>{contactsImportError}</div>
)}
<div style={{ marginBottom: '10px' }}>
<input type="file" accept=".csv,text/csv" onChange={handleContactsCsvFileUpload} />
</div>
<textarea
className="text-input"
rows="6"
value={contactsCsvText}
onChange={(e) => {
setContactsCsvText(e.target.value);
setContactsImportPreview(null);
setContactsImportOverrides({});
}}
placeholder={'First name,Last name,Investor entity name,Email,LinkedIn URL,City/location\nJane,Doe,Example Capital,jane@example.com,https://linkedin.com/in/jane-doe,"New York City, NY, USA"'}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '10px', fontSize: '12px', color: '#c7d3e0' }}>
<input
type="checkbox"
checked={contactsUpdateExisting}
onChange={(e) => {
setContactsUpdateExisting(e.target.checked);
setContactsImportPreview(null);
setContactsImportOverrides({});
}}
/>
Update existing contacts when email matches (otherwise skip existing)
</label>
<div style={{ marginTop: '10px', display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
<button type="button" className="button-secondary" onClick={() => handleImportContactsCsv(true)} disabled={contactsImportLoading}>
{contactsImportLoading ? <Spinner /> : 'Dry Run Contacts Import'}
</button>
<button type="button" className="button-secondary" onClick={handlePreviewThenConfirmContactsImport} disabled={contactsImportLoading}>
{contactsImportLoading ? <Spinner /> : 'Review Before Import'}
</button>
</div>
{contactsImportPreview && (
<div style={{ marginTop: '12px', padding: '10px', border: '1px solid #263548', borderRadius: '8px', background: '#0d1622' }}>
<div style={{ fontWeight: 600, marginBottom: '6px' }}>Import Review</div>
<div style={{ fontSize: '12px', color: '#c7d3e0', marginBottom: '8px' }}>
Created: {contactsImportPreview.created || 0} · Updated: {contactsImportPreview.updated || 0} · Skipped: {contactsImportPreview.skipped || 0}
</div>
{Array.isArray(contactsImportPreview.errors) && contactsImportPreview.errors.length > 0 && (
<div style={{ marginBottom: '8px', fontSize: '12px', color: '#d9a15f', maxHeight: '120px', overflowY: 'auto' }}>
{contactsImportPreview.errors.slice(0, 30).map((e, i) => (
<div key={`${e}-${i}`}> {e}</div>
))}
{contactsImportPreview.errors.length > 30 && (
<div>and {contactsImportPreview.errors.length - 30} more</div>
)}
</div>
)}
{Array.isArray(contactsImportPreview.matches) && contactsImportPreview.matches.length > 0 && (
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '6px' }}>
Matched by email. Choose what to do per row before import:
<span style={{ marginLeft: '6px', color: '#c7d3e0' }}>update = overwrite existing contact, skip = ignore row, create duplicate = insert another contact with same email.</span>
</div>
<div style={{ maxHeight: '220px', overflowY: 'auto', border: '1px solid #263548', borderRadius: '6px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '12px' }}>
<thead>
<tr style={{ background: '#101c2b', color: '#8ea2b7' }}>
<th style={{ textAlign: 'left', padding: '6px', borderBottom: '1px solid #263548' }}>Row</th>
<th style={{ textAlign: 'left', padding: '6px', borderBottom: '1px solid #263548' }}>Incoming</th>
<th style={{ textAlign: 'left', padding: '6px', borderBottom: '1px solid #263548' }}>Existing</th>
<th style={{ textAlign: 'left', padding: '6px', borderBottom: '1px solid #263548' }}>Action</th>
</tr>
</thead>
<tbody>
{contactsImportPreview.matches.map((match, idx) => {
const rowKey = String(match.row || idx + 1);
const currentAction = contactsImportOverrides[rowKey] || match.action || match.default_action || (contactsUpdateExisting ? 'update' : 'skip');
return (
<tr key={`${rowKey}-${idx}`}>
<td style={{ padding: '6px', borderBottom: '1px solid #1a2a3a', color: '#8ea2b7' }}>{rowKey}</td>
<td style={{ padding: '6px', borderBottom: '1px solid #1a2a3a', color: '#c7d3e0' }}>
<div>{match.incoming_name || '-'}</div>
<div style={{ color: '#8ea2b7' }}>{match.incoming_email || '-'}</div>
<div style={{ color: '#8ea2b7' }}>{match.incoming_organization || '-'}</div>
</td>
<td style={{ padding: '6px', borderBottom: '1px solid #1a2a3a', color: '#c7d3e0' }}>
<div>{match.existing_name || '-'}</div>
<div style={{ color: '#8ea2b7' }}>{match.existing_email || '-'}</div>
<div style={{ color: '#8ea2b7' }}>{match.existing_organization || '-'}</div>
</td>
<td style={{ padding: '6px', borderBottom: '1px solid #1a2a3a' }}>
<select
className="text-input"
value={currentAction}
onChange={(e) => {
const nextAction = e.target.value;
setContactsImportOverrides((prev) => ({ ...prev, [rowKey]: nextAction }));
}}
>
<option value="update">Update existing</option>
<option value="skip">Skip row</option>
<option value="create_duplicate">Create duplicate</option>
</select>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
<button type="button" className="button-secondary" onClick={() => handleImportContactsCsv(false)} disabled={contactsImportLoading}>
{contactsImportLoading ? <Spinner /> : 'Proceed With Import'}
</button>
<button
type="button"
className="button-secondary"
onClick={() => {
setContactsImportPreview(null);
setContactsImportOverrides({});
}}
disabled={contactsImportLoading}
>
Cancel
</button>
</div>
</div>
)}
</div>
</div>
)}
{user?.role === 'admin' && (
<div className="section">
<div className="section-title">Admin</div>
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '12px' }}>
Invite users and manage fundraising state backups.
</div>
<div style={{ marginBottom: '20px', borderBottom: '1px solid #263548', paddingBottom: '16px' }}>
<div style={{ fontWeight: 600, marginBottom: '8px' }}>Daily Digest Email</div>
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '10px' }}>
A daily email to all active admins: each team member's activity per investor, plus a by-investor view (inbound + outbound), summarized locally (Spark), never Claude. Use <b>Preview</b> below to build the digest over a window and read it before sending — a wide window is how you verify the summarizer on a quiet day. <b>Send</b> pushes that same window to the admin inboxes now. Neither touches the daily schedule.
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '14px', flexWrap: 'wrap', marginBottom: '12px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={Boolean(digestPolicy.enabled)}
disabled={digestPolicyLoading || digestPolicySaving}
onChange={(e) => handleSaveDigestPolicy({ enabled: e.target.checked })}
/>
Send automatically every day
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
at
<select
value={Number(digestPolicy.send_hour)}
disabled={digestPolicyLoading || digestPolicySaving || !digestPolicy.enabled}
onChange={(e) => handleSaveDigestPolicy({ send_hour: Number(e.target.value) })}
>
{Array.from({ length: 24 }, (_, h) => (
<option key={h} value={h}>
{((h % 12) || 12) + ':00 ' + (h < 12 ? 'AM' : 'PM')}
</option>
))}
</select>
<span style={{ fontSize: '11px', color: '#8ea2b7' }}>(server local time)</span>
</label>
</div>
<div style={{ fontWeight: 600, fontSize: '13px', margin: '4px 0 8px' }}>Manual run &amp; preview</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap', marginBottom: '10px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
<input type="radio" name="digestWindow" checked={digestWindowMode === '24h'}
onChange={() => setDigestWindowMode('24h')} />
Last 24 hours
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
<input type="radio" name="digestWindow" checked={digestWindowMode === 'since'}
onChange={() => setDigestWindowMode('since')} />
Since
<input type="date" value={digestSince} max={new Date().toISOString().slice(0, 10)}
onChange={(e) => { setDigestSince(e.target.value); setDigestWindowMode('since'); }}
disabled={digestWindowMode !== 'since'} />
</label>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button type="button" onClick={handlePreviewDigest} disabled={digestPreviewLoading}>
{digestPreviewLoading ? <Spinner /> : 'Preview'}
</button>
<button type="button" onClick={handleSendDigestNow} disabled={sendDigestLoading}>
{sendDigestLoading ? <Spinner /> : 'Send to admins now'}
</button>
<button type="button" onClick={handleSendTestDigestEmail} disabled={testEmailLoading}
style={{ background: 'transparent', color: '#8ea2b7', border: '1px solid #2a3a4d', boxShadow: 'none' }}
title="Send a fixed test message to verify the outbound transport (Gmail-DWD / SMTP)">
{testEmailLoading ? <Spinner /> : 'Send transport test'}
</button>
</div>
{digestPreview && (
<div style={{ marginTop: '12px', border: '1px solid #263548', borderRadius: '6px', overflow: 'hidden' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', padding: '8px 10px', background: '#16202e', flexWrap: 'wrap' }}>
<span style={{ fontSize: '12px', color: '#cdd9e5' }}>
<b>{digestPreview.subject}</b> — {digestSummary(digestPreview)}
{Array.isArray(digestPreview.window) && (
<span style={{ color: '#8ea2b7' }}> · {formatDate(digestPreview.window[0])} → {formatDate(digestPreview.window[1])}</span>
)}
</span>
<button type="button" style={{ fontSize: '11px', padding: '4px 8px', background: 'transparent', color: '#8ea2b7', border: '1px solid #2a3a4d', boxShadow: 'none' }} onClick={() => setDigestPreview(null)}>Clear</button>
</div>
<pre style={{ margin: 0, padding: '10px', maxHeight: '380px', overflow: 'auto', fontSize: '12px', lineHeight: 1.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: '#cdd9e5' }}>{digestPreview.body}</pre>
</div>
)}
</div>
<div style={{ marginBottom: '20px', borderBottom: '1px solid #263548', paddingBottom: '16px' }}>
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Invite User</div>
<form onSubmit={handleInviteUser}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(190px,1fr))', gap: '10px', marginBottom: '10px' }}>
<input
type="text"
className="text-input"
placeholder="Username"
value={inviteForm.username}
onChange={(e) => setInviteForm((f) => ({ ...f, username: e.target.value }))}
required
/>
<input
type="text"
className="text-input"
placeholder="Full name"
value={inviteForm.full_name}
onChange={(e) => setInviteForm((f) => ({ ...f, full_name: e.target.value }))}
required
/>
<input
type="email"
className="text-input"
placeholder="Email"
value={inviteForm.email}
onChange={(e) => setInviteForm((f) => ({ ...f, email: e.target.value }))}
required
/>
<input
type="text"
className="text-input"
placeholder="Temporary password"
value={inviteForm.password}
onChange={(e) => setInviteForm((f) => ({ ...f, password: e.target.value }))}
required
/>
<select
className="select-input"
value={inviteForm.role}
onChange={(e) => setInviteForm((f) => ({ ...f, role: e.target.value }))}
>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
</div>
<button type="submit" disabled={inviteLoading}>
{inviteLoading ? <Spinner /> : 'Invite User'}
</button>
</form>
</div>
<div style={{ marginBottom: '20px', borderBottom: '1px solid #263548', paddingBottom: '16px' }}>
<div style={{ fontWeight: 600, marginBottom: '8px', color: '#f3b2b2' }}>Danger Zone: Reset All Data</div>
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '10px' }}>
Clears all CRM records (contacts, organizations, opportunities, communications, feature requests) and resets fundraising grid to empty defaults.
</div>
<input
type="text"
className="text-input"
placeholder='Type: RESET ALL DATA'
value={resetAllDataConfirm}
onChange={(e) => setResetAllDataConfirm(e.target.value)}
style={{ marginBottom: '10px' }}
/>
<button type="button" className="button-danger" onClick={handleResetAllData} disabled={resetAllDataLoading}>
{resetAllDataLoading ? <Spinner /> : 'Reset All Data'}
</button>
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Fundraising State Ops</div>
<div style={{ marginBottom: '12px', padding: '10px', border: '1px solid #263548', borderRadius: '8px' }}>
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '8px' }}>Scheduled Backups</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(180px,1fr))', gap: '10px', marginBottom: '10px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px', color: '#c7d3e0' }}>
<input
type="checkbox"
checked={Boolean(backupPolicyDraft.enabled)}
onChange={(e) => setBackupPolicyDraft((p) => ({ ...p, enabled: e.target.checked }))}
/>
Enabled
</label>
<label style={{ display: 'grid', gap: '4px', fontSize: '12px', color: '#8ea2b7' }}>
Interval (hours)
<input
type="number"
min="1"
max="168"
className="text-input"
value={backupPolicyDraft.interval_hours}
onChange={(e) => setBackupPolicyDraft((p) => ({ ...p, interval_hours: e.target.value }))}
/>
</label>
<label style={{ display: 'grid', gap: '4px', fontSize: '12px', color: '#8ea2b7' }}>
Retention (days)
<input
type="number"
min="1"
max="365"
className="text-input"
value={backupPolicyDraft.retention_days}
onChange={(e) => setBackupPolicyDraft((p) => ({ ...p, retention_days: e.target.value }))}
/>
</label>
<label style={{ display: 'grid', gap: '4px', fontSize: '12px', color: '#8ea2b7' }}>
Max backups
<input
type="number"
min="1"
max="1000"
className="text-input"
value={backupPolicyDraft.max_backups}
onChange={(e) => setBackupPolicyDraft((p) => ({ ...p, max_backups: e.target.value }))}
/>
</label>
</div>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center', flexWrap: 'wrap' }}>
<button type="button" className="button-secondary" onClick={handleSaveBackupPolicy} disabled={backupPolicyLoading}>
{backupPolicyLoading ? <Spinner /> : 'Save Backup Policy'}
</button>
{backupPolicy?.next_run_at && (
<span style={{ fontSize: '12px', color: '#8ea2b7' }}>
Next run: {formatDateLong(backupPolicy.next_run_at)}
</span>
)}
</div>
</div>
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
<button type="button" className="button-secondary" onClick={handleExportFundraising} disabled={exportLoading}>
{exportLoading ? <Spinner /> : 'Download Export JSON'}
</button>
<button type="button" className="button-secondary" onClick={handleBackupFundraising} disabled={backupLoading}>
{backupLoading ? <Spinner /> : 'Backup Now'}
</button>
</div>
<div style={{ marginTop: '12px', display: 'flex', gap: '10px', flexWrap: 'wrap', alignItems: 'center' }}>
<input type="file" accept=".json,application/json" onChange={handleRestoreFileSelected} />
<button type="button" className="button-secondary" onClick={handlePreviewRestore} disabled={previewLoading || !restorePayload}>
{previewLoading ? <Spinner /> : 'Preview Restore'}
</button>
<button type="button" className="button-secondary" onClick={handleRestoreFundraising} disabled={restoreLoading || !restorePayload}>
{restoreLoading ? <Spinner /> : 'Restore From JSON'}
</button>
</div>
{restoreFileName && (
<div style={{ marginTop: '8px', fontSize: '12px', color: '#8ea2b7' }}>
Loaded restore file: {restoreFileName}
</div>
)}
{restorePreview && (
<div style={{ marginTop: '8px', fontSize: '12px', color: '#8ea2b7', display: 'grid', gap: '5px' }}>
<div>Preview: {restorePreview.columns_count} columns, {restorePreview.rows_count} rows, {restorePreview.views_count} views</div>
{restorePreview.diff && (
<div style={{ color: '#a6b8ca' }}>
Diff: +{restorePreview.diff.rows_added_count || 0} rows, -{restorePreview.diff.rows_removed_count || 0} rows, {restorePreview.diff.rows_changed_count || 0} rows changed, {restorePreview.diff.cell_changes_count || 0} cell edits, +{(restorePreview.diff.columns_added || []).length} columns, -{(restorePreview.diff.columns_removed || []).length} columns
</div>
)}
</div>
)}
{lastBackupInfo && (
<div style={{ marginTop: '10px', fontSize: '12px', color: '#8ea2b7' }}>
Last backup: {lastBackupInfo.filename} ({lastBackupInfo.path})
</div>
)}
</div>
<div style={{ marginTop: '20px', borderTop: '1px solid #263548', paddingTop: '16px' }}>
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Backup History</div>
{backupHistoryLoading ? (
<SkeletonBlock lines={4} />
) : backupHistory.length === 0 ? (
<div className="empty-state" style={{ padding: '10px 0' }}>No backups yet</div>
) : (
<table className="table">
<thead>
<tr>
<th>File</th>
<th>Type</th>
<th>Modified</th>
<th>Size</th>
<th></th>
</tr>
</thead>
<tbody>
{backupHistory.slice(0, 30).map((b) => (
<tr key={b.filename}>
<td>{b.filename}</td>
<td>{b.kind}</td>
<td>{formatDateLong(b.modified_at)}</td>
<td>{Math.round((b.size_bytes || 0) / 1024)} KB</td>
<td>
<button
type="button"
className="button-secondary"
onClick={() => handleRestoreFromBackup(b.filename)}
disabled={restoreLoading}
>
Restore
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div style={{ marginTop: '20px', borderTop: '1px solid #263548', paddingTop: '16px' }}>
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Users</div>
{usersLoading ? (
<SkeletonBlock lines={5} />
) : users.length === 0 ? (
<div className="empty-state" style={{ padding: '10px 0' }}>No users</div>
) : (
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>{u.full_name || '-'}</td>
<td>{u.username}</td>
<td>{u.email || '-'}</td>
<td>
<select
className="select-input"
style={{ minWidth: '120px' }}
value={u.role || 'member'}
onChange={(e) => handleUpdateUserRole(u, e.target.value)}
disabled={userActionLoadingId === `${u.id}:role` || u.id === user?.id}
>
<option value="member">member</option>
<option value="admin">admin</option>
{/* bot = dedicated agent service account (e.g. the Matrix bot): authenticated but never admin */}
<option value="bot">bot</option>
</select>
</td>
<td>{u.is_active ? 'Active' : 'Inactive'}</td>
<td>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
type="button"
className="button-secondary"
onClick={() => handleToggleUserActive(u)}
disabled={userActionLoadingId === u.id || u.id === user?.id}
>
{userActionLoadingId === u.id ? <Spinner /> : (u.is_active ? 'Deactivate' : 'Activate')}
</button>
<button
type="button"
className="button-secondary"
onClick={() => handleResetUserPassword(u)}
disabled={userActionLoadingId === `${u.id}:password`}
>
{userActionLoadingId === `${u.id}:password` ? <Spinner /> : 'Reset Password'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div style={{ marginTop: '20px', borderTop: '1px solid #263548', paddingTop: '16px' }}>
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Audit Log</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(170px,1fr))', gap: '10px', marginBottom: '10px' }}>
<select className="select-input" value={auditFilters.entity_type} onChange={(e) => setAuditFilters((f) => ({ ...f, entity_type: e.target.value }))}>
<option value="">All entities</option>
<option value="fundraising_state">fundraising_state</option>
<option value="user">user</option>
<option value="feature_request">feature_request</option>
<option value="contact">contact</option>
<option value="organization">organization</option>
<option value="opportunity">opportunity</option>
<option value="communication">communication</option>
<option value="tag">tag</option>
</select>
<input className="text-input" placeholder="Action (create/update/...)" value={auditFilters.action} onChange={(e) => setAuditFilters((f) => ({ ...f, action: e.target.value }))} />
<select className="select-input" value={auditFilters.user_id} onChange={(e) => setAuditFilters((f) => ({ ...f, user_id: e.target.value }))}>
<option value="">All users</option>
{users.map((u) => (
<option key={u.id} value={u.id}>{u.full_name || u.username}</option>
))}
</select>
<input className="text-input" type="date" value={auditFilters.date_from} onChange={(e) => setAuditFilters((f) => ({ ...f, date_from: e.target.value }))} />
<input className="text-input" type="date" value={auditFilters.date_to} onChange={(e) => setAuditFilters((f) => ({ ...f, date_to: e.target.value }))} />
<input className="text-input" placeholder="Search entity or changes" value={auditFilters.search} onChange={(e) => setAuditFilters((f) => ({ ...f, search: e.target.value }))} />
</div>
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
<button type="button" className="button-secondary" onClick={fetchAuditLogs} disabled={auditLoading}>
{auditLoading ? <Spinner /> : 'Apply Filters'}
</button>
<button
type="button"
className="button-secondary"
onClick={() => setAuditFilters({ entity_type: '', action: '', user_id: '', date_from: '', date_to: '', search: '', limit: 100 })}
>
Reset
</button>
</div>
{auditLoading ? (
<SkeletonBlock lines={5} />
) : auditLogs.length === 0 ? (
<div className="empty-state" style={{ padding: '10px 0' }}>No audit events</div>
) : (
<table className="table">
<thead>
<tr>
<th>When</th>
<th>Entity</th>
<th>Action</th>
<th>User</th>
</tr>
</thead>
<tbody>
{auditLogs.slice(0, 40).map((l) => (
<tr key={l.id}>
<td>{formatDateLong(l.created_at)}</td>
<td>{l.entity_type} · {l.entity_id}</td>
<td>{l.action}</td>
<td>{l.user_name || l.user_id || '-'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div style={{ marginTop: '20px', borderTop: '1px solid #263548', paddingTop: '16px' }}>
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Automations</div>
{automationsLoading ? (
<SkeletonBlock lines={4} />
) : (
<>
{automationRules.length === 0 ? (
<div className="empty-state" style={{ padding: '10px 0' }}>No automation rules</div>
) : (
<table className="table">
<thead>
<tr>
<th>Rule</th>
<th>Trigger</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{automationRules.map((r) => (
<tr key={r.id}>
<td>{r.name}</td>
<td>{r.trigger_type}</td>
<td>{r.enabled ? 'Enabled' : 'Disabled'}</td>
<td>
<button type="button" className="button-secondary" onClick={() => handleToggleAutomationRule(r)}>
{r.enabled ? 'Disable' : 'Enable'}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<div style={{ marginTop: '10px', fontSize: '12px', color: '#8ea2b7' }}>Recent automation runs</div>
{automationRuns.length === 0 ? (
<div className="empty-state" style={{ padding: '8px 0' }}>No runs yet</div>
) : (
<table className="table">
<thead>
<tr>
<th>When</th>
<th>Investor</th>
<th>Status</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{automationRuns.slice(0, 12).map((r) => (
<tr key={r.id}>
<td>{formatDateLong(r.created_at)}</td>
<td>{r.investor_name || r.investor_id || '-'}</td>
<td>{r.status}</td>
<td style={{ fontFamily: 'IBM Plex Mono, monospace', fontSize: '11px' }}>{JSON.stringify(r.result_json || {})}</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
</div>
<div style={{ marginTop: '20px', borderTop: '1px solid #263548', paddingTop: '16px' }}>
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Reliability Checks</div>
<button type="button" className="button-secondary" onClick={handleVerifyBackups} disabled={backupVerifyLoading}>
{backupVerifyLoading ? <Spinner /> : 'Verify Backup Files'}
</button>
{backupVerifyResult && (
<div style={{ marginTop: '10px', fontSize: '12px', color: '#8ea2b7' }}>
Checked {backupVerifyResult.checked} backups. Valid: {backupVerifyResult.valid}. Invalid: {backupVerifyResult.invalid_count}.
</div>
)}
</div>
<div style={{ marginTop: '20px', borderTop: '1px solid #263548', paddingTop: '16px' }}>
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Activity Feed</div>
{activityLoading ? (
<SkeletonBlock lines={4} />
) : activityFeed.length === 0 ? (
<div className="empty-state" style={{ padding: '10px 0' }}>No activity yet</div>
) : (
<table className="table">
<thead>
<tr>
<th>When</th>
<th>Source</th>
<th>Entity</th>
<th>Action</th>
<th>Actor</th>
</tr>
</thead>
<tbody>
{activityFeed.slice(0, 30).map((a) => (
<tr key={a.id}>
<td>{formatDateLong(a.created_at)}</td>
<td>{a.source}</td>
<td>{a.entity_type} · {a.entity_id}</td>
<td>{a.action}</td>
<td>{a.actor_name || '-'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div style={{ marginTop: '20px', borderTop: '1px solid #263548', paddingTop: '16px' }}>
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Security</div>
{securityLoading ? (
<SkeletonBlock lines={2} />
) : securityStatus ? (
<div style={{ fontSize: '12px', color: '#8ea2b7', display: 'grid', gap: '4px' }}>
<div>Environment: {securityStatus.env}</div>
<div>CORS: {securityStatus.cors_origin}</div>
<div>Custom secret set: {securityStatus.has_custom_secret ? 'Yes' : 'No'}</div>
<div>Rate limits: login {securityStatus.rate_limits?.login_per_min || '-'} /min, writes {securityStatus.rate_limits?.write_per_min || '-'} /min</div>
{Array.isArray(securityStatus.warnings) && securityStatus.warnings.map((w, i) => (
<div key={i} style={{ color: '#d9a15f' }}>Warning: {w}</div>
))}
</div>
) : (
<div className="empty-state" style={{ padding: '10px 0' }}>No security info</div>
)}
</div>
</div>
)}
</div>
);
};
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>
);
};
// Friendly label for the various node types in a thesis tree.
const THESIS_NODE_TYPE_LABEL = {
thesis_root: 'Thesis',
throughline: 'Throughline',
section: 'Section',
claim: 'Claim',
proof_point: 'Proof Point',
objection: 'Objection',
segment_cut: 'Segment Cut'
};
const formatNodeType = (t) => THESIS_NODE_TYPE_LABEL[t] || formatThesisStatus(t);
// Detect the server's "Architect not connected" signal so we can show a
// calm connect-the-Architect message instead of a raw 502.
const isArchitectMissingKeyError = (err) => {
const status = err?.status;
const payload = err?.payload || {};
const haystack = `${err?.message || ''} ${payload.error || ''} ${payload.reason || ''} ${payload.detail || ''}`.toLowerCase();
return status === 502 || haystack.includes('anthropic_api_key') || haystack.includes('api key') || haystack.includes('not configured');
};
const ARCHITECT_CONNECT_HINT = "The Architect isn't connected yet — add your Anthropic API key on the server to enable generation. You can still view and edit the thesis.";
// Renders a single node's competing options (its variant group). The
// number of options is whatever the server returns — could be 0, 1, or
// many — and is rendered as a dynamic list, never a fixed A/B layout.
const ThesisWorkshopOptions = ({ token, node, lineKey, isAdmin, architectReady, onShowToast, onTreeChanged }) => {
const nodeId = node.id;
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [genN, setGenN] = useState(3);
const [guidance, setGuidance] = useState('');
const [generating, setGenerating] = useState(false);
const [editingId, setEditingId] = useState(null);
const [editText, setEditText] = useState('');
const [busyId, setBusyId] = useState(null);
const load = useCallback(async () => {
try {
setLoading(true);
const result = await api(`/api/thesis/nodes/${nodeId}/variants`, {}, token);
setData(result);
setError('');
} catch (err) {
setError(getErrorMessage(err, 'Failed to load options'));
} finally {
setLoading(false);
}
}, [token, nodeId]);
useEffect(() => { load(); }, [load]);
const handleGenerate = async (e) => {
e.preventDefault();
if (!isAdmin || generating) return;
const n = Math.max(1, Math.min(8, parseInt(genN, 10) || 1));
try {
setGenerating(true);
const result = await api(`/api/thesis/nodes/${nodeId}/generate`, {
method: 'POST',
body: JSON.stringify({ n, guidance: guidance.trim() })
}, token);
const count = result?.data?.generated ?? (result?.data?.options || []).length;
onShowToast(`The Architect drafted ${count} option${count === 1 ? '' : 's'}`, 'success');
setGuidance('');
await refresh();
} catch (err) {
if (isArchitectMissingKeyError(err)) {
onShowToast(ARCHITECT_CONNECT_HINT, 'error');
} else {
onShowToast(getErrorMessage(err, 'Failed to generate options'), 'error');
}
} finally {
setGenerating(false);
}
};
const refresh = async () => { await load(); if (onTreeChanged) onTreeChanged(); };
const chooseOption = async (optId) => {
if (busyId) return;
try {
setBusyId(optId);
await api(`/api/thesis/nodes/${optId}/choose`, { method: 'POST' }, token);
onShowToast('Set as the chosen wording', 'success');
await refresh();
} catch (err) { onShowToast(getErrorMessage(err, 'Could not choose this option'), 'error'); }
finally { setBusyId(null); }
};
const deleteOption = async (optId) => {
if (busyId) return;
if (!window.confirm('Delete this and anything under it? This can be undone by an admin.')) return;
try {
setBusyId(optId);
await api(`/api/thesis/nodes/${optId}`, { method: 'DELETE' }, token);
onShowToast('Deleted', 'success');
await refresh();
} catch (err) { onShowToast(getErrorMessage(err, 'Could not delete'), 'error'); }
finally { setBusyId(null); }
};
const saveEdit = async (optId) => {
if (busyId) return;
try {
setBusyId(optId);
await api(`/api/thesis/nodes/${optId}`, { method: 'PUT', body: JSON.stringify({ body: editText }) }, token);
setEditingId(null);
onShowToast('Saved', 'success');
await refresh();
} catch (err) { onShowToast(getErrorMessage(err, 'Could not save'), 'error'); }
finally { setBusyId(null); }
};
if (loading) return <div style={{ padding: '4px 0' }}><SkeletonBlock lines={3} /></div>;
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
const options = Array.isArray(data?.variants) ? data.variants : [];
const multi = options.length > 1;
const busy = generating;
return (
<div>
{options.length === 0 ? (
<div className="empty-state" style={{ padding: '18px 0' }}>
No wording yet for this part.{isAdmin && architectReady ? ' Ask the Architect below, or write your own.' : ''}
</div>
) : (
<div className="thesis-ws-options">
{options.map((opt, i) => {
const meta = opt.meta || {};
const rationale = meta.rationale || meta.reason || meta.notes || '';
const editing = editingId === opt.id;
const oBusy = busyId === opt.id;
return (
<div key={opt.id ?? i} className="thesis-ws-option">
<div className="thesis-ws-option-head">
<span className="thesis-ws-option-num">{multi ? `Option ${i + 1} of ${options.length}` : 'Current wording'}</span>
{opt.status && (
<span className={`badge ${thesisStatusClass(opt.status)}`}>{formatThesisStatus(opt.status)}</span>
)}
</div>
{editing ? (
<textarea className="text-input" rows="5" value={editText} onChange={(e) => setEditText(e.target.value)} />
) : (
<div className="thesis-ws-option-text">{opt.body || '—'}</div>
)}
{rationale && !editing && (
<div className="thesis-ws-rationale">
<div className="thesis-ws-rationale-label">Architect's rationale</div>
{rationale}
</div>
)}
{isAdmin && (
<div style={{ display: 'flex', gap: '8px', marginTop: '8px', flexWrap: 'wrap' }}>
{editing ? (
<>
<button type="button" disabled={oBusy} onClick={() => saveEdit(opt.id)}>{oBusy ? 'Saving' : 'Save'}</button>
<button type="button" className="button-secondary" onClick={() => setEditingId(null)}>Cancel</button>
</>
) : (
<>
{multi && <button type="button" disabled={oBusy} onClick={() => chooseOption(opt.id)}>Use this</button>}
<button type="button" className="button-secondary" disabled={oBusy} onClick={() => { setEditingId(opt.id); setEditText(opt.body || ''); }}>Edit</button>
<button type="button" className="button-danger" disabled={oBusy} onClick={() => deleteOption(opt.id)}>Delete</button>
</>
)}
</div>
)}
</div>
);
})}
</div>
)}
{isAdmin ? (
<form className="thesis-ws-control-col" onSubmit={handleGenerate} style={{ marginTop: '14px' }}>
<div className="thesis-block-label">Ask the Architect for options</div>
<textarea
className="text-input"
rows="3"
placeholder="Optional — tell it what to try: sharper, shorter, a different angle, answer a specific objection…"
value={guidance}
onChange={(e) => setGuidance(e.target.value)}
disabled={busy}
/>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="number"
className="text-input thesis-ws-n-input"
min="1"
max="8"
value={genN}
onChange={(e) => setGenN(e.target.value)}
aria-label="How many options"
disabled={busy}
/>
<span className="form-help" style={{ marginTop: 0 }}>option(s)</span>
<button type="submit" disabled={busy || !architectReady}>
{generating ? 'Architect is drafting' : 'Generate'}
</button>
</div>
{!architectReady && (
<div className="form-help">Generation needs the Architect connected.</div>
)}
</form>
) : (
<div className="form-help" style={{ marginTop: '14px' }}>Only admins can edit, choose, or generate options.</div>
)}
</div>
);
};
// One node in the tree. Renders its header (click to select + reveal its
// options), its options when selected, then recurses into children.
const ThesisWorkshopNode = ({ token, node, lineKey, depth, isAdmin, architectReady, selectedId, onSelect, onShowToast, onTreeChanged }) => {
const isSelected = selectedId === node.id;
const children = Array.isArray(node.children) ? node.children : [];
const variantGroup = node.variant_group;
return (
<div className={`thesis-ws-node ${isSelected ? 'selected' : ''}`}>
<button
type="button"
className="thesis-ws-node-head"
onClick={() => onSelect(isSelected ? null : node.id)}
>
<div style={{ minWidth: 0 }}>
<div className="thesis-ws-node-type">{formatNodeType(node.node_type)}</div>
<div className="thesis-ws-node-title">{node.title || node.body || formatNodeType(node.node_type)}</div>
{node.body && node.title && node.body !== node.title && (
<div className="thesis-line-meta">{node.body}</div>
)}
</div>
<span className="thesis-ws-count">{isSelected ? 'Hide options' : 'Options'}</span>
</button>
{isSelected && (
<div className="thesis-ws-body">
<ThesisWorkshopOptions
key={variantGroup || node.id}
token={token}
node={node}
lineKey={lineKey}
isAdmin={isAdmin}
architectReady={architectReady}
onShowToast={onShowToast}
onTreeChanged={onTreeChanged}
/>
</div>
)}
{children.length > 0 && (
<div className="thesis-ws-children">
{children.map((child) => (
<ThesisWorkshopNode
key={child.id}
token={token}
node={child}
lineKey={lineKey}
depth={(depth || 0) + 1}
isAdmin={isAdmin}
architectReady={architectReady}
selectedId={selectedId}
onSelect={onSelect}
onShowToast={onShowToast}
onTreeChanged={onTreeChanged}
/>
))}
</div>
)}
</div>
);
};
const ThesisWorkshopPage = ({ token, user, onShowToast }) => {
const isAdmin = user?.role === 'admin';
const [lines, setLines] = useState([]);
const [linesLoading, setLinesLoading] = useState(true);
const [linesError, setLinesError] = useState('');
const [selectedLineKey, setSelectedLineKey] = useState(null);
const [tree, setTree] = useState(null);
const [treeLoading, setTreeLoading] = useState(false);
const [treeError, setTreeError] = useState('');
const [selectedNodeId, setSelectedNodeId] = useState(null);
const [treeTick, setTreeTick] = useState(0);
const [architect, setArchitect] = useState(null);
const [canonical, setCanonical] = useState(null);
const [approving, setApproving] = useState(false);
// Seed-a-line form (shown when there are no lines).
const [showNewLine, setShowNewLine] = useState(false);
const [newLine, setNewLine] = useState({ line_key: '', name: '', is_core: false, description: '' });
const [creatingLine, setCreatingLine] = useState(false);
// Seed-a-node controls (shown when a line has an empty tree).
const [seedTitle, setSeedTitle] = useState('');
const [seedType, setSeedType] = useState('throughline');
const [seeding, setSeeding] = useState(false);
// Architect status — non-blocking; failure just means "unknown",
// we still let the partner view/edit the thesis.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const result = await api('/api/architect/status', {}, token);
if (!cancelled) setArchitect(result.data || result);
} catch (err) {
if (!cancelled) setArchitect({ ready: false, reason: getErrorMessage(err, 'Status unavailable') });
}
})();
return () => { cancelled = true; };
}, [token]);
const loadLines = useCallback(async () => {
try {
setLinesLoading(true);
const result = await api('/api/thesis/lines', {}, token);
const lineList = result.lines || [];
setLines(lineList);
setLinesError('');
setSelectedLineKey((prev) => {
if (prev && lineList.some((l) => l.line_key === prev)) return prev;
return lineList[0]?.line_key ?? null;
});
} catch (err) {
setLinesError(getErrorMessage(err, 'Failed to load thesis lines'));
} finally {
setLinesLoading(false);
}
}, [token]);
useEffect(() => { loadLines(); }, [loadLines]);
useEffect(() => {
if (!selectedLineKey) { setTree(null); return undefined; }
let cancelled = false;
(async () => {
try {
setTreeLoading(true);
const result = await api(`/api/thesis/${selectedLineKey}/tree`, {}, token);
if (cancelled) return;
setTree(result);
setTreeError('');
setSelectedNodeId(null);
} catch (err) {
if (!cancelled) setTreeError(getErrorMessage(err, 'Failed to load thesis tree'));
} finally {
if (!cancelled) setTreeLoading(false);
}
})();
return () => { cancelled = true; };
}, [token, selectedLineKey, treeTick]);
// Is the selected line already someone's current (canonical) thesis?
useEffect(() => {
if (!selectedLineKey) { setCanonical(null); return undefined; }
let cancelled = false;
(async () => {
try {
const r = await api(`/api/thesis/${selectedLineKey}/canonical`, {}, token);
if (!cancelled) setCanonical(r.data || r);
} catch (err) {
if (!cancelled) setCanonical(null);
}
})();
return () => { cancelled = true; };
}, [token, selectedLineKey, treeTick]);
const handleApproveLine = async () => {
if (!isAdmin || approving || !selectedLineKey) return;
try {
setApproving(true);
const r = await api(`/api/thesis/lines/${selectedLineKey}/approve`, { method: 'POST' }, token);
const d = r.data || r;
if (d.promoted_to_canonical) {
onShowToast('Approved — this is now your current thesis.', 'success');
} else {
const left = Math.max(0, (d.required || 2) - (d.approvals || 0));
onShowToast(`Approved (${d.approvals} of ${d.required}). ${left} more admin approval${left === 1 ? '' : 's'} needed.`, 'success');
}
setTreeTick((t) => t + 1);
await loadLines();
} catch (err) {
onShowToast(getErrorMessage(err, 'Could not approve'), 'error');
} finally {
setApproving(false);
}
};
const handleCreateLine = async (e) => {
e.preventDefault();
if (!isAdmin || creatingLine) return;
if (!newLine.line_key.trim() || !newLine.name.trim()) {
onShowToast('A key and name are required', 'error');
return;
}
try {
setCreatingLine(true);
await api('/api/thesis/lines', {
method: 'POST',
body: JSON.stringify({
line_key: newLine.line_key.trim(),
name: newLine.name.trim(),
is_core: !!newLine.is_core,
description: newLine.description.trim()
})
}, token);
onShowToast('Thesis line created', 'success');
const createdKey = newLine.line_key.trim();
setNewLine({ line_key: '', name: '', is_core: false, description: '' });
setShowNewLine(false);
await loadLines();
setSelectedLineKey(createdKey);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to create thesis line'), 'error');
} finally {
setCreatingLine(false);
}
};
const handleSeedNode = async (e) => {
e.preventDefault();
if (!isAdmin || seeding || !selectedLineKey) return;
if (!seedTitle.trim()) {
onShowToast('Give the new part a title', 'error');
return;
}
try {
setSeeding(true);
await api(`/api/thesis/lines/${selectedLineKey}/nodes`, {
method: 'POST',
body: JSON.stringify({ node_type: seedType, title: seedTitle.trim(), body: '' })
}, token);
onShowToast('Added to the thesis', 'success');
setSeedTitle('');
setTreeTick((t) => t + 1);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to add part'), 'error');
} finally {
setSeeding(false);
}
};
const architectReady = !!architect?.ready;
const nodes = Array.isArray(tree?.tree) ? tree.tree : [];
return (
<div className="page-container">
<h2 className="section-title" style={{ marginBottom: '20px' }}>Thesis Workshop</h2>
{architect && (
<div className={`thesis-ws-banner ${architectReady ? 'ready' : ''}`}>
<span className="thesis-ws-banner-icon">{architectReady ? '◆' : 'ⓘ'}</span>
<div>
{architectReady ? (
<>The Architect is connected{architect.model ? ` (${architect.model})` : ''} and ready to draft options with you.</>
) : (
<>{ARCHITECT_CONNECT_HINT}{architect.reason ? ` (${architect.reason})` : ''}</>
)}
</div>
</div>
)}
<div className="thesis-layout">
<div className="thesis-col">
<div className="section">
<div className="section-title">Thesis Lines</div>
{linesLoading ? (
<SkeletonBlock lines={4} />
) : linesError ? (
<div className="toast error" style={{ position: 'static' }}>{linesError}</div>
) : lines.length === 0 ? (
<div className="empty-state" style={{ padding: '20px 0' }}>
<div className="empty-state-icon">§</div>
No thesis lines yet.
{isAdmin ? ' Seed one to start working with the Architect.' : ' An admin needs to create one.'}
</div>
) : (
<div className="thesis-line-list">
{lines.map((line) => (
<button
key={line.id}
className={`thesis-version-row ${selectedLineKey === line.line_key ? 'active' : ''}`}
onClick={() => setSelectedLineKey(line.line_key)}
>
<div style={{ minWidth: 0 }}>
<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>
</button>
))}
</div>
)}
{isAdmin && !linesLoading && (
<div style={{ marginTop: '14px' }}>
{showNewLine ? (
<form onSubmit={handleCreateLine} className="thesis-review-form" style={{ marginTop: 0 }}>
<div className="form-group">
<label className="form-label">Key</label>
<input
className="text-input"
placeholder="e.g. bitcoin_energy"
value={newLine.line_key}
onChange={(e) => setNewLine((f) => ({ ...f, line_key: e.target.value }))}
/>
</div>
<div className="form-group">
<label className="form-label">Name</label>
<input
className="text-input"
placeholder="Human-friendly name"
value={newLine.name}
onChange={(e) => setNewLine((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="form-group">
<label className="form-label">Description</label>
<textarea
className="text-input"
rows="2"
placeholder="Optional one-liner"
value={newLine.description}
onChange={(e) => setNewLine((f) => ({ ...f, description: e.target.value }))}
/>
</div>
<label className="form-help" style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={newLine.is_core}
onChange={(e) => setNewLine((f) => ({ ...f, is_core: e.target.checked }))}
/>
Mark as a core thesis line
</label>
<div className="form-actions">
<button type="button" className="button-secondary" onClick={() => setShowNewLine(false)} disabled={creatingLine}>Cancel</button>
<button type="submit" disabled={creatingLine}>{creatingLine ? 'Creating…' : 'Create line'}</button>
</div>
</form>
) : (
<button className="button-secondary" style={{ width: '100%' }} onClick={() => setShowNewLine(true)}>+ New thesis line</button>
)}
</div>
)}
</div>
</div>
<div className="thesis-col">
<div className="section">
<div className="section-title">Workshop</div>
{selectedLineKey && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', flexWrap: 'wrap', marginBottom: '14px', paddingBottom: '12px', borderBottom: '1px solid #1e2a3a' }}>
<div className="form-help" style={{ marginTop: 0, maxWidth: '60%' }}>
Click any part to draft and compare wordings, pick the one you like, then approve the line as your current thesis.
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{canonical && canonical.status === 'ok' && (
<span className="badge badge-investor">Current {canonical.version_no ? ` · v${canonical.version_no}` : ''}</span>
)}
{isAdmin && (
<button type="button" disabled={approving} onClick={handleApproveLine}>
{approving ? 'Approving…' : (canonical && canonical.status === 'ok' ? 'Re-approve as current' : 'Approve as current')}
</button>
)}
</div>
</div>
)}
{!selectedLineKey ? (
<div className="empty-state" style={{ padding: '24px 0' }}>
<div className="empty-state-icon"></div>
Pick a thesis line on the left to start iterating on it.
</div>
) : treeLoading ? (
<SkeletonBlock lines={6} />
) : treeError ? (
<div className="toast error" style={{ position: 'static' }}>{treeError}</div>
) : (
<div>
{nodes.length === 0 ? (
<div className="empty-state" style={{ padding: '20px 0' }}>
This line has no content yet.
{isAdmin ? ' Add a throughline or a pillar to seed it.' : ' An admin needs to seed it.'}
</div>
) : (
<div>
{nodes.map((node) => (
<ThesisWorkshopNode
key={node.id}
token={token}
node={node}
lineKey={selectedLineKey}
depth={0}
isAdmin={isAdmin}
architectReady={architectReady}
selectedId={selectedNodeId}
onSelect={setSelectedNodeId}
onShowToast={onShowToast}
onTreeChanged={() => setTreeTick((t) => t + 1)}
/>
))}
</div>
)}
{isAdmin && (
<form onSubmit={handleSeedNode} className="thesis-review-form">
<div className="thesis-block-label">Add a part</div>
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div className="form-group" style={{ marginBottom: 0, flex: '0 0 auto' }}>
<label className="form-label">Type</label>
<select
className="select-input"
value={seedType}
onChange={(e) => setSeedType(e.target.value)}
>
<option value="throughline">Throughline</option>
<option value="section">Section / Pillar</option>
<option value="claim">Claim</option>
<option value="proof_point">Proof Point</option>
<option value="objection">Objection</option>
<option value="segment_cut">Segment Cut</option>
</select>
</div>
<div className="form-group" style={{ marginBottom: 0, flex: '1 1 200px' }}>
<label className="form-label">Title</label>
<input
className="text-input"
placeholder="Short name for this part"
value={seedTitle}
onChange={(e) => setSeedTitle(e.target.value)}
/>
</div>
<button type="submit" disabled={seeding}>{seeding ? 'Adding…' : 'Add'}</button>
</div>
<div className="form-help" style={{ marginTop: '8px' }}>
Each part holds its own set of competing options one or many that you grow with the Architect.
</div>
</form>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
};
const OutreachPage = ({ token, user, onShowToast }) => {
const [investors, setInvestors] = useState([]);
const [investorId, setInvestorId] = useState('');
const [type, setType] = useState('follow_up');
const [guidance, setGuidance] = useState('');
const [drafting, setDrafting] = useState(false);
const [result, setResult] = useState(null);
const [draftText, setDraftText] = useState('');
const [radar, setRadar] = useState([]);
const [lastInvestor, setLastInvestor] = useState('');
const [gmailBusy, setGmailBusy] = useState(false);
const [gmailResult, setGmailResult] = useState(null);
const GMAIL_FAIL = {
no_recipient: "No email address on file for this investor, so there's nothing to address the draft to.",
integration_disabled: 'Gmail integration is off on the server.',
auth_error: 'Could not authorize Gmail — check the compose scope is authorized in Workspace admin.',
no_sender: 'Your account has no email on file to draft from.',
empty: 'The draft is empty.',
gmail_error: 'Gmail rejected the draft.',
};
const TYPES = [
['intro', 'Intro'],
['follow_up', 'Warm follow-up'],
['fund_update', 'Fund update'],
['meeting_follow_up', 'Meeting follow-up'],
['nurture', 'Nurture / stay in touch'],
];
const FAIL = {
not_found: 'That investor was not found.',
scrub_unavailable: 'The redaction boundary could not be prepared, so nothing was sent to Claude.',
claude_not_configured: 'The Architect (Claude) is not configured on the server.',
rehydrate_failed: 'The draft could not be safely personalized (an unexpected placeholder). Try again.',
};
useEffect(() => {
let cancelled = false;
(async () => {
try {
const r = await api('/api/outreach/investors', {}, token);
if (!cancelled) setInvestors(Array.isArray(r?.investors) ? r.investors : []);
} catch (_) { /* none */ }
try {
const rr = await api('/api/outreach/radar', {}, token);
if (!cancelled) setRadar(Array.isArray(rr?.items) ? rr.items : []);
} catch (_) { /* none */ }
})();
return () => { cancelled = true; };
}, [token]);
const draft = async (ovInvestor, ovType) => {
if (drafting) return;
const inv = ovInvestor || investorId;
const t = ovType || type;
if (!inv) { onShowToast('Pick an investor first', 'error'); return; }
try {
setDrafting(true);
setResult(null);
setGmailResult(null);
setLastInvestor(inv);
const res = await api('/api/outreach/draft', {
method: 'POST',
body: JSON.stringify({ investor_id: inv, outreach_type: t, guidance }),
}, token);
const data = res.data || res;
setResult(data);
if (data.status === 'ok') setDraftText(data.draft || '');
} catch (err) {
const msg = getErrorMessage(err, 'Drafting failed');
setResult({ status: 'error', reason: msg });
onShowToast(msg, 'error');
} finally {
setDrafting(false);
}
};
const copy = async () => {
try { await navigator.clipboard.writeText(draftText); onShowToast('Draft copied', 'success'); }
catch (_) { onShowToast('Could not copy', 'error'); }
};
const createGmailDraft = async () => {
if (gmailBusy || !lastInvestor) return;
try {
setGmailBusy(true);
setGmailResult(null);
const res = await api('/api/outreach/gmail-draft', {
method: 'POST',
body: JSON.stringify({ investor_id: lastInvestor, draft: draftText }),
}, token);
const d = res.data || res;
setGmailResult(d);
if (d.status === 'ok') onShowToast(d.threaded ? 'Reply draft created in your Gmail' : 'Draft created in your Gmail', 'success');
else onShowToast(GMAIL_FAIL[d.status] || d.reason || 'Could not create the draft', 'error');
} catch (err) {
onShowToast(getErrorMessage(err, 'Could not create the draft'), 'error');
} finally {
setGmailBusy(false);
}
};
const ok = result && result.status === 'ok';
return (
<div className="page-container">
<h2 className="section-title" style={{ marginBottom: '20px' }}>Outreach</h2>
{radar.length > 0 && (
<div className="section">
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
Needs attention
<span className="approval-pill">{radar.length}</span>
</div>
<div className="index-action-hint" style={{ marginTop: 0, marginBottom: '12px' }}>
Investors who are waiting on a reply or have gone quiet, most urgent first. Every reason is verifiable from your email history no guesswork. Click Draft to compose in your voice.
</div>
{radar.map((it) => (
<div key={it.investor_id} className="merge-candidate-card"
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<div>
<div style={{ fontWeight: 600 }}>{it.name}</div>
<div className="kpi-subtitle">{it.reason}</div>
</div>
<button
onClick={() => { setInvestorId(it.investor_id); setType(it.suggested_type); draft(it.investor_id, it.suggested_type); }}
disabled={drafting}>
{drafting ? '…' : `Draft ${it.suggested_type === 'nurture' ? 'nurture' : 'follow-up'}`}
</button>
</div>
))}
</div>
)}
<div className="section">
<div className="index-action-hint" style={{ marginTop: 0, marginBottom: '12px' }}>
Drafts a tailored LP email in Ten31's voice, grounded in the thesis and that investor's CRM notes + email history. The investor's details are de-identified before Claude sees them and restored locally, so the LP list never leaves Ten31. Drafts only — you review, edit, and send.
</div>
<div className="form-group" style={{ marginBottom: '12px', maxWidth: '420px' }}>
<label className="form-label">Investor</label>
<select className="select-input" value={investorId} onChange={(e) => setInvestorId(e.target.value)}>
<option value="">Select an investor…</option>
{investors.map((iv) => <option key={iv.id} value={iv.id}>{iv.name}</option>)}
</select>
</div>
<div className="form-group" style={{ marginBottom: '12px', maxWidth: '420px' }}>
<label className="form-label">Type</label>
<select className="select-input" value={type} onChange={(e) => setType(e.target.value)}>
{TYPES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</div>
<div className="form-group" style={{ marginBottom: '12px' }}>
<label className="form-label">Guidance (optional)</label>
<textarea className="text-input" style={{ width: '100%', minHeight: '54px' }}
placeholder="e.g. mention the new Giga deal; they asked about lock-up terms"
value={guidance} onChange={(e) => setGuidance(e.target.value)} />
</div>
<div className="index-action-buttons">
<button onClick={() => draft()} disabled={drafting || !investorId}>
{drafting ? 'Drafting (this can take a moment)' : 'Draft outreach'}
</button>
</div>
</div>
{drafting && <div className="section"><SkeletonBlock lines={6} /></div>}
{result && !drafting && (
<div className="section">
{ok ? (
<>
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
Draft for {result.investor_name}
{result.scrub_stats && result.scrub_stats.tokens != null
? <span className="approval-pill">{result.scrub_stats.tokens} identifiers protected</span> : null}
</div>
<textarea className="text-input" style={{ width: '100%', minHeight: '260px', lineHeight: 1.5 }}
value={draftText} onChange={(e) => setDraftText(e.target.value)} />
<div className="index-action-buttons" style={{ marginTop: '10px' }}>
<button onClick={copy}>Copy draft</button>
<button onClick={createGmailDraft} disabled={gmailBusy}>
{gmailBusy ? 'Creating' : 'Create Gmail draft'}
</button>
</div>
{gmailResult && gmailResult.status === 'ok' && (
<div className="index-action-hint" style={{ marginTop: '8px' }}>
{gmailResult.threaded ? 'In-thread reply draft' : 'Draft'} created in your Gmail. <a href={gmailResult.gmail_url} target="_blank" rel="noopener noreferrer">Open Gmail Drafts</a> to review and send.
</div>
)}
<div className="index-action-hint" style={{ marginTop: '12px' }}>
<strong>Voice based on:</strong>{' '}
{result.voice_examples && result.voice_examples.length > 0
? <>your codified rules + {result.voice_examples.length} of your prior emails — {result.voice_examples.map((v, i) => (
<span key={i}>{i > 0 ? '; ' : ''}"{v.subject}"{v.date ? ` (${v.date})` : ''}</span>
))}</>
: 'your codified voice rules only (no prior emails of yours were found to learn from yet)'}
</div>
<div className="index-action-hint" style={{ marginTop: '6px' }}>
Review and edit before sending. Nothing is sent automatically.
</div>
</>
) : (
<div className="toast error" style={{ position: 'static' }}>
{FAIL[result.status] || result.reason || 'Drafting did not complete.'}
</div>
)}
</div>
)}
</div>
);
};
const EmailCapturePage = ({ token, user, onShowToast }) => {
const isAdmin = user?.role === 'admin';
const [status, setStatus] = useState(null);
const [accounts, setAccounts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [busy, setBusy] = useState('');
const [oneEmail, setOneEmail] = useState(() => user?.email || '');
const [proposals, setProposals] = useState([]);
const [edits, setEdits] = useState({});
const [deciding, setDeciding] = useState(null);
const [openEmail, setOpenEmail] = useState(null); // proposal id whose source email is expanded
const [emailCache, setEmailCache] = useState({}); // email_id -> {loading, data, error}
const load = useCallback(async () => {
let s;
try {
s = await api('/api/email/status', {}, token);
} catch (err) {
// Integration off on the server -> render the informative disabled state, not an error.
if (err?.status === 503 || /disabl/i.test(err?.payload?.error || '')) {
setStatus({ enabled: false, accounts_summary: {}, matched_emails: 0, captured_emails: 0, backfilling: false, last_run: null });
setAccounts([]);
return;
}
throw err;
}
let a = { accounts: [] };
try { a = await api('/api/email/accounts', {}, token); } catch (_) { /* none / disabled */ }
let pr = { proposals: [] };
try { pr = await api('/api/activity/proposals', {}, token); } catch (_) { /* admin-only / none */ }
setStatus(s);
setAccounts(Array.isArray(a?.accounts) ? a.accounts : []);
setProposals(Array.isArray(pr?.proposals) ? pr.proposals : []);
}, [token]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
setLoading(true);
await load();
if (!cancelled) setError('');
} catch (err) {
if (!cancelled) setError(getErrorMessage(err, 'Failed to load email status'));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [load]);
// While a backfill is in progress the captured-email count climbs page by page,
// so poll the status to show live progress without a manual refresh.
const backfilling = !!status && (status.backfilling || status.last_run?.status === 'running' || status.running);
useEffect(() => {
if (!backfilling) return undefined;
let cancelled = false;
const iv = setInterval(async () => { try { if (!cancelled) await load(); } catch (_) { /* transient */ } }, 5000);
return () => { cancelled = true; clearInterval(iv); };
}, [backfilling, load]);
// Steady-state poll of just the proposals so a decision made on Matrix (approve/dismiss
// in the review room) clears its card here without a manual reload — the mirror of the
// bot announcing a web-side decision in-thread. Admin-only (only admins see proposals).
const refreshProposals = useCallback(async () => {
try {
const pr = await api('/api/activity/proposals', {}, token);
setProposals(Array.isArray(pr?.proposals) ? pr.proposals : []);
} catch (_) { /* admin-only / transient — leave the current list */ }
}, [token]);
useEffect(() => {
if (!isAdmin) return undefined;
const iv = setInterval(() => { refreshProposals(); }, 25000);
return () => clearInterval(iv);
}, [isAdmin, refreshProposals]);
const runAction = async (key, endpoint, successMsg, confirmMsg, body) => {
if (busy) return;
if (confirmMsg && !window.confirm(confirmMsg)) return;
try {
setBusy(key);
const opts = { method: 'POST' };
if (body) opts.body = JSON.stringify(body);
const res = await api(endpoint, opts, token);
onShowToast(typeof successMsg === 'function' ? successMsg(res) : successMsg, 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Action failed'), 'error');
} finally {
setBusy('');
try { await load(); } catch (_) { /* ignore refresh failure */ }
}
};
const decide = async (p, decision) => {
if (deciding) return;
try {
setDeciding(p.id);
const body = decision === 'approve'
? JSON.stringify({ note: (edits[p.id] != null ? edits[p.id] : p.proposed_note) })
: undefined;
await api(`/api/activity/proposals/${p.id}/${decision}`, { method: 'POST', body }, token);
setProposals((prev) => prev.filter((x) => x.id !== p.id));
onShowToast(decision === 'approve' ? 'Note added to the grid' : 'Proposal dismissed', 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Action failed'), 'error');
} finally {
setDeciding(null);
}
};
// Click a proposal to see the email it was drafted from (from/to/cc/date/subject +
// scrollable body) so you can judge whether the note is right. Lazily fetched +
// cached per email; reuses the admin-only /api/email/detail used by Communications.
const toggleEmail = async (p) => {
if (openEmail === p.id) { setOpenEmail(null); return; }
setOpenEmail(p.id);
const eid = p.email_id;
if (!eid || emailCache[eid]) return;
setEmailCache((c) => ({ ...c, [eid]: { loading: true } }));
try {
const res = await api(`/api/email/detail?id=${encodeURIComponent(eid)}`, {}, token);
setEmailCache((c) => ({ ...c, [eid]: { loading: false, data: res } }));
} catch (err) {
setEmailCache((c) => ({ ...c, [eid]: { loading: false, error: getErrorMessage(err, 'Failed to load email') } }));
}
};
const renderProposalEmail = (p) => {
const det = emailCache[p.email_id];
if (!det) return null;
if (det.loading) return <div style={{ marginTop: '8px' }}><SkeletonBlock lines={4} /></div>;
if (det.error) return <div className="toast error" style={{ position: 'static', marginTop: '8px' }}>{det.error}</div>;
const d = det.data || {};
const rcpt = (kind) => (d.recipients || []).filter((r) => r.kind === kind)
.map((r) => r.display_name ? `${r.display_name} <${r.address}>` : r.address).join(', ');
const to = rcpt('to'), cc = rcpt('cc');
const from = d.from_name ? `${d.from_name} <${d.from_email || ''}>` : (d.from_email || '');
const lbl = { fontSize: '11px', color: '#8ea2b7' };
return (
<div style={{ marginTop: '8px', border: '1px solid #263548', borderRadius: '6px', background: '#0d1622', padding: '10px' }}>
<div style={lbl}><b>From:</b> {from}</div>
{to && <div style={lbl}><b>To:</b> {to}</div>}
{cc && <div style={lbl}><b>Cc:</b> {cc}</div>}
<div style={lbl}><b>Date:</b> {d.sent_at ? new Date(d.sent_at).toLocaleString() : ''}</div>
<div style={lbl}><b>Subject:</b> {d.subject || '(no subject)'}</div>
{(d.attachments || []).length > 0 && (
<div style={{ ...lbl, marginTop: '2px' }}><b>Attachments:</b> {d.attachments.map((a) => a.filename).join(', ')}</div>
)}
<pre style={{ margin: '8px 0 0', maxHeight: '280px', overflowY: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: '12px', lineHeight: 1.5, color: '#cdd9e5', fontFamily: 'inherit' }}>
{d.body_text || (d.has_html ? '(HTML-only email open in Gmail to view formatting)' : '(no body captured)')}
</pre>
</div>
);
};
if (loading) return <div style={{ padding: '20px' }}><SkeletonBlock lines={8} /></div>;
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
if (!status) return <div className="empty-state">No data</div>;
const sum = status.accounts_summary || {};
const enabled = !!status.enabled;
const nAccounts = sum.n_accounts || 0;
const nError = sum.n_error || 0;
const capturing = enabled && nAccounts > 0;
const captured = status.captured_emails ?? 0;
const lastRun = status.last_run;
const lastSync = lastRun ? formatDate(lastRun.finished_at || lastRun.started_at)
: (status.last_run_unix ? formatDate(new Date(status.last_run_unix * 1000).toISOString()) : '');
const statusWord = backfilling ? 'Backfilling' : capturing ? 'Capturing' : enabled ? 'Not enrolled' : 'Disabled';
return (
<div className="page-container">
<h2 className="section-title" style={{ marginBottom: '20px' }}>Email Capture</h2>
<div className="section">
<div className="index-action-status">
{!enabled ? (
<span className="index-job-pill idle"><span className="index-job-dot" /> Integration disabled — no Gmail service-account key on the server.</span>
) : nAccounts === 0 ? (
<span className="index-job-pill idle"><span className="index-job-dot" /> Enabled, but no mailbox is enrolled yet, so nothing is being captured.</span>
) : backfilling ? (
<span className="index-job-pill running"><span className="index-job-dot" /> Backfilling history… {captured} emails captured so far ({status.matched_emails || 0} matched to investors). Updates live; first backfill can take a while.</span>
) : nError ? (
<span className="index-job-pill running"><span className="index-job-dot" /> {nAccounts} mailbox(es), {nError} in error — see below.</span>
) : (
<span className="index-job-pill"><span className="index-job-dot" /> Capturing: {nAccounts} mailbox(es), {captured} emails captured, {status.matched_emails || 0} matched to investors.</span>
)}
</div>
</div>
<div className="kpi-grid">
<div className="kpi-card">
<div className="kpi-label">Status</div>
<div className="kpi-value" style={{ fontSize: '16px' }}>{statusWord}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Mailboxes</div>
<div className="kpi-value">{nAccounts}</div>
{nError ? <div className="kpi-subtitle">{nError} error</div> : null}
</div>
<div className="kpi-card">
<div className="kpi-label">Emails captured</div>
<div className="kpi-value">{captured}{backfilling ? '' : ''}</div>
<div className="kpi-subtitle">{status.matched_emails ?? 0} matched to investors</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Last sync</div>
<div className="kpi-value" style={{ fontSize: '16px' }}>{backfilling ? 'in progress' : lastSync}</div>
<div className="kpi-subtitle">{lastRun?.status ? `last run: ${lastRun.status}` : (status.interval_sec ? `every ${Math.round(status.interval_sec / 60)} min` : '')}</div>
</div>
</div>
{isAdmin && proposals.length > 0 && (
<div className="section">
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
Proposed grid notes
<span className="approval-pill">{proposals.length} to review</span>
</div>
<div className="index-action-hint" style={{ marginTop: 0, marginBottom: '12px' }}>
The agent drafted these from matched emails on your local model. Edit if needed, then Approve to append the note to that investor's grid notes, or Dismiss.
</div>
{proposals.map((p) => (
<div key={p.id} className="merge-candidate-card" style={{ marginBottom: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '10px', marginBottom: '6px' }}>
<div style={{ fontWeight: 600 }}>{p.investor_name || 'Unknown investor'}</div>
<div className="kpi-subtitle">{p.direction === 'sent' ? 'Sent' : 'Received'}{p.email_date ? ` · ${formatDate(p.email_date)}` : ''}</div>
</div>
{p.email_subject ? <div className="kpi-subtitle" style={{ marginBottom: '8px' }}>Subject: {p.email_subject}</div> : null}
<textarea
className="text-input"
style={{ width: '100%', minHeight: '54px', marginBottom: '8px' }}
value={edits[p.id] != null ? edits[p.id] : (p.proposed_note || '')}
onChange={(e) => setEdits((m) => ({ ...m, [p.id]: e.target.value }))}
/>
<div className="index-action-buttons">
<button onClick={() => decide(p, 'approve')} disabled={deciding === p.id}>
{deciding === p.id ? 'Adding…' : 'Approve & add to grid'}
</button>
<button onClick={() => decide(p, 'dismiss')} disabled={deciding === p.id}>Dismiss</button>
{p.email_id && (
<button
onClick={() => toggleEmail(p)}
style={{ background: 'transparent', border: '1px solid #263548', color: '#8ea2b7' }}
>
{openEmail === p.id ? 'Hide email' : 'View email'}
</button>
)}
</div>
{openEmail === p.id && renderProposalEmail(p)}
</div>
))}
</div>
)}
{isAdmin && (
<div className="section">
<div className="section-title">Actions</div>
<div className="index-action-hint" style={{ marginTop: 0, marginBottom: '8px' }}>
Test with a single mailbox first:
</div>
<div className="index-action-buttons" style={{ alignItems: 'center', marginBottom: '16px' }}>
<input
className="text-input"
type="email"
placeholder="name@ten31.xyz"
value={oneEmail}
onChange={(e) => setOneEmail(e.target.value)}
style={{ maxWidth: '260px' }}
/>
<button
onClick={() => {
const addr = (oneEmail || '').trim();
if (!addr) { onShowToast('Enter a mailbox address', 'error'); return; }
runAction('enroll-one', '/api/email/accounts/enroll', (r) => `Enrolled ${r?.email || addr}`, `Enroll just ${addr} for capture?`, { email: addr });
}}
disabled={!enabled || !!busy}
>
{busy === 'enroll-one' ? 'Enrolling…' : 'Enroll this mailbox'}
</button>
</div>
<div className="index-action-buttons">
<button
onClick={() => runAction('enroll', '/api/email/accounts/enroll-all', (r) => `Enrolled ${r?.count ?? 0} mailbox(es)`, 'Enroll ALL Ten31 mailboxes for capture? This connects every active @ten31.xyz users Gmail via the service account. Domain-wide delegation must already be authorized in Google Workspace admin.')}
disabled={!enabled || !!busy}
>
{busy === 'enroll' ? 'Enrolling…' : 'Enroll all Ten31 mailboxes'}
</button>
<button
onClick={() => runAction('sync', '/api/email/sync/run-now', 'Sync started')}
disabled={!enabled || nAccounts === 0 || !!busy}
>
{busy === 'sync' ? 'Syncing…' : 'Sync now'}
</button>
<button
onClick={() => runAction('rematch', '/api/email/rematch', 'Re-match started')}
disabled={!enabled || nAccounts === 0 || !!busy}
>
{busy === 'rematch' ? 'Re-matching…' : 'Re-match to investors'}
</button>
</div>
<div className="index-action-hint">
Enroll connects each Ten31 mailbox through the Google service account (requires domain-wide delegation authorized in Google Workspace admin first). Sync pulls new mail; Re-match links already-captured emails to investors. Captured email is sensitive and only ever feeds the Architect through the redaction boundary, never to Claude directly.
</div>
</div>
)}
<div className="section">
<div className="section-title">Mailboxes</div>
{accounts.length === 0 ? (
<div className="empty-state" style={{ padding: '20px 0' }}>
<div className="empty-state-icon"></div>
No mailbox enrolled yet.{isAdmin ? ' Use “Enroll Ten31 mailboxes” above to start capturing.' : ''}
</div>
) : (
<div className="kpi-grid" style={{ marginBottom: 0 }}>
{accounts.map((a) => (
<div key={a.id} className="kpi-card">
<div className="kpi-label">{a.email_address}</div>
<div className="kpi-value" style={{ fontSize: '15px' }}>{a.sync_status || 'pending'}{a.backfill_complete ? '' : ' · backfilling'}</div>
<div className="kpi-subtitle">{(a.captured ?? 0)} captured · {(a.matched ?? 0)} matched</div>
<div className="kpi-subtitle">{a.last_synced_at ? `last ${formatDate(a.last_synced_at)}` : 'never synced'}{a.sync_error ? ` · ${a.sync_error}` : ''}</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
const SystemStatusPage = ({ token, user, onShowToast }) => {
const isAdmin = user?.role === 'admin';
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [actionBusy, setActionBusy] = useState(false);
const [candidates, setCandidates] = useState([]);
const [candidatesLoading, setCandidatesLoading] = useState(false);
const [decidingId, setDecidingId] = useState(null);
const loadStatus = useCallback(async () => {
const result = await api('/api/system/status', {}, token);
setData(result.data);
return result.data;
}, [token]);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
setLoading(true);
await loadStatus();
if (!cancelled) setError('');
} catch (err) {
if (!cancelled) setError(getErrorMessage(err, 'Failed to load system status'));
} finally {
if (!cancelled) setLoading(false);
}
};
load();
return () => { cancelled = true; };
}, [loadStatus]);
const indexJob = data?.index_job || null;
const jobRunning = !!(indexJob && indexJob.running);
// Poll the status endpoint while an index job is running so the
// indicator + last result update without a manual refresh.
useEffect(() => {
if (!jobRunning) return undefined;
let cancelled = false;
const interval = setInterval(async () => {
try {
if (!cancelled) await loadStatus();
} catch (_) { /* transient; next tick retries */ }
}, 3000);
return () => { cancelled = true; clearInterval(interval); };
}, [jobRunning, loadStatus]);
const loadCandidates = useCallback(async () => {
if (!isAdmin) return;
try {
setCandidatesLoading(true);
const result = await api('/api/entities/merge-candidates', {}, token);
setCandidates(Array.isArray(result.candidates) ? result.candidates : []);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to load duplicate review queue'), 'error');
} finally {
setCandidatesLoading(false);
}
}, [isAdmin, token, onShowToast]);
useEffect(() => {
loadCandidates();
}, [loadCandidates]);
const runIndexAction = async (endpoint, label, confirmMessage) => {
if (jobRunning || actionBusy) return;
if (confirmMessage && !window.confirm(confirmMessage)) return;
try {
setActionBusy(true);
const result = await api(endpoint, { method: 'POST' }, token);
const kind = result?.data?.kind || label;
onShowToast(`Started: ${kind}`, 'success');
} catch (err) {
if (err?.status === 409 || err?.payload?.error === 'job_running') {
onShowToast('An index job is already running', 'error');
} else {
onShowToast(getErrorMessage(err, `Failed to start ${label}`), 'error');
}
} finally {
setActionBusy(false);
try { await loadStatus(); } catch (_) { /* ignore refresh failure */ }
}
};
const decideCandidate = async (candidate, decision) => {
if (decidingId) return;
try {
setDecidingId(candidate.id);
await api(`/api/entities/merge-candidates/${candidate.id}`, {
method: 'POST',
body: JSON.stringify({ decision })
}, token);
setCandidates((prev) => prev.filter((c) => c.id !== candidate.id));
onShowToast(decision === 'approve' ? 'Contacts merged' : 'Kept separate', 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to record decision'), 'error');
} finally {
setDecidingId(null);
}
};
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 fmtBytes = (n) => {
if (n == null) return '—';
if (n < 1024) return n + ' B';
const u = ['KB', 'MB', 'GB', 'TB']; let i = -1; let v = n;
do { v /= 1024; i++; } while (v >= 1024 && i < u.length - 1);
return v.toFixed(v < 10 ? 1 : 0) + ' ' + u[i];
};
const storage = data.storage;
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">Investors</div>
<div className="kpi-value">{entities.investor ?? 0}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">People (resolved)</div>
<div className="kpi-value">{entities.person ?? 0}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Contacts in CRM</div>
<div className="kpi-value">{(data.source_counts || {}).contacts ?? '—'}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Grid contacts</div>
<div className="kpi-value">{(data.source_counts || {}).fundraising_contacts ?? '—'}</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>
{storage && (
<div className="section">
<div className="section-title">Storage</div>
<div className="kpi-grid" style={{ marginBottom: 0 }}>
<div className="kpi-card">
<div className="kpi-label">Database</div>
<div className="kpi-value" style={{ fontSize: '18px' }}>{fmtBytes(storage.database_bytes)}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Email attachments</div>
<div className="kpi-value" style={{ fontSize: '18px' }}>{fmtBytes(storage.attachments_bytes)}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Backups</div>
<div className="kpi-value" style={{ fontSize: '18px' }}>{fmtBytes(storage.backups_bytes)}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Disk free</div>
<div className="kpi-value" style={{ fontSize: '18px' }}>{fmtBytes(storage.disk_free_bytes)}</div>
<div className="kpi-subtitle">of {fmtBytes(storage.disk_total_bytes)} total</div>
</div>
</div>
</div>
)}
{isAdmin && (
<div className="section">
<div className="section-title">Index Actions</div>
<div className="index-action-status">
{jobRunning ? (
<span className="index-job-pill running">
<span className="index-job-dot" /> running: {indexJob.kind || 'job'}
{indexJob.started_at ? ` · started ${formatDate(indexJob.started_at)}` : ''}
</span>
) : indexJob && indexJob.result ? (
<span className="index-job-pill">
last: {indexJob.kind ? `${indexJob.kind}` : ''}{indexJob.result}
{indexJob.finished_at ? ` · ${formatDate(indexJob.finished_at)}` : ''}
</span>
) : (
<span className="index-job-pill idle">No index job has run yet.</span>
)}
</div>
{jobRunning && indexJob.tail ? (
<pre className="index-job-tail">{indexJob.tail}</pre>
) : null}
<div className="index-action-buttons">
<button
onClick={() => runIndexAction('/api/index/update', 'update search index')}
disabled={jobRunning || actionBusy}
>
Update search index
</button>
<button
onClick={() => runIndexAction('/api/index/rebuild', 'rebuild search index', 'Rebuild the entire search index? This re-embeds every record and can take several minutes.')}
disabled={jobRunning || actionBusy}
>
Rebuild search index
</button>
<button
onClick={() => runIndexAction('/api/entities/find-duplicates', 'find duplicate contacts')}
disabled={jobRunning || actionBusy}
>
Find duplicate contacts
</button>
</div>
<div className="index-action-hint">
Update is a fast incremental pass. Rebuild re-embeds everything. Find duplicates runs the local model and fills the review queue below.
</div>
</div>
)}
{isAdmin && (
<div className="section">
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '16px' }}>
Duplicate Review
<span className="approval-pill">{candidates.length} pending</span>
</div>
{candidatesLoading ? (
<SkeletonBlock lines={4} />
) : candidates.length === 0 ? (
<div className="empty-state" style={{ padding: '20px 0' }}>No duplicates to review.</div>
) : (
<div className="merge-candidate-list">
{candidates.map((c) => {
const verdict = (c.verdict || '').toLowerCase();
const confidencePct = c.confidence != null
? `${Math.round((c.confidence <= 1 ? c.confidence * 100 : c.confidence))}%`
: null;
const busy = decidingId === c.id;
return (
<div key={c.id} className="merge-candidate-card">
<div className="merge-candidate-people">
<div className="merge-candidate-person">
<div className="merge-candidate-name">{c.name_a || '(no name)'}</div>
<div className="merge-candidate-email">{c.email_a || '—'}</div>
</div>
<div className="merge-candidate-vs">vs</div>
<div className="merge-candidate-person">
<div className="merge-candidate-name">{c.name_b || '(no name)'}</div>
<div className="merge-candidate-email">{c.email_b || '—'}</div>
</div>
</div>
{c.context ? (
<div className="merge-candidate-context">{c.context}</div>
) : null}
<div className="merge-candidate-suggestion">
<span className={`badge ${verdict === 'same' ? 'badge-funded' : 'badge-other'}`}>
Suggestion: {c.verdict || 'unknown'}
</span>
{confidencePct ? (
<span className="merge-candidate-confidence">{confidencePct} confidence</span>
) : null}
</div>
{c.reason ? (
<div className="merge-candidate-reason">{c.reason}</div>
) : null}
<div className="merge-candidate-actions">
<button
onClick={() => decideCandidate(c, 'approve')}
disabled={busy}
>
Same person merge
</button>
<button
className="button-secondary"
onClick={() => decideCandidate(c, 'reject')}
disabled={busy}
>
Different keep separate
</button>
</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();
const [page, setPage] = useState('fundraising-grid');
const [toasts, setToasts] = useState([]);
const [sidebarHidden, setSidebarHidden] = useState(false);
const [gridViews, setGridViews] = useState(loadGridViews());
const [activeGridView, setActiveGridView] = useState('view-main');
const [gridUiAction, setGridUiAction] = useState(null);
const [sidebarContextMenu, setSidebarContextMenu] = useState(null);
useEffect(() => {
localStorage.setItem(GRID_VIEW_STORAGE_KEY, JSON.stringify(gridViews));
}, [gridViews]);
useEffect(() => {
if (!gridViews.find((v) => v.id === activeGridView)) {
setActiveGridView(gridViews[0]?.id || 'view-main');
}
}, [gridViews, activeGridView]);
useEffect(() => {
if (!sidebarContextMenu) return undefined;
const close = () => setSidebarContextMenu(null);
const onKeyDown = (e) => {
if (e.key === 'Escape') close();
};
window.addEventListener('click', close);
window.addEventListener('contextmenu', close);
window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('click', close);
window.removeEventListener('contextmenu', close);
window.removeEventListener('keydown', onKeyDown);
};
}, [sidebarContextMenu]);
const showToast = useCallback((message, type = 'info') => {
const id = Math.random();
setToasts(t => [...t, { id, message, type }]);
}, []);
if (!token) {
return <LoginPage />;
}
const handleLogout = () => {
logout();
setPage('fundraising-grid');
};
const openSidebarContextMenu = (event, payload) => {
event.preventDefault();
event.stopPropagation();
setSidebarContextMenu({
x: event.clientX,
y: event.clientY,
...payload
});
};
const applySidebarContextAction = (action) => {
const menu = sidebarContextMenu;
setSidebarContextMenu(null);
if (!menu) return;
const targetViewId = menu.viewId || activeGridView;
const targetView = gridViews.find((v) => v.id === targetViewId);
const isProtected = PROTECTED_VIEW_IDS.has(targetViewId);
if (action === 'save-active-view') {
setPage('fundraising-grid');
setActiveGridView(targetViewId);
setGridUiAction('save-active-view');
return;
}
if (action === 'save-as-new-view') {
setPage('fundraising-grid');
if (targetViewId) setActiveGridView(targetViewId);
setGridUiAction('open-save-view');
return;
}
if (action === 'rename-view') {
if (!targetView) return;
if (isProtected) {
showToast('Default views cannot be renamed', 'error');
return;
}
const nextName = window.prompt('Rename view', targetView.name || '');
if (nextName === null) return;
const trimmed = String(nextName).trim();
if (!trimmed) {
showToast('View name is required', 'error');
return;
}
setGridViews((prev) => prev.map((v) => (v.id === targetViewId ? { ...v, name: trimmed } : v)));
showToast('View renamed', 'success');
return;
}
if (action === 'delete-view') {
if (!targetView) return;
if (isProtected) {
showToast('Default views cannot be deleted', 'error');
return;
}
const confirmed = window.confirm(`Delete saved view "${targetView.name}"?`);
if (!confirmed) return;
setGridViews((prev) => prev.filter((v) => v.id !== targetViewId));
if (activeGridView === targetViewId) {
setActiveGridView('view-main');
}
showToast('View deleted', 'success');
}
};
return (
<div className="app-container">
<div
className={`sidebar ${sidebarHidden ? 'hidden' : ''}`}
onContextMenu={(e) => {
e.preventDefault();
}}
>
<div className="sidebar-header-row">
<div className="sidebar-header" aria-label="Ten31">
<span className="sidebar-logo">
<img className="sidebar-logo-img" src="/assets/ten31-inverted-square.png" alt="Ten31" />
</span>
</div>
<button className="sidebar-toggle" onClick={() => setSidebarHidden(true)}>Hide</button>
</div>
<div className="nav-items">
<button
className={`nav-item ${page === 'fundraising-grid' ? 'active' : ''}`}
onClick={() => setPage('fundraising-grid')}
onContextMenu={(e) => openSidebarContextMenu(e, { kind: 'fundraising-root' })}
>
<span className="nav-item-icon"></span> Fundraising Grid
</button>
<div className="sub-nav-items">
{gridViews.map((v) => (
<button
key={v.id}
className={`sub-nav-item ${activeGridView === v.id ? 'active' : ''}`}
onClick={() => {
setPage('fundraising-grid');
setActiveGridView(v.id);
}}
onContextMenu={(e) => openSidebarContextMenu(e, { kind: 'view', viewId: v.id })}
>
{v.name}
</button>
))}
</div>
<button className={`nav-item ${page === 'dashboard' ? 'active' : ''}`} onClick={() => setPage('dashboard')}>
<span className="nav-item-icon"></span> Dashboard
</button>
<button className={`nav-item ${page === 'contacts' ? 'active' : ''}`} onClick={() => setPage('contacts')}>
<span className="nav-item-icon"></span> Contacts
</button>
<button className={`nav-item ${page === 'pipeline' ? 'active' : ''}`} onClick={() => setPage('pipeline')}>
<span className="nav-item-icon"></span> Pipeline
</button>
{user?.role === 'admin' && (
<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 === 'thesis-workshop' ? 'active' : ''}`} onClick={() => setPage('thesis-workshop')}>
<span className="nav-item-icon"></span> Thesis Workshop
</button>
<button className={`nav-item ${page === 'outreach' ? 'active' : ''}`} onClick={() => setPage('outreach')}>
<span className="nav-item-icon"></span> Outreach
</button>
<button className={`nav-item ${page === 'system-status' ? 'active' : ''}`} onClick={() => setPage('system-status')}>
<span className="nav-item-icon"></span> System Status
</button>
{user?.role === 'admin' && (
<button className={`nav-item ${page === 'email-capture' ? 'active' : ''}`} onClick={() => setPage('email-capture')}>
<span className="nav-item-icon"></span> Email Capture
</button>
)}
<button className={`nav-item ${page === 'feature-requests' ? 'active' : ''}`} onClick={() => setPage('feature-requests')}>
<span className="nav-item-icon"></span> Feedback
</button>
<button className={`nav-item ${page === 'instructions' ? 'active' : ''}`} onClick={() => setPage('instructions')}>
<span className="nav-item-icon"></span> Instructions
</button>
<button className={`nav-item ${page === 'settings' ? 'active' : ''}`} onClick={() => setPage('settings')}>
<span className="nav-item-icon"></span> Settings
</button>
</div>
<div className="sidebar-footer">
<button className="logout-btn" onClick={handleLogout}>Logout</button>
</div>
</div>
<div className="main-content">
<div className="header">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{sidebarHidden && <button className="sidebar-toggle" onClick={() => setSidebarHidden(false)}>Show Menu</button>}
<div className="header-title">
{page === 'fundraising-grid' && 'Fundraising Grid'}
{page === 'dashboard' && 'Dashboard'}
{page === 'contacts' && 'Contacts'}
{page === 'pipeline' && 'Pipeline'}
{page === 'communications' && 'Communications'}
{page === 'thesis' && 'Thesis'}
{page === 'thesis-workshop' && 'Thesis Workshop'}
{page === 'outreach' && 'Outreach'}
{page === 'system-status' && 'System Status'}
{page === 'email-capture' && 'Email Capture'}
{page === 'feature-requests' && 'Feature Requests'}
{page === 'instructions' && 'Instructions'}
{page === 'settings' && 'Settings'}
</div>
</div>
<div className="user-info">
{user?.full_name || user?.username}{MOCK_MODE ? ' · Mock Mode' : ''}
</div>
</div>
<div className="content">
{page === 'fundraising-grid' && (
<FundraisingGridPage
user={user}
token={token}
onShowToast={showToast}
views={gridViews}
activeView={activeGridView}
setActiveView={setActiveGridView}
setViews={setGridViews}
uiAction={gridUiAction}
onUiActionHandled={() => setGridUiAction(null)}
/>
)}
{page === 'dashboard' && <DashboardPage token={token} />}
{page === 'contacts' && <ContactsPage token={token} onShowToast={showToast} />}
{page === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} />}
{page === 'communications' && <CommunicationsPage token={token} user={user} onShowToast={showToast} />}
{page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />}
{page === 'thesis-workshop' && <ThesisWorkshopPage token={token} user={user} onShowToast={showToast} />}
{page === 'outreach' && <OutreachPage token={token} user={user} onShowToast={showToast} />}
{page === 'system-status' && <SystemStatusPage token={token} user={user} onShowToast={showToast} />}
{page === 'email-capture' && <EmailCapturePage token={token} user={user} onShowToast={showToast} />}
{page === 'feature-requests' && <FeatureRequestsPage token={token} onShowToast={showToast} user={user} />}
{page === 'instructions' && <InstructionsPage />}
{page === 'settings' && (
<SettingsPage
token={token}
onShowToast={showToast}
user={user}
onOpenAirtableImport={() => {
setPage('fundraising-grid');
setGridUiAction('open-import');
}}
/>
)}
</div>
</div>
{toasts.map(toast => (
<Toast
key={toast.id}
message={toast.message}
type={toast.type}
onClose={() => setToasts(t => t.filter(x => x.id !== toast.id))}
/>
))}
{sidebarContextMenu && (
<div className="context-menu" style={{ left: `${sidebarContextMenu.x}px`, top: `${sidebarContextMenu.y}px` }} onClick={(e) => e.stopPropagation()}>
<button className="context-menu-item" onClick={() => applySidebarContextAction('save-active-view')}>Save Current View</button>
<button className="context-menu-item" onClick={() => applySidebarContextAction('save-as-new-view')}>Save as New View</button>
{sidebarContextMenu.kind === 'view' && (
<>
<div className="context-menu-sep" />
<button className="context-menu-item" onClick={() => applySidebarContextAction('rename-view')}>Rename View</button>
<button className="context-menu-item" onClick={() => applySidebarContextAction('delete-view')}>Delete View</button>
</>
)}
</div>
)}
</div>
);
};
ReactDOM.render(
<AuthProvider>
<AppErrorBoundary>
<App />
</AppErrorBoundary>
</AuthProvider>,
document.getElementById('root')
);
</script>
</body>
</html>