c7ce44d963
Workstream A–C substrate for the Ten31 agentic system: - A1: docs/crm-overview.md; CLAUDE.md conventions + guardrail #9 - A2: additive/reversible core migration (canonical_entities, entity_links, interaction_log, relationship_edges, soft-delete) + ledgered runner - B1/B3: chunking + deterministic entity resolution (backend/ingest) - B2: dense (bge-m3) + BM25 sparse ingest to Qdrant crm_chunks - C: CRM MCP server (reads, retrieval modes, logged writes) — no outbound tools - docs: redaction/re-hydration, Gmail enablement runbook - synthetic test data; .env.example; housekeeping (.gitignore, untrack crm.db, drop legacy files + start9/0.3.5) Verified end-to-end on synthetic data + live Sparks (hybrid > dense on entity queries). Real backfill runs on Ten31 infra; index holds synthetic data only. Branch snapshot also captures pre-existing working-tree changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8922 lines
471 KiB
HTML
8922 lines
471 KiB
HTML
<!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">
|
||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></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;
|
||
}
|
||
|
||
.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 [showForm, setShowForm] = useState(false);
|
||
const [formData, setFormData] = useState({ contact_type: 'prospect', status: 'active', source: '', linkedin_url: '', city: '', state: '', country: '', location_query: '' });
|
||
const [formError, setFormError] = useState('');
|
||
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 handleAddContact = async (e) => {
|
||
e.preventDefault();
|
||
setFormError('');
|
||
|
||
try {
|
||
await api('/api/contacts', {
|
||
method: 'POST',
|
||
body: JSON.stringify(formData)
|
||
}, token);
|
||
|
||
setShowForm(false);
|
||
setFormData({ contact_type: 'prospect', status: 'active', source: '', linkedin_url: '', city: '', state: '', country: '', location_query: '' });
|
||
|
||
// Refresh list
|
||
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);
|
||
onShowToast('Contact created successfully', 'success');
|
||
} catch (err) {
|
||
setFormError(err.message);
|
||
}
|
||
};
|
||
|
||
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)}
|
||
/>
|
||
<button onClick={() => setShowForm(true)}>+ Add Contact</button>
|
||
</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>
|
||
|
||
{showForm && (
|
||
<div className="modal-overlay">
|
||
<div className="modal">
|
||
<div className="modal-header">Add New Contact</div>
|
||
{formError && <div className="toast error" style={{ position: 'static', marginBottom: '16px' }}>{formError}</div>}
|
||
<form onSubmit={handleAddContact}>
|
||
<div className="form-group">
|
||
<label className="form-label">First Name *</label>
|
||
<input
|
||
type="text"
|
||
className="text-input"
|
||
value={formData.first_name || ''}
|
||
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Last Name *</label>
|
||
<input
|
||
type="text"
|
||
className="text-input"
|
||
value={formData.last_name || ''}
|
||
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Email</label>
|
||
<input
|
||
type="email"
|
||
className="text-input"
|
||
value={formData.email || ''}
|
||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Phone</label>
|
||
<input
|
||
type="tel"
|
||
className="text-input"
|
||
value={formData.phone || ''}
|
||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||
/>
|
||
</div>
|
||
<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 })}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Organization</label>
|
||
<input
|
||
type="text"
|
||
className="text-input"
|
||
value={formData.organization || ''}
|
||
onChange={(e) => setFormData({ ...formData, organization: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Lead Source</label>
|
||
<input
|
||
type="text"
|
||
className="text-input"
|
||
value={formData.source || ''}
|
||
onChange={(e) => setFormData({ ...formData, source: e.target.value })}
|
||
placeholder="Intro source, conference, referral, etc."
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">LinkedIn URL</label>
|
||
<input
|
||
type="url"
|
||
className="text-input"
|
||
value={formData.linkedin_url || ''}
|
||
onChange={(e) => setFormData({ ...formData, linkedin_url: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">City</label>
|
||
<input
|
||
type="text"
|
||
className="text-input"
|
||
value={formData.city || ''}
|
||
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">State</label>
|
||
<input
|
||
type="text"
|
||
className="text-input"
|
||
value={formData.state || ''}
|
||
onChange={(e) => setFormData({ ...formData, state: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Country</label>
|
||
<input
|
||
type="text"
|
||
className="text-input"
|
||
value={formData.country || ''}
|
||
onChange={(e) => setFormData({ ...formData, country: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Contact Type</label>
|
||
<select
|
||
className="select-input"
|
||
value={formData.contact_type}
|
||
onChange={(e) => setFormData({ ...formData, 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={formData.status || ''}
|
||
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
|
||
>
|
||
<option value="active">Active</option>
|
||
<option value="inactive">Inactive</option>
|
||
<option value="prospect">Prospect</option>
|
||
</select>
|
||
</div>
|
||
<div className="form-actions">
|
||
<button type="button" className="button-secondary" onClick={() => setShowForm(false)}>Cancel</button>
|
||
<button type="submit">Add Contact</button>
|
||
</div>
|
||
</form>
|
||
</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.lp_profile && (
|
||
<div className="detail-section">
|
||
<div className="detail-section-title">LP Profile</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Commitment</span>
|
||
<span className="detail-value">{formatCurrencyLong(details.lp_profile.commitment_amount)}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Funded</span>
|
||
<span className="detail-value">{formatCurrencyLong(details.lp_profile.funded_amount)}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Fund</span>
|
||
<span className="detail-value">{details.lp_profile.fund_name || '-'}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Docs Signed</span>
|
||
<span className="detail-value">{details.lp_profile.legal_docs_signed ? '✓' : '✗'}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Wire Received</span>
|
||
<span className="detail-value">{details.lp_profile.wire_received ? '✓' : '✗'}</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 [contacts, setContacts] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [showForm, setShowForm] = useState(false);
|
||
const [formData, setFormData] = useState({ stage: 'lead', priority: 'medium', contact_id: '' });
|
||
const [formError, setFormError] = useState('');
|
||
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, contactResult] = await Promise.all([
|
||
api('/api/opportunities?limit=1000', {}, token),
|
||
api('/api/contacts?limit=1000', {}, token)
|
||
]);
|
||
setOpportunities(oppResult.data || []);
|
||
setContacts(contactResult.data || []);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to load pipeline'), 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
fetchOpportunities();
|
||
}, [token, onShowToast]);
|
||
|
||
const handleAddOpportunity = async (e) => {
|
||
e.preventDefault();
|
||
setFormError('');
|
||
|
||
try {
|
||
await api('/api/opportunities', {
|
||
method: 'POST',
|
||
body: JSON.stringify(formData)
|
||
}, token);
|
||
|
||
setShowForm(false);
|
||
setFormData({ stage: 'lead', priority: 'medium', contact_id: '' });
|
||
|
||
const result = await api('/api/opportunities?limit=1000', {}, token);
|
||
setOpportunities(result.data || []);
|
||
onShowToast('Opportunity created', 'success');
|
||
} catch (err) {
|
||
setFormError(err.message);
|
||
}
|
||
};
|
||
|
||
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>
|
||
<button onClick={() => setShowForm(true)}>+ New Opportunity</button>
|
||
</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>
|
||
</>
|
||
)}
|
||
|
||
{showForm && (
|
||
<div className="modal-overlay">
|
||
<div className="modal">
|
||
<div className="modal-header">New Opportunity</div>
|
||
{formError && <div className="toast error" style={{ position: 'static', marginBottom: '16px' }}>{formError}</div>}
|
||
<form onSubmit={handleAddOpportunity}>
|
||
<div className="form-group">
|
||
<label className="form-label">Opportunity Name *</label>
|
||
<input
|
||
type="text"
|
||
className="text-input"
|
||
value={formData.name || ''}
|
||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Contact *</label>
|
||
<select
|
||
className="select-input"
|
||
value={formData.contact_id || ''}
|
||
onChange={(e) => setFormData({ ...formData, contact_id: e.target.value })}
|
||
required
|
||
>
|
||
<option value="">Select contact</option>
|
||
{contacts.map((c) => (
|
||
<option key={c.id} value={c.id}>{contactName(c)}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Stage</label>
|
||
<select
|
||
className="select-input"
|
||
value={formData.stage}
|
||
onChange={(e) => setFormData({ ...formData, stage: e.target.value })}
|
||
>
|
||
{stages.map(s => (
|
||
<option key={s} value={s}>{s.replace(/_/g, ' ')}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Expected Amount</label>
|
||
<input
|
||
type="number"
|
||
className="text-input"
|
||
value={formData.expected_amount || ''}
|
||
onChange={(e) => setFormData({ ...formData, expected_amount: parseFloat(e.target.value) })}
|
||
/>
|
||
</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">Fund Name</label>
|
||
<input
|
||
type="text"
|
||
className="text-input"
|
||
value={formData.fund_name || ''}
|
||
onChange={(e) => setFormData({ ...formData, fund_name: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="form-actions">
|
||
<button type="button" className="button-secondary" onClick={() => setShowForm(false)}>Cancel</button>
|
||
<button type="submit">Create</button>
|
||
</div>
|
||
</form>
|
||
</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, onShowToast }) => {
|
||
const [communications, setCommunications] = useState([]);
|
||
const [contacts, setContacts] = useState([]);
|
||
const [investorNames, setInvestorNames] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [type, setType] = useState('');
|
||
const [search, setSearch] = useState('');
|
||
const [showForm, setShowForm] = useState(false);
|
||
const [formData, setFormData] = useState({ type: 'email', contact_id: '', investor_selection: '', investor_name_new: '', append_note: true });
|
||
const [quickMapSearch, setQuickMapSearch] = useState('');
|
||
const [formError, setFormError] = useState('');
|
||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||
const NEW_INVESTOR_VALUE = '__new_investor__';
|
||
|
||
const rankedContacts = useMemo(() => {
|
||
const q = String(quickMapSearch || '').trim();
|
||
if (!q) return contacts;
|
||
return contacts
|
||
.map((c, idx) => {
|
||
const label = `${contactName(c)} ${c.organization_name || c.organization || ''}`.trim();
|
||
const score = fuzzyScore(q, label);
|
||
return { c, idx, score };
|
||
})
|
||
.filter((x) => x.score >= 0.45)
|
||
.sort((a, b) => (b.score - a.score) || (a.idx - b.idx))
|
||
.map((x) => x.c);
|
||
}, [contacts, quickMapSearch]);
|
||
|
||
const rankedInvestors = useMemo(() => {
|
||
const q = String(quickMapSearch || '').trim();
|
||
if (!q) return investorNames;
|
||
return investorNames
|
||
.map((name, idx) => ({ name, idx, score: fuzzyScore(q, name) }))
|
||
.filter((x) => x.score >= 0.45)
|
||
.sort((a, b) => (b.score - a.score) || (a.idx - b.idx))
|
||
.map((x) => x.name);
|
||
}, [investorNames, quickMapSearch]);
|
||
|
||
useEffect(() => {
|
||
const fetchComms = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const [commResult, contactResult, fundraisingResult] = await Promise.all([
|
||
api(`/api/communications?type=${type}&search=${search}&limit=200`, {}, token),
|
||
api('/api/contacts?limit=1000', {}, token),
|
||
api('/api/fundraising/state', {}, token)
|
||
]);
|
||
setCommunications(commResult.data || []);
|
||
setContacts(contactResult.data || []);
|
||
const names = Array.from(new Set(
|
||
((fundraisingResult?.data?.grid?.rows || [])
|
||
.map((r) => String(r?.investor_name || '').trim())
|
||
.filter(Boolean))
|
||
)).sort((a, b) => a.localeCompare(b));
|
||
setInvestorNames(names);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to load communications'), 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
fetchComms();
|
||
}, [token, type, search, onShowToast]);
|
||
|
||
const handleAddComm = async (e) => {
|
||
e.preventDefault();
|
||
setFormError('');
|
||
try {
|
||
const selected = contacts.find((c) => c.id === formData.contact_id);
|
||
if (!selected) {
|
||
setFormError('Contact is required');
|
||
return;
|
||
}
|
||
const selectedInvestor = String(formData.investor_selection || '').trim();
|
||
const newInvestorInput = String(formData.investor_name_new || '').trim();
|
||
const investorName = selectedInvestor === NEW_INVESTOR_VALUE ? newInvestorInput : selectedInvestor;
|
||
if (!investorName) {
|
||
setFormError('Investor mapping is required. Select an investor or create a new one.');
|
||
return;
|
||
}
|
||
const fullName = contactName(selected || {});
|
||
await api('/api/fundraising/log-communication', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
investor_name: investorName,
|
||
create_investor_if_missing: true,
|
||
contact: {
|
||
name: fullName,
|
||
email: selected?.email || '',
|
||
title: selected?.title || ''
|
||
},
|
||
type: formData.type || 'note',
|
||
subject: formData.subject || '',
|
||
body: formData.body || '',
|
||
outcome: formData.outcome || '',
|
||
next_action: formData.next_action || '',
|
||
next_action_date: formData.next_action_date || '',
|
||
append_note: !!formData.append_note
|
||
})
|
||
}, token);
|
||
setShowForm(false);
|
||
setFormData({ type: 'email', contact_id: '', investor_selection: '', investor_name_new: '', append_note: true });
|
||
setQuickMapSearch('');
|
||
const [result, fundraisingResult] = await Promise.all([
|
||
api(`/api/communications?type=${type}&search=${search}&limit=200`, {}, token),
|
||
api('/api/fundraising/state', {}, token)
|
||
]);
|
||
setCommunications(result.data || []);
|
||
const names = Array.from(new Set(
|
||
((fundraisingResult?.data?.grid?.rows || [])
|
||
.map((r) => String(r?.investor_name || '').trim())
|
||
.filter(Boolean))
|
||
)).sort((a, b) => a.localeCompare(b));
|
||
setInvestorNames(names);
|
||
onShowToast('Communication logged', 'success');
|
||
} catch (err) {
|
||
setFormError(getErrorMessage(err, 'Failed to log communication'));
|
||
}
|
||
};
|
||
|
||
const handleDeleteComm = async (id) => {
|
||
try {
|
||
await api(`/api/communications/${id}`, { method: 'DELETE' }, token);
|
||
setCommunications((prev) => prev.filter((c) => c.id !== id));
|
||
setConfirmDelete(null);
|
||
onShowToast('Communication deleted', 'success');
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to delete communication'), 'error');
|
||
}
|
||
};
|
||
|
||
const typeLabel = (v) => {
|
||
if (v === 'email') return 'Email';
|
||
if (v === 'call') return 'Call';
|
||
if (v === 'meeting') return 'Meeting';
|
||
if (v === 'text') return 'Text';
|
||
return 'Note';
|
||
};
|
||
|
||
return (
|
||
<div className="page-container">
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||
<h2 className="section-title">Communications</h2>
|
||
<button onClick={() => {
|
||
setFormError('');
|
||
setFormData({ type: 'email', contact_id: '', investor_selection: '', investor_name_new: '', append_note: true });
|
||
setQuickMapSearch('');
|
||
setShowForm(true);
|
||
}}>+ Log Communication</button>
|
||
</div>
|
||
<div className="section">
|
||
<div className="controls">
|
||
<input
|
||
type="text"
|
||
className="search-input"
|
||
placeholder="Search subject/body..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
/>
|
||
<select className="select-input" value={type} onChange={(e) => setType(e.target.value)}>
|
||
<option value="">All Types</option>
|
||
<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>
|
||
{loading ? (
|
||
<SkeletonBlock lines={8} />
|
||
) : communications.length === 0 ? (
|
||
<div className="empty-state">No communications</div>
|
||
) : (
|
||
<div className="timeline">
|
||
{communications.map((comm) => (
|
||
<div key={comm.id} className="timeline-item">
|
||
<div className="timeline-marker"></div>
|
||
<div className="timeline-content">
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '8px' }}>
|
||
<div className="timeline-header">{typeLabel(comm.type)} · {contactName(comm)}</div>
|
||
<button className="button-danger" style={{ padding: '4px 8px', fontSize: '11px' }} onClick={() => setConfirmDelete(comm.id)}>Delete</button>
|
||
</div>
|
||
<div className="timeline-meta">{formatDate(comm.communication_date)}</div>
|
||
{comm.subject && <div className="timeline-body">{comm.subject}</div>}
|
||
{comm.body && <div className="timeline-body" style={{ marginTop: '4px', color: '#9fb0c2' }}>{comm.body}</div>}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{showForm && (
|
||
<div className="modal-overlay">
|
||
<div className="modal">
|
||
<div className="modal-header">Log Communication</div>
|
||
{formError && <div className="toast error" style={{ position: 'static', marginBottom: '16px' }}>{formError}</div>}
|
||
<div className="form-help" style={{ marginBottom: '14px', padding: '10px', border: '1px solid #263548', borderRadius: '8px', background: '#0d1622' }}>
|
||
This writes a communication record to the shared timeline, updates <strong>Last Communication Date</strong> on the fundraising row, and can append a one-line summary into <strong>Notes / Communication / Outreach</strong>.
|
||
</div>
|
||
<form onSubmit={handleAddComm}>
|
||
<div className="form-group">
|
||
<label className="form-label">Quick Find</label>
|
||
<input
|
||
type="text"
|
||
className="text-input"
|
||
placeholder="Search investor or contact (fuzzy)"
|
||
value={quickMapSearch}
|
||
onChange={(e) => setQuickMapSearch(e.target.value)}
|
||
/>
|
||
<div className="form-help">Use one search box to narrow both lists below. Selecting a contact auto-populates investor mapping when available.</div>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Contact *</label>
|
||
<select className="select-input" value={formData.contact_id || ''} onChange={(e) => {
|
||
const contactId = e.target.value;
|
||
const selected = contacts.find((c) => c.id === contactId);
|
||
const orgName = String(selected?.organization_name || selected?.organization || '').trim();
|
||
setFormData((f) => {
|
||
if (!orgName) return { ...f, contact_id: contactId };
|
||
if (investorNames.includes(orgName)) {
|
||
return { ...f, contact_id: contactId, investor_selection: orgName, investor_name_new: '' };
|
||
}
|
||
return { ...f, contact_id: contactId, investor_selection: NEW_INVESTOR_VALUE, investor_name_new: orgName };
|
||
});
|
||
}} required>
|
||
<option value="">Select contact</option>
|
||
{rankedContacts.map((c) => (
|
||
<option key={c.id} value={c.id}>
|
||
{contactName(c)}{(c.organization_name || c.organization) ? ` · ${c.organization_name || c.organization}` : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<div className="form-help">Person this communication is tied to.</div>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Investor Mapping *</label>
|
||
<select className="select-input" value={formData.investor_selection || ''} onChange={(e) => setFormData((f) => ({ ...f, investor_selection: e.target.value }))} required>
|
||
<option value="">Select investor</option>
|
||
{rankedInvestors.map((name) => <option key={name} value={name}>{name}</option>)}
|
||
<option value={NEW_INVESTOR_VALUE}>+ Create new investor...</option>
|
||
</select>
|
||
{formData.investor_selection === NEW_INVESTOR_VALUE && (
|
||
<input
|
||
type="text"
|
||
className="text-input"
|
||
placeholder="New investor name"
|
||
value={formData.investor_name_new || ''}
|
||
onChange={(e) => setFormData((f) => ({ ...f, investor_name_new: e.target.value }))}
|
||
required
|
||
/>
|
||
)}
|
||
<div className="form-help">Ensures this communication lands on the right fundraising row. New investor names are auto-created in Fundraising Grid.</div>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Type</label>
|
||
<select className="select-input" value={formData.type || 'note'} onChange={(e) => setFormData((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">Communication category for reporting and filtering.</div>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Summary</label>
|
||
<input type="text" className="text-input" placeholder="Short summary (used in timeline and notes append)" value={formData.subject || ''} onChange={(e) => setFormData((f) => ({ ...f, subject: e.target.value }))} />
|
||
<div className="form-help">This is not an email subject line. It is the headline summary shown in Communications.</div>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Details</label>
|
||
<textarea className="text-input" rows="4" placeholder="Full detail, context, and key points" value={formData.body || ''} onChange={(e) => setFormData((f) => ({ ...f, body: e.target.value }))} />
|
||
<div className="form-help">Saved in Communications history only (not appended to investor notes unless your summary references it).</div>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Outcome</label>
|
||
<input type="text" className="text-input" value={formData.outcome || ''} onChange={(e) => setFormData((f) => ({ ...f, outcome: e.target.value }))} />
|
||
<div className="form-help">Result of this touchpoint (for quick review later).</div>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Next Action</label>
|
||
<input type="text" className="text-input" value={formData.next_action || ''} onChange={(e) => setFormData((f) => ({ ...f, next_action: e.target.value }))} />
|
||
<div className="form-help">Explicit follow-up task (what should happen next).</div>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Next Action Date</label>
|
||
<input type="date" className="text-input" value={formData.next_action_date || ''} onChange={(e) => setFormData((f) => ({ ...f, next_action_date: e.target.value }))} />
|
||
<div className="form-help">Target date for the next action.</div>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">
|
||
<input type="checkbox" checked={!!formData.append_note} onChange={(e) => setFormData((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={() => setShowForm(false)}>Cancel</button>
|
||
<button type="submit">Log</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{confirmDelete && (
|
||
<ConfirmDialog
|
||
title="Delete Communication"
|
||
message="Are you sure?"
|
||
onConfirm={() => handleDeleteComm(confirmDelete)}
|
||
onCancel={() => setConfirmDelete(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const LPTrackerPage = ({ token, onShowToast }) => {
|
||
const [lps, setLps] = useState([]);
|
||
const [contacts, setContacts] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [search, setSearch] = useState('');
|
||
const [showForm, setShowForm] = useState(false);
|
||
const [formData, setFormData] = useState({ contact_id: '' });
|
||
const [formError, setFormError] = useState('');
|
||
const [selectedLP, setSelectedLP] = useState(null);
|
||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||
|
||
useEffect(() => {
|
||
const fetchLPs = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const [lpResult, contactResult] = await Promise.all([
|
||
api(`/api/lp-profiles?search=${search}`, {}, token),
|
||
api('/api/contacts?limit=1000', {}, token)
|
||
]);
|
||
setLps(lpResult.data || []);
|
||
setContacts(contactResult.data || []);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to load LP profiles'), 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
fetchLPs();
|
||
}, [token, search, onShowToast]);
|
||
|
||
const handleAddLP = async (e) => {
|
||
e.preventDefault();
|
||
setFormError('');
|
||
|
||
try {
|
||
await api('/api/lp-profiles', {
|
||
method: 'POST',
|
||
body: JSON.stringify(formData)
|
||
}, token);
|
||
|
||
setShowForm(false);
|
||
setFormData({ contact_id: '' });
|
||
|
||
const result = await api(`/api/lp-profiles?search=${search}`, {}, token);
|
||
setLps(result.data || []);
|
||
onShowToast('LP profile created', 'success');
|
||
} catch (err) {
|
||
setFormError(err.message);
|
||
}
|
||
};
|
||
|
||
const handleDeleteLP = async (id) => {
|
||
if (!MOCK_MODE) {
|
||
onShowToast('LP delete endpoint is not available yet in backend', 'error');
|
||
return;
|
||
}
|
||
try {
|
||
await api(`/api/lp-profiles/${id}`, { method: 'DELETE' }, token);
|
||
setLps(lps.filter(l => l.id !== id));
|
||
setConfirmDelete(null);
|
||
setSelectedLP(null);
|
||
onShowToast('LP deleted', 'success');
|
||
} catch (err) {
|
||
onShowToast(err.message, 'error');
|
||
}
|
||
};
|
||
|
||
const totalCommitted = useMemo(() => lps.reduce((sum, lp) => sum + (lp.commitment_amount || 0), 0), [lps]);
|
||
const totalFunded = useMemo(() => lps.reduce((sum, lp) => sum + (lp.funded_amount || 0), 0), [lps]);
|
||
const avgCheck = lps.length > 0 ? totalCommitted / lps.length : 0;
|
||
|
||
return (
|
||
<div className="page-container">
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||
<h2 className="section-title">LP Tracker</h2>
|
||
<button onClick={() => setShowForm(true)}>+ Add LP Profile</button>
|
||
</div>
|
||
|
||
<div className="kpi-grid">
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Total Committed</div>
|
||
<div className="kpi-value">{formatCurrencyLong(totalCommitted)}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Total Funded</div>
|
||
<div className="kpi-value">{formatCurrencyLong(totalFunded)}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Avg Check Size</div>
|
||
<div className="kpi-value">{formatCurrencyLong(avgCheck)}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Number of LPs</div>
|
||
<div className="kpi-value">{lps.length}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="section">
|
||
<div className="controls">
|
||
<input
|
||
type="text"
|
||
className="search-input"
|
||
placeholder="Search LPs..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<SkeletonBlock lines={8} />
|
||
) : lps.length === 0 ? (
|
||
<div className="empty-state">No LP profiles</div>
|
||
) : (
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th>Name</th>
|
||
<th>Organization</th>
|
||
<th>Commitment</th>
|
||
<th>Funded</th>
|
||
<th>Docs</th>
|
||
<th>Wire</th>
|
||
<th>K1</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{lps.map(lp => (
|
||
<tr key={lp.id} onClick={() => setSelectedLP(lp)}>
|
||
<td>{contactName(lp)}</td>
|
||
<td>{lp.organization || lp.organization_name || '-'}</td>
|
||
<td>{formatCurrencyLong(lp.commitment_amount)}</td>
|
||
<td>{formatCurrencyLong(lp.funded_amount)}</td>
|
||
<td>{lp.legal_docs_signed ? '✓' : '✗'}</td>
|
||
<td>{lp.wire_received ? '✓' : '✗'}</td>
|
||
<td>{lp.k1_sent ? '✓' : '✗'}</td>
|
||
<td>
|
||
<button
|
||
className="button-danger"
|
||
style={{ padding: '4px 8px', fontSize: '11px' }}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setConfirmDelete(lp.id);
|
||
}}
|
||
>
|
||
Delete
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
|
||
{showForm && (
|
||
<div className="modal-overlay">
|
||
<div className="modal">
|
||
<div className="modal-header">Add LP Profile</div>
|
||
{formError && <div className="toast error" style={{ position: 'static', marginBottom: '16px' }}>{formError}</div>}
|
||
<form onSubmit={handleAddLP}>
|
||
<div className="form-group">
|
||
<label className="form-label">Contact *</label>
|
||
<select
|
||
className="select-input"
|
||
value={formData.contact_id || ''}
|
||
onChange={(e) => setFormData({ ...formData, contact_id: e.target.value })}
|
||
required
|
||
>
|
||
<option value="">Select contact</option>
|
||
{contacts.map((c) => (
|
||
<option key={c.id} value={c.id}>{contactName(c)}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Commitment Amount</label>
|
||
<input
|
||
type="number"
|
||
className="text-input"
|
||
value={formData.commitment_amount || ''}
|
||
onChange={(e) => setFormData({ ...formData, commitment_amount: parseFloat(e.target.value) })}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Funded Amount</label>
|
||
<input
|
||
type="number"
|
||
className="text-input"
|
||
value={formData.funded_amount || ''}
|
||
onChange={(e) => setFormData({ ...formData, funded_amount: parseFloat(e.target.value) })}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Fund Name</label>
|
||
<input
|
||
type="text"
|
||
className="text-input"
|
||
value={formData.fund_name || ''}
|
||
onChange={(e) => setFormData({ ...formData, fund_name: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.legal_docs_signed || false}
|
||
onChange={(e) => setFormData({ ...formData, legal_docs_signed: e.target.checked })}
|
||
/>
|
||
{' '}Docs Signed
|
||
</label>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.wire_received || false}
|
||
onChange={(e) => setFormData({ ...formData, wire_received: e.target.checked })}
|
||
/>
|
||
{' '}Wire Received
|
||
</label>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.k1_sent || false}
|
||
onChange={(e) => setFormData({ ...formData, k1_sent: e.target.checked })}
|
||
/>
|
||
{' '}K1 Sent
|
||
</label>
|
||
</div>
|
||
<div className="form-actions">
|
||
<button type="button" className="button-secondary" onClick={() => setShowForm(false)}>Cancel</button>
|
||
<button type="submit">Create</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{confirmDelete && (
|
||
<ConfirmDialog
|
||
title="Delete LP"
|
||
message="Are you sure?"
|
||
onConfirm={() => handleDeleteLP(confirmDelete)}
|
||
onCancel={() => setConfirmDelete(null)}
|
||
/>
|
||
)}
|
||
</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: '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;
|
||
}
|
||
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 };
|
||
};
|
||
|
||
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: incomingRows, views: incomingViews });
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns: incomingColumns, rows: 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 snapshot = JSON.stringify({ columns, rows, 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 },
|
||
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 }));
|
||
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 }));
|
||
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 : [];
|
||
const defaultName = `${row.investor_name || 'Investor'} Opportunity`;
|
||
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);
|
||
};
|
||
|
||
const submitCreateOpportunity = async () => {
|
||
if (!createOppContext?.investorName) return;
|
||
setCreateOppSubmitting(true);
|
||
try {
|
||
const contacts = Array.isArray(createOppContext.contacts) ? createOppContext.contacts : [];
|
||
const selected = contacts[createOppForm.contactIndex] || contacts[0] || null;
|
||
if (!selected) {
|
||
onShowToast('Add at least one contact on the investor row first', 'error');
|
||
return;
|
||
}
|
||
|
||
const allContactsResp = await api('/api/contacts?limit=1000', {}, token);
|
||
const allContacts = Array.isArray(allContactsResp?.data) ? allContactsResp.data : [];
|
||
const selectedEmail = String(selected.email || '').trim().toLowerCase();
|
||
const selectedName = String(selected.name || '').trim().toLowerCase();
|
||
const investorName = String(createOppContext.investorName || '').trim().toLowerCase();
|
||
|
||
let matched = null;
|
||
if (selectedEmail) {
|
||
matched = allContacts.find((c) => String(c.email || '').trim().toLowerCase() === selectedEmail) || null;
|
||
}
|
||
if (!matched && selectedName) {
|
||
matched = allContacts.find((c) => {
|
||
const n = `${c.first_name || ''} ${c.last_name || ''}`.trim().toLowerCase();
|
||
const org = String(c.organization_name || c.organization || '').trim().toLowerCase();
|
||
return n === selectedName && org === investorName;
|
||
}) || null;
|
||
}
|
||
|
||
let contactId = matched?.id || null;
|
||
if (!contactId) {
|
||
const split = splitFullName(selected.name || '');
|
||
const created = await api('/api/contacts', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
first_name: split.first,
|
||
last_name: split.last,
|
||
email: selected.email || '',
|
||
title: selected.title || '',
|
||
organization: createOppContext.investorName,
|
||
contact_type: 'investor',
|
||
status: 'active'
|
||
})
|
||
}, token);
|
||
contactId = created?.data?.id || null;
|
||
}
|
||
|
||
if (!contactId) throw new Error('Could not resolve contact');
|
||
|
||
await api('/api/opportunities', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
name: String(createOppForm.name || '').trim() || `${createOppContext.investorName} Opportunity`,
|
||
contact_id: contactId,
|
||
stage: createOppForm.stage || 'lead',
|
||
expected_amount: parseNumericInput(createOppForm.expected_amount),
|
||
probability: Number(createOppForm.probability) || 35,
|
||
fund_name: String(createOppForm.fund_name || '').trim(),
|
||
priority: rows.find((r) => r.id === createOppContext.rowId)?.priority ? 'high' : 'medium'
|
||
})
|
||
}, token);
|
||
|
||
onShowToast('Pipeline opportunity created', 'success');
|
||
setShowCreateOppModal(false);
|
||
setCreateOppContext(null);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to create pipeline opportunity'), 'error');
|
||
} finally {
|
||
setCreateOppSubmitting(false);
|
||
}
|
||
};
|
||
|
||
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 saveResponse = await api('/api/fundraising/state', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
grid: { columns: combinedColumns, rows: nextRows },
|
||
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: nextRows, views: nextViews });
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns: combinedColumns, rows: nextRows }));
|
||
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.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: '380px', 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 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"
|
||
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 5', 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: '', 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}>+ Row</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">Create Pipeline Opportunity</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 Name (optional)</label>
|
||
<input className="text-input" value={createOppForm.fund_name} onChange={(e) => setCreateOppForm((f) => ({ ...f, fund_name: e.target.value }))} />
|
||
</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 /> : 'Create'}
|
||
</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 [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 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();
|
||
fetchAuditLogs();
|
||
fetchAutomations();
|
||
fetchActivityFeed();
|
||
fetchSecurityStatus();
|
||
}, [user?.role, fetchBackupHistory, fetchBackupPolicy, 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 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: '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>
|
||
</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>
|
||
);
|
||
};
|
||
|
||
// ==================== 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 }]);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (page === 'lp-tracker') {
|
||
setPage('fundraising-grid');
|
||
}
|
||
}, [page]);
|
||
|
||
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>
|
||
<button className={`nav-item ${page === 'communications' ? 'active' : ''}`} onClick={() => setPage('communications')}>
|
||
<span className="nav-item-icon">◌</span> Communications
|
||
</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 === '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} 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>
|