27e9ea5b0b
v89 added the 'bot' role for the Matrix email-review bot's endpoints but kept it out of the UI, leaving no click-path to assign it. Add 'bot' to the Settings -> Admin edit-user role dropdown (the teammate-invite form stays member/admin only — provisioning an agent account is an admin re-classification of a dedicated user, not a teammate invite). The backend update validator already accepts 'bot'. Frontend-only, no schema change.
11028 lines
582 KiB
HTML
11028 lines
582 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">
|
||
<!-- Vendored + SRI-pinned (v0.1.0:82). These ship inside the s9pk and are served
|
||
same-origin from /assets/vendor/, so a CDN can never swap our prod deps (the
|
||
v78/v79 blank-screen class) and the box needs no outbound internet to render.
|
||
The integrity hashes are sha384 of the exact bytes below; regenerate with
|
||
`openssl dgst -sha384 -binary FILE | openssl base64 -A` if you re-vendor.
|
||
Babel stays on the 7.x line on purpose: Babel 8 defaults @babel/preset-react
|
||
to the automatic JSX runtime, which emits `import {jsx} from "react/jsx-runtime"`
|
||
— illegal in this classic (non-module) inline script and blanks the whole app.
|
||
7.x preset-react defaults to the classic runtime (React.createElement). -->
|
||
<script src="/assets/vendor/react-18.3.1.production.min.js"
|
||
integrity="sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"></script>
|
||
<script src="/assets/vendor/react-dom-18.3.1.production.min.js"
|
||
integrity="sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"></script>
|
||
<script src="/assets/vendor/babel-standalone-7.29.7.min.js"
|
||
integrity="sha384-ezQ6HS3FLspd9te19o2McUV6FAK091+GG7KO54f/R8DKgCDi7fULhapNrd5LY+vG"></script>
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap');
|
||
|
||
:root {
|
||
--bg-base: #0b1118;
|
||
--bg-panel: #111a27;
|
||
--bg-panel-elevated: #152233;
|
||
--bg-hover: #1b2a3a;
|
||
--border: #263548;
|
||
--text-primary: #e5edf5;
|
||
--text-secondary: #c7d3e0;
|
||
--text-muted: #8ea2b7;
|
||
--accent: #3b82c4;
|
||
--accent-strong: #2f6ea9;
|
||
--accent-soft: #3b82c422;
|
||
}
|
||
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
body {
|
||
font-family: 'IBM Plex Sans', 'Avenir Next', 'Segoe UI', sans-serif;
|
||
background:
|
||
radial-gradient(1200px 600px at 15% -10%, #1a3c5e44, transparent 60%),
|
||
radial-gradient(1000px 500px at 90% 0%, #27496b33, transparent 58%),
|
||
var(--bg-base);
|
||
color: var(--text-primary);
|
||
overflow: hidden;
|
||
letter-spacing: 0.01em;
|
||
text-rendering: optimizeLegibility;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
.app-container {
|
||
display: flex;
|
||
height: 100vh;
|
||
}
|
||
|
||
.sidebar {
|
||
width: 250px;
|
||
background: linear-gradient(180deg, #111a27 0%, #101926 100%);
|
||
border-right: 1px solid var(--border);
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 20px;
|
||
gap: 20px;
|
||
transition: width 0.2s ease, padding 0.2s ease, opacity 0.2s ease;
|
||
}
|
||
|
||
.sidebar.hidden {
|
||
width: 0;
|
||
padding: 0;
|
||
border-right: none;
|
||
opacity: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.sidebar-header {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: flex-start;
|
||
min-height: 30px;
|
||
}
|
||
|
||
.sidebar-logo {
|
||
width: 42px;
|
||
height: 42px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.sidebar-logo-img {
|
||
width: 42px;
|
||
height: 42px;
|
||
object-fit: contain;
|
||
display: block;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.sidebar-header-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.sidebar-toggle {
|
||
padding: 6px 10px;
|
||
background: #263548;
|
||
color: #c7d3e0;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.nav-items {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
flex: 1;
|
||
}
|
||
|
||
.nav-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: background-color 0.18s ease, transform 0.14s ease, box-shadow 0.18s ease;
|
||
border: none;
|
||
font-size: 14px;
|
||
color: #c7d3e0;
|
||
background: transparent;
|
||
width: 100%;
|
||
text-align: left;
|
||
}
|
||
|
||
.nav-item:hover {
|
||
background-color: #263548;
|
||
transform: translateX(1px);
|
||
}
|
||
|
||
.nav-item.active {
|
||
background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%);
|
||
color: white;
|
||
box-shadow: inset 0 0 0 1px #6ea6d455;
|
||
}
|
||
|
||
.nav-item-icon {
|
||
font-size: 14px;
|
||
min-width: 24px;
|
||
color: #89a9c4;
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
font-weight: 600;
|
||
line-height: 1;
|
||
}
|
||
|
||
.nav-item.active .nav-item-icon {
|
||
color: #ffffff;
|
||
}
|
||
|
||
.sub-nav-items {
|
||
margin: -4px 0 6px 32px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.sub-nav-item {
|
||
border: none;
|
||
background: transparent;
|
||
color: #8ea2b7;
|
||
text-align: left;
|
||
padding: 6px 8px;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
transition: background-color 0.15s ease, color 0.15s ease;
|
||
}
|
||
|
||
.sub-nav-item:hover {
|
||
background: #263548;
|
||
color: #e5edf5;
|
||
}
|
||
|
||
.sub-nav-item.active {
|
||
background: linear-gradient(180deg, #2f6ea9 0%, #295f91 100%);
|
||
color: #fff;
|
||
}
|
||
|
||
.column-header-inner {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
min-height: 20px;
|
||
padding-right: 8px;
|
||
gap: 7px;
|
||
}
|
||
|
||
.column-drag-indicator {
|
||
color: #7f95ab;
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
font-size: 11px;
|
||
line-height: 1;
|
||
letter-spacing: -1px;
|
||
opacity: 0.9;
|
||
pointer-events: none;
|
||
user-select: none;
|
||
}
|
||
|
||
.table th.column-dragging {
|
||
opacity: 0.55;
|
||
}
|
||
|
||
.table th.column-drag-over {
|
||
box-shadow: inset -3px 0 0 #4a9adf;
|
||
background: #172635;
|
||
}
|
||
|
||
.column-resize-handle {
|
||
position: absolute;
|
||
top: -8px;
|
||
right: -6px;
|
||
width: 12px;
|
||
height: calc(100% + 16px);
|
||
cursor: col-resize;
|
||
background: transparent;
|
||
z-index: 2;
|
||
}
|
||
|
||
.column-resize-handle::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 5px;
|
||
top: 4px;
|
||
bottom: 4px;
|
||
width: 2px;
|
||
border-radius: 1px;
|
||
background: #36506a;
|
||
opacity: 0.55;
|
||
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||
}
|
||
|
||
.column-resize-handle:hover {
|
||
background: #3b82c455;
|
||
}
|
||
|
||
.column-resize-handle:hover::before {
|
||
opacity: 1;
|
||
background: #78acd8;
|
||
}
|
||
|
||
.grid-cell-selected {
|
||
outline: 2px solid #4a9adf;
|
||
outline-offset: -2px;
|
||
background: #1a2a3c;
|
||
box-shadow: inset 0 0 0 1px #9ac7ec40;
|
||
}
|
||
|
||
.context-menu {
|
||
position: fixed;
|
||
z-index: 5000;
|
||
min-width: 220px;
|
||
background: #0b1118;
|
||
border: 1px solid #263548;
|
||
border-radius: 8px;
|
||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
|
||
padding: 6px;
|
||
}
|
||
|
||
.context-menu-item {
|
||
width: 100%;
|
||
border: none;
|
||
background: transparent;
|
||
color: #e5edf5;
|
||
padding: 8px 10px;
|
||
text-align: left;
|
||
font-size: 13px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.context-menu-item:hover {
|
||
background: #263548;
|
||
}
|
||
|
||
.context-menu-item.context-menu-danger {
|
||
color: #f3b2b2;
|
||
}
|
||
|
||
.context-menu-item.context-menu-danger:hover {
|
||
background: #3b1b24;
|
||
color: #ffd3d3;
|
||
}
|
||
|
||
.context-menu-sep {
|
||
height: 1px;
|
||
background: #263548;
|
||
margin: 6px 4px;
|
||
}
|
||
|
||
.sidebar-footer {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid #263548;
|
||
}
|
||
|
||
.logout-btn {
|
||
padding: 10px 12px;
|
||
background-color: #7f1d1d;
|
||
border: none;
|
||
border-radius: 6px;
|
||
color: #fca5a5;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
transition: background-color 0.2s;
|
||
}
|
||
|
||
.logout-btn:hover {
|
||
background-color: #991b1b;
|
||
}
|
||
|
||
.main-content {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.header {
|
||
background: linear-gradient(180deg, #121d2b 0%, #101926 100%);
|
||
border-bottom: 1px solid #263548;
|
||
padding: 20px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
box-shadow: 0 1px 0 #ffffff08 inset;
|
||
}
|
||
|
||
.header-title {
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.user-info {
|
||
font-size: 13px;
|
||
color: #8ea2b7;
|
||
}
|
||
|
||
.content {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 20px;
|
||
}
|
||
|
||
.content::-webkit-scrollbar {
|
||
width: 10px;
|
||
height: 10px;
|
||
}
|
||
|
||
.content::-webkit-scrollbar-thumb {
|
||
background: #2a3a4d;
|
||
border-radius: 20px;
|
||
border: 2px solid #101926;
|
||
}
|
||
|
||
.content::-webkit-scrollbar-track {
|
||
background: #0f1723;
|
||
}
|
||
|
||
.page-container {
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.kpi-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
gap: 16px;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.kpi-card {
|
||
background-color: #111a27;
|
||
border: 1px solid #263548;
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.2s ease;
|
||
}
|
||
|
||
.kpi-card:hover {
|
||
transform: translateY(-1px);
|
||
border-color: #35506a;
|
||
box-shadow: 0 10px 20px rgba(7, 17, 30, 0.35);
|
||
}
|
||
|
||
.kpi-label {
|
||
font-size: 12px;
|
||
color: #8ea2b7;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.kpi-value {
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
color: #3b82c4;
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
letter-spacing: 0;
|
||
}
|
||
|
||
.kpi-subtitle {
|
||
font-size: 11px;
|
||
color: #70859b;
|
||
}
|
||
|
||
.section {
|
||
background-color: #111a27;
|
||
border: 1px solid #263548;
|
||
border-radius: 8px;
|
||
padding: 20px;
|
||
margin-bottom: 20px;
|
||
box-shadow: 0 14px 26px rgba(2, 12, 24, 0.28), inset 0 1px 0 #ffffff07;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
margin-bottom: 16px;
|
||
color: #e5edf5;
|
||
}
|
||
|
||
.controls {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.search-input, .text-input, .select-input {
|
||
padding: 10px 12px;
|
||
background-color: #0d1622;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
color: var(--text-primary);
|
||
font-size: 13px;
|
||
flex: 1;
|
||
min-width: 200px;
|
||
}
|
||
|
||
.search-input:focus, .text-input:focus, .select-input:focus {
|
||
outline: none;
|
||
border-color: #3b82c4;
|
||
background-color: #111a27;
|
||
}
|
||
|
||
.text-input, .select-input {
|
||
min-width: 150px;
|
||
}
|
||
|
||
button {
|
||
padding: 10px 16px;
|
||
background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%);
|
||
border: none;
|
||
border-radius: 6px;
|
||
color: white;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
transition: background-color 0.15s ease, transform 0.12s ease, box-shadow 0.15s ease;
|
||
font-weight: 500;
|
||
font-family: 'IBM Plex Sans', 'Avenir Next', 'Segoe UI', sans-serif;
|
||
letter-spacing: 0.01em;
|
||
}
|
||
|
||
button:hover {
|
||
background-color: #2f6ea9;
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 6px 14px rgba(12, 40, 68, 0.35);
|
||
}
|
||
|
||
button:active {
|
||
transform: translateY(0);
|
||
box-shadow: none;
|
||
}
|
||
|
||
button:disabled {
|
||
background-color: #3b4e63;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.button-secondary {
|
||
background: #1b2837;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.button-secondary:hover {
|
||
background-color: #3b4e63;
|
||
}
|
||
|
||
.button-danger {
|
||
background-color: #dc2626;
|
||
}
|
||
|
||
.button-danger:hover {
|
||
background-color: #b91c1c;
|
||
}
|
||
|
||
.table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
|
||
.fundraising-table {
|
||
border-collapse: separate;
|
||
border-spacing: 0;
|
||
table-layout: fixed;
|
||
}
|
||
|
||
.fundraising-table.compact-rows td {
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
max-height: 34px;
|
||
padding-top: 6px;
|
||
padding-bottom: 6px;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.compact-cell-preview {
|
||
display: block;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.grid-table-shell {
|
||
overflow: auto;
|
||
max-height: calc(100vh - 280px);
|
||
border: 1px solid #263548;
|
||
border-radius: 10px;
|
||
background: #0c141f;
|
||
box-shadow: inset 0 1px 0 #ffffff06;
|
||
}
|
||
|
||
.grid-table-shell::-webkit-scrollbar {
|
||
height: 11px;
|
||
}
|
||
|
||
.grid-table-shell::-webkit-scrollbar-thumb {
|
||
background: #2b3f54;
|
||
border-radius: 20px;
|
||
border: 2px solid #0f1723;
|
||
}
|
||
|
||
.grid-table-shell::-webkit-scrollbar-track {
|
||
background: #0c141f;
|
||
}
|
||
|
||
.table th {
|
||
background-color: #0d1622;
|
||
padding: 11px 12px;
|
||
text-align: left;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
border-bottom: 1px solid var(--border);
|
||
border-right: 1px solid var(--border);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 3;
|
||
box-shadow: 0 1px 0 #1b2a3a;
|
||
}
|
||
|
||
.fundraising-table thead th {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 5;
|
||
}
|
||
|
||
.table th:hover {
|
||
color: #c7d3e0;
|
||
}
|
||
|
||
.table td {
|
||
padding: 9px 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
border-right: 1px solid var(--border);
|
||
font-size: 13px;
|
||
font-variant-numeric: tabular-nums;
|
||
line-height: 1.4;
|
||
transition: background-color 0.12s ease, box-shadow 0.14s ease;
|
||
}
|
||
|
||
.table th:last-child,
|
||
.table td:last-child {
|
||
border-right: none;
|
||
}
|
||
|
||
.table tbody tr:nth-child(even) td {
|
||
background: #101b28;
|
||
}
|
||
|
||
.table tr:hover {
|
||
background-color: #172435;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.table tr:hover td {
|
||
background-color: #172435;
|
||
}
|
||
|
||
.table th.sticky-col {
|
||
position: sticky;
|
||
top: 0;
|
||
left: 0;
|
||
z-index: 8;
|
||
box-shadow: 1px 0 0 #263548, 0 1px 0 #1b2a3a;
|
||
}
|
||
|
||
.table td.sticky-col {
|
||
position: sticky;
|
||
left: 0;
|
||
z-index: 2;
|
||
background: #0d1622;
|
||
box-shadow: 1px 0 0 #263548;
|
||
}
|
||
|
||
.table tbody tr:nth-child(even) td.sticky-col {
|
||
background: #101b28;
|
||
}
|
||
|
||
.table tr:hover td.sticky-col {
|
||
background-color: #172435;
|
||
}
|
||
|
||
.fundraising-table tfoot td {
|
||
position: sticky;
|
||
bottom: 0;
|
||
z-index: 4;
|
||
background: #0f1b2a;
|
||
border-top: 1px solid #2a3d52;
|
||
box-shadow: 0 -1px 0 #1d2c3d;
|
||
}
|
||
|
||
.fundraising-table tfoot td.sticky-col {
|
||
z-index: 7;
|
||
left: 0;
|
||
box-shadow: 1px 0 0 #263548, 0 -1px 0 #1d2c3d;
|
||
background: #0f1b2a;
|
||
}
|
||
|
||
.badge {
|
||
display: inline-block;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.badge-investor {
|
||
background-color: #10b98122;
|
||
color: #6ee7b7;
|
||
}
|
||
|
||
.badge-prospect {
|
||
background-color: #3b82c422;
|
||
color: #93c5fd;
|
||
}
|
||
|
||
.badge-advisor {
|
||
background-color: #f59e0b22;
|
||
color: #fcd34d;
|
||
}
|
||
|
||
.badge-other {
|
||
background-color: #70859b44;
|
||
color: #c7d3e0;
|
||
}
|
||
|
||
.badge-lead {
|
||
background-color: #ec407a22;
|
||
color: #f48fb1;
|
||
}
|
||
|
||
.badge-outreach {
|
||
background-color: #ff9800aa;
|
||
color: #fff3cd;
|
||
}
|
||
|
||
.badge-meeting {
|
||
background-color: #2196f322;
|
||
color: #90caf9;
|
||
}
|
||
|
||
.badge-due-diligence {
|
||
background-color: #2563eb22;
|
||
color: #93c5fd;
|
||
}
|
||
|
||
.badge-committed {
|
||
background-color: #ff5722aa;
|
||
color: #ffccbc;
|
||
}
|
||
|
||
.badge-funded {
|
||
background-color: #4caf5022;
|
||
color: #a5d6a7;
|
||
}
|
||
|
||
.badge-high {
|
||
background-color: #dc262622;
|
||
color: #fca5a5;
|
||
}
|
||
|
||
.badge-medium {
|
||
background-color: #f59e0b22;
|
||
color: #fcd34d;
|
||
}
|
||
|
||
.badge-low {
|
||
background-color: #6b7280aa;
|
||
color: #d1d5db;
|
||
}
|
||
|
||
.modal-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: rgba(0, 0, 0, 0.56);
|
||
backdrop-filter: blur(3px);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.modal {
|
||
background-color: #111a27;
|
||
border: 1px solid #263548;
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
max-width: 500px;
|
||
width: 90%;
|
||
max-height: 80vh;
|
||
overflow-y: auto;
|
||
animation: modalIn 0.22s ease-out;
|
||
box-shadow: 0 24px 56px rgba(1, 8, 17, 0.5), inset 0 1px 0 #ffffff08;
|
||
}
|
||
|
||
.modal-header {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.form-label {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: #c7d3e0;
|
||
}
|
||
|
||
.form-help {
|
||
font-size: 12px;
|
||
color: #8ea2b7;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.form-error {
|
||
font-size: 12px;
|
||
color: #fca5a5;
|
||
}
|
||
|
||
.form-actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-top: 24px;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.toast {
|
||
position: fixed;
|
||
bottom: 20px;
|
||
right: 20px;
|
||
background-color: #111a27;
|
||
border: 1px solid #263548;
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
max-width: 400px;
|
||
z-index: 2000;
|
||
animation: slideIn 0.3s ease-out;
|
||
box-shadow: 0 10px 24px rgba(4, 12, 22, 0.45);
|
||
}
|
||
|
||
.toast.error {
|
||
border-color: #7f1d1d;
|
||
background-color: #1f1a1a;
|
||
}
|
||
|
||
.toast.success {
|
||
border-color: #15803d;
|
||
background-color: #1a1f1a;
|
||
}
|
||
|
||
@keyframes slideIn {
|
||
from {
|
||
transform: translateX(400px);
|
||
opacity: 0;
|
||
}
|
||
to {
|
||
transform: translateX(0);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
@keyframes modalIn {
|
||
from {
|
||
transform: translateY(8px) scale(0.985);
|
||
opacity: 0;
|
||
}
|
||
to {
|
||
transform: translateY(0) scale(1);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
.spinner {
|
||
display: inline-block;
|
||
width: 16px;
|
||
height: 16px;
|
||
border: 2px solid #263548;
|
||
border-top: 2px solid #3b82c4;
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
.skeleton-block {
|
||
border: 1px solid #263548;
|
||
border-radius: 10px;
|
||
background: linear-gradient(180deg, #101926 0%, #0e1824 100%);
|
||
padding: 14px;
|
||
box-shadow: inset 0 1px 0 #ffffff06;
|
||
}
|
||
|
||
.skeleton-line {
|
||
height: 11px;
|
||
border-radius: 999px;
|
||
background: linear-gradient(90deg, #263548 0%, #3a526d 48%, #263548 100%);
|
||
background-size: 200% 100%;
|
||
animation: shimmer 1.35s linear infinite;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.skeleton-line:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.skeleton-line.w-30 { width: 30%; }
|
||
.skeleton-line.w-45 { width: 45%; }
|
||
.skeleton-line.w-60 { width: 60%; }
|
||
.skeleton-line.w-75 { width: 75%; }
|
||
.skeleton-line.w-90 { width: 90%; }
|
||
|
||
@keyframes shimmer {
|
||
from { background-position: 180% 0; }
|
||
to { background-position: -20% 0; }
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 40px 20px;
|
||
color: #70859b;
|
||
}
|
||
|
||
.empty-state-icon {
|
||
font-size: 48px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.tabs {
|
||
display: flex;
|
||
gap: 20px;
|
||
border-bottom: 1px solid #263548;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.tab {
|
||
padding: 12px 0;
|
||
color: #70859b;
|
||
border: none;
|
||
background: none;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
position: relative;
|
||
transition: color 0.2s;
|
||
}
|
||
|
||
.tab:hover {
|
||
color: #c7d3e0;
|
||
}
|
||
|
||
.tab.active {
|
||
color: #3b82c4;
|
||
}
|
||
|
||
.tab.active::after {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: -1px;
|
||
left: 0;
|
||
right: 0;
|
||
height: 2px;
|
||
background-color: #3b82c4;
|
||
}
|
||
|
||
.kanban-board {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||
gap: 16px;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.kanban-column {
|
||
background-color: #0b1118;
|
||
border: 1px solid #263548;
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
min-height: 400px;
|
||
}
|
||
|
||
.kanban-header {
|
||
font-weight: 600;
|
||
margin-bottom: 16px;
|
||
color: #e5edf5;
|
||
}
|
||
|
||
.kanban-card {
|
||
background-color: #111a27;
|
||
border: 1px solid #263548;
|
||
border-radius: 6px;
|
||
padding: 12px;
|
||
margin-bottom: 12px;
|
||
cursor: pointer;
|
||
transition: border-color 0.18s ease, transform 0.14s ease, box-shadow 0.2s ease;
|
||
}
|
||
|
||
.kanban-card:hover {
|
||
border-color: #3b82c4;
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 8px 18px rgba(6, 20, 34, 0.36);
|
||
}
|
||
|
||
.kanban-card-title {
|
||
font-weight: 500;
|
||
margin-bottom: 6px;
|
||
color: #e5edf5;
|
||
}
|
||
|
||
.kanban-card-subtitle {
|
||
font-size: 12px;
|
||
color: #8ea2b7;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.kanban-card-amount {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #10b981;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.slide-over {
|
||
position: fixed;
|
||
right: 0;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 400px;
|
||
background-color: #111a27;
|
||
border-left: 1px solid #263548;
|
||
overflow-y: auto;
|
||
z-index: 999;
|
||
padding: 20px;
|
||
animation: slideInRight 0.3s ease-out;
|
||
box-shadow: -12px 0 32px rgba(4, 10, 18, 0.45), inset 1px 0 0 #ffffff07;
|
||
}
|
||
|
||
@keyframes slideInRight {
|
||
from {
|
||
transform: translateX(400px);
|
||
}
|
||
to {
|
||
transform: translateX(0);
|
||
}
|
||
}
|
||
|
||
.slide-over-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid #263548;
|
||
}
|
||
|
||
.slide-over-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.close-btn {
|
||
background: none;
|
||
border: none;
|
||
color: #8ea2b7;
|
||
cursor: pointer;
|
||
font-size: 24px;
|
||
padding: 0;
|
||
width: 32px;
|
||
height: 32px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: color 0.2s;
|
||
}
|
||
|
||
.close-btn:hover {
|
||
color: #e5edf5;
|
||
}
|
||
|
||
.detail-section {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.detail-section-title {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #8ea2b7;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.detail-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: 8px 0;
|
||
border-bottom: 1px solid #263548;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.detail-label {
|
||
color: #8ea2b7;
|
||
}
|
||
|
||
.detail-value {
|
||
color: #e5edf5;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.timeline {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.timeline-item {
|
||
display: flex;
|
||
gap: 12px;
|
||
position: relative;
|
||
padding-left: 24px;
|
||
transition: transform 0.14s ease;
|
||
}
|
||
|
||
.timeline-item:hover {
|
||
transform: translateX(2px);
|
||
}
|
||
|
||
.timeline-marker {
|
||
width: 8px;
|
||
height: 8px;
|
||
background-color: #3b82c4;
|
||
border-radius: 50%;
|
||
position: absolute;
|
||
left: 0;
|
||
top: 6px;
|
||
}
|
||
|
||
.timeline-content {
|
||
flex: 1;
|
||
}
|
||
|
||
.timeline-header {
|
||
font-weight: 500;
|
||
color: #e5edf5;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.timeline-meta {
|
||
font-size: 12px;
|
||
color: #8ea2b7;
|
||
}
|
||
|
||
.timeline-body {
|
||
margin-top: 6px;
|
||
font-size: 13px;
|
||
color: #c7d3e0;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.divider {
|
||
height: 1px;
|
||
background-color: #263548;
|
||
margin: 16px 0;
|
||
}
|
||
|
||
.thesis-layout {
|
||
display: grid;
|
||
grid-template-columns: minmax(300px, 1fr) minmax(360px, 1.2fr);
|
||
gap: 20px;
|
||
align-items: start;
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.thesis-layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.thesis-col {
|
||
min-width: 0;
|
||
}
|
||
|
||
.thesis-line-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.thesis-line-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 12px;
|
||
background-color: #0d1622;
|
||
border: 1px solid #263548;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.thesis-version-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
width: 100%;
|
||
text-align: left;
|
||
padding: 12px;
|
||
background-color: #0d1622;
|
||
border: 1px solid #263548;
|
||
border-radius: 8px;
|
||
color: var(--text-primary);
|
||
font-weight: 400;
|
||
}
|
||
|
||
.thesis-version-row:hover {
|
||
background-color: #152233;
|
||
border-color: #35506a;
|
||
transform: none;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.thesis-version-row.active {
|
||
border-color: #3b82c4;
|
||
background-color: #152233;
|
||
box-shadow: inset 0 0 0 1px #3b82c455;
|
||
}
|
||
|
||
.thesis-line-name {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #e5edf5;
|
||
}
|
||
|
||
.thesis-line-meta {
|
||
font-size: 12px;
|
||
color: #8ea2b7;
|
||
margin-top: 3px;
|
||
}
|
||
|
||
.approval-pill {
|
||
flex-shrink: 0;
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #93c5fd;
|
||
background-color: #3b82c422;
|
||
border-radius: 999px;
|
||
padding: 4px 10px;
|
||
}
|
||
|
||
.approval-meter {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 6px;
|
||
}
|
||
|
||
.approval-meter-count {
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: #3b82c4;
|
||
}
|
||
|
||
.approval-meter-label {
|
||
font-size: 12px;
|
||
color: #8ea2b7;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.thesis-block {
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.thesis-block-label {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: #8ea2b7;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.thesis-throughline {
|
||
font-size: 15px;
|
||
line-height: 1.6;
|
||
color: #e5edf5;
|
||
}
|
||
|
||
.thesis-list {
|
||
margin: 0;
|
||
padding-left: 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
color: #c7d3e0;
|
||
line-height: 1.5;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.thesis-list-title {
|
||
color: #e5edf5;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.thesis-list-detail {
|
||
margin-top: 3px;
|
||
font-size: 12px;
|
||
color: #8ea2b7;
|
||
}
|
||
|
||
.thesis-review-form {
|
||
margin-top: 8px;
|
||
border-top: 1px solid #263548;
|
||
padding-top: 16px;
|
||
}
|
||
|
||
/* Thesis Workshop ----------------------------------------------------- */
|
||
.thesis-ws-banner {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
padding: 12px 14px;
|
||
margin-bottom: 18px;
|
||
background-color: #1a2233;
|
||
border: 1px solid #35506a;
|
||
border-radius: 8px;
|
||
color: #c7d3e0;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.thesis-ws-banner.ready {
|
||
background-color: #10b9810f;
|
||
border-color: #1f6f54;
|
||
}
|
||
|
||
.thesis-ws-banner-icon {
|
||
flex-shrink: 0;
|
||
font-size: 16px;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.thesis-ws-node {
|
||
border: 1px solid #263548;
|
||
border-radius: 8px;
|
||
background-color: #0d1622;
|
||
margin-bottom: 14px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.thesis-ws-node.selected {
|
||
border-color: #3b82c4;
|
||
box-shadow: inset 0 0 0 1px #3b82c455;
|
||
}
|
||
|
||
.thesis-ws-node-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
width: 100%;
|
||
text-align: left;
|
||
padding: 12px 14px;
|
||
background-color: transparent;
|
||
color: var(--text-primary);
|
||
font-weight: 400;
|
||
border-radius: 0;
|
||
}
|
||
|
||
.thesis-ws-node-head:hover {
|
||
background-color: #152233;
|
||
transform: none;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.thesis-ws-node-type {
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: #8ea2b7;
|
||
}
|
||
|
||
.thesis-ws-node-title {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #e5edf5;
|
||
margin-top: 3px;
|
||
}
|
||
|
||
.thesis-ws-children {
|
||
padding-left: 18px;
|
||
border-left: 1px solid #1d2a3a;
|
||
margin: 4px 0 4px 18px;
|
||
}
|
||
|
||
.thesis-ws-body {
|
||
padding: 4px 14px 16px;
|
||
}
|
||
|
||
.thesis-ws-count {
|
||
flex-shrink: 0;
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #93c5fd;
|
||
background-color: #3b82c422;
|
||
border-radius: 999px;
|
||
padding: 4px 10px;
|
||
}
|
||
|
||
.thesis-ws-options {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.thesis-ws-option {
|
||
border: 1px solid #263548;
|
||
border-radius: 8px;
|
||
background-color: #111a27;
|
||
padding: 14px;
|
||
}
|
||
|
||
.thesis-ws-option-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.thesis-ws-option-num {
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: #8ea2b7;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
|
||
.thesis-ws-option-text {
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
color: #e5edf5;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.thesis-ws-rationale {
|
||
margin-top: 10px;
|
||
padding: 8px 10px;
|
||
background-color: #0d1622;
|
||
border-left: 2px solid #3b82c4;
|
||
border-radius: 0 6px 6px 0;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
color: #8ea2b7;
|
||
}
|
||
|
||
.thesis-ws-rationale-label {
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: #93c5fd;
|
||
margin-bottom: 3px;
|
||
}
|
||
|
||
.thesis-ws-controls {
|
||
margin-top: 16px;
|
||
border-top: 1px solid #263548;
|
||
padding-top: 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
@media (min-width: 760px) {
|
||
.thesis-ws-controls {
|
||
flex-direction: row;
|
||
align-items: flex-start;
|
||
}
|
||
.thesis-ws-control-col {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
}
|
||
|
||
.thesis-ws-control-col {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.thesis-ws-n-input {
|
||
min-width: 0;
|
||
width: 80px;
|
||
flex: none;
|
||
}
|
||
|
||
.index-action-status {
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.index-job-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
font-size: 12px;
|
||
color: #c7d3e0;
|
||
background-color: #1b2837;
|
||
border-radius: 999px;
|
||
padding: 6px 12px;
|
||
}
|
||
|
||
.index-job-pill.idle {
|
||
color: #8ea2b7;
|
||
}
|
||
|
||
.index-job-pill.running {
|
||
color: #fcd34d;
|
||
background-color: #f59e0b22;
|
||
}
|
||
|
||
.index-job-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background-color: #fcd34d;
|
||
animation: index-job-pulse 1.2s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes index-job-pulse {
|
||
0%, 100% { opacity: 0.35; }
|
||
50% { opacity: 1; }
|
||
}
|
||
|
||
.index-job-tail {
|
||
margin: 0 0 14px;
|
||
max-height: 140px;
|
||
overflow: auto;
|
||
background-color: #0d1622;
|
||
border: 1px solid #263548;
|
||
border-radius: 6px;
|
||
padding: 10px 12px;
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
font-size: 11px;
|
||
color: #8ea2b7;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.index-action-buttons {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
|
||
.index-action-hint {
|
||
margin-top: 12px;
|
||
font-size: 12px;
|
||
color: #8ea2b7;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.merge-candidate-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
|
||
.merge-candidate-card {
|
||
background-color: #0d1622;
|
||
border: 1px solid #263548;
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
}
|
||
|
||
.merge-candidate-people {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.merge-candidate-person {
|
||
flex: 1;
|
||
min-width: 160px;
|
||
}
|
||
|
||
.merge-candidate-name {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #e5edf5;
|
||
}
|
||
|
||
.merge-candidate-email {
|
||
margin-top: 2px;
|
||
font-size: 12px;
|
||
color: #8ea2b7;
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
}
|
||
|
||
.merge-candidate-vs {
|
||
flex-shrink: 0;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: #8ea2b7;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
}
|
||
|
||
.merge-candidate-context {
|
||
margin-top: 10px;
|
||
font-size: 12px;
|
||
color: #c7d3e0;
|
||
}
|
||
|
||
.merge-candidate-suggestion {
|
||
margin-top: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.merge-candidate-confidence {
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
font-size: 12px;
|
||
color: #8ea2b7;
|
||
}
|
||
|
||
.merge-candidate-reason {
|
||
margin-top: 8px;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
color: #c7d3e0;
|
||
}
|
||
|
||
.merge-candidate-actions {
|
||
margin-top: 14px;
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.confirmation-dialog {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1001;
|
||
}
|
||
|
||
.confirmation-box {
|
||
background-color: #111a27;
|
||
border: 1px solid #263548;
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
max-width: 400px;
|
||
width: 90%;
|
||
}
|
||
|
||
.confirmation-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.confirmation-message {
|
||
font-size: 13px;
|
||
color: #c7d3e0;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.confirmation-actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.login-container {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100vh;
|
||
background-color: #0b1118;
|
||
}
|
||
|
||
.login-card {
|
||
background-color: #111a27;
|
||
border: 1px solid #263548;
|
||
border-radius: 12px;
|
||
padding: 32px;
|
||
width: 100%;
|
||
max-width: 400px;
|
||
}
|
||
|
||
.login-title {
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
margin-bottom: 8px;
|
||
text-align: center;
|
||
}
|
||
|
||
.login-logo-wrap {
|
||
display: flex;
|
||
justify-content: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.login-logo-img {
|
||
width: 96px;
|
||
height: 96px;
|
||
object-fit: contain;
|
||
display: block;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.login-subtitle {
|
||
font-size: 13px;
|
||
color: #8ea2b7;
|
||
text-align: center;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.login-form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.login-button {
|
||
width: 100%;
|
||
}
|
||
|
||
.login-link {
|
||
text-align: center;
|
||
font-size: 13px;
|
||
color: #8ea2b7;
|
||
}
|
||
|
||
.login-link a {
|
||
color: #3b82c4;
|
||
text-decoration: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.login-link a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.pipeline-summary {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||
gap: 12px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.pipeline-stage-card {
|
||
background-color: #0b1118;
|
||
border: 1px solid #263548;
|
||
border-radius: 6px;
|
||
padding: 12px;
|
||
text-align: center;
|
||
}
|
||
|
||
.pipeline-stage-name {
|
||
font-size: 12px;
|
||
color: #8ea2b7;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.pipeline-stage-count {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: #3b82c4;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.pipeline-stage-amount {
|
||
font-size: 11px;
|
||
color: #10b981;
|
||
}
|
||
|
||
.csv-preview {
|
||
background-color: #0b1118;
|
||
border: 1px solid #263548;
|
||
border-radius: 6px;
|
||
padding: 12px;
|
||
margin: 16px 0;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.csv-preview-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.csv-preview-table th {
|
||
background-color: #111a27;
|
||
padding: 8px;
|
||
text-align: left;
|
||
border-bottom: 1px solid #263548;
|
||
font-weight: 600;
|
||
color: #8ea2b7;
|
||
}
|
||
|
||
.csv-preview-table td {
|
||
padding: 8px;
|
||
border-bottom: 1px solid #263548;
|
||
}
|
||
|
||
.drag-drop-area {
|
||
border: 2px dashed #263548;
|
||
border-radius: 8px;
|
||
padding: 30px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: border-color 0.2s;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.drag-drop-area:hover {
|
||
border-color: #3b82c4;
|
||
}
|
||
|
||
.drag-drop-area.active {
|
||
border-color: #3b82c4;
|
||
background-color: #0b1118;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
color: #10b981;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 12px;
|
||
color: #8ea2b7;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.sidebar {
|
||
display: none;
|
||
}
|
||
|
||
.header {
|
||
padding: 14px 12px;
|
||
}
|
||
|
||
.content {
|
||
padding: 12px;
|
||
}
|
||
|
||
.section {
|
||
padding: 12px;
|
||
}
|
||
|
||
.slide-over {
|
||
width: 100%;
|
||
}
|
||
|
||
.kpi-grid {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
|
||
.kanban-board {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.table th {
|
||
font-size: 10px;
|
||
padding: 9px 8px;
|
||
}
|
||
|
||
.table td {
|
||
font-size: 12px;
|
||
padding: 8px;
|
||
}
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
* {
|
||
animation-duration: 0.01ms !important;
|
||
animation-iteration-count: 1 !important;
|
||
transition-duration: 0.01ms !important;
|
||
scroll-behavior: auto !important;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="root"></div>
|
||
|
||
<script type="text/babel">
|
||
const { useState, useEffect, useContext, createContext, useCallback, useMemo, useRef } = React;
|
||
|
||
// ==================== Context ====================
|
||
const AuthContext = createContext();
|
||
const MOCK_MODE = false;
|
||
|
||
const getErrorMessage = (err, fallback) => {
|
||
if (!err) return fallback;
|
||
if (typeof err === 'string') return err;
|
||
return err.message || err.error || fallback;
|
||
};
|
||
|
||
const contactName = (row) => {
|
||
if (!row) return '-';
|
||
if (row.contact_name) return row.contact_name;
|
||
if (row.first_name || row.last_name) return `${row.first_name || ''} ${row.last_name || ''}`.trim();
|
||
return '-';
|
||
};
|
||
|
||
const loadFeatureRequests = () => {
|
||
try {
|
||
const raw = localStorage.getItem('venture_crm_feature_requests');
|
||
if (!raw) return [];
|
||
const parsed = JSON.parse(raw);
|
||
return Array.isArray(parsed) ? parsed : [];
|
||
} catch (_) {
|
||
return [];
|
||
}
|
||
};
|
||
|
||
const FUNDRAISING_GRID_STORAGE_KEY = 'venture_crm_fundraising_grid_v1';
|
||
const FUNDRAISING_VIEWS_STORAGE_KEY = 'venture_crm_fundraising_views_v1';
|
||
const FUNDRAISING_VERSION_STORAGE_KEY = 'venture_crm_fundraising_version_v1';
|
||
// Persisted auth so refreshes / tab closes don't force a re-login.
|
||
// The JWT carries the user's real role and is verified server-side on
|
||
// every request, so a tampered localStorage user object can't escalate
|
||
// privileges — the backend will simply reject the next admin call.
|
||
const AUTH_TOKEN_STORAGE_KEY = 'venture_crm_auth_token_v1';
|
||
const AUTH_USER_STORAGE_KEY = 'venture_crm_auth_user_v1';
|
||
|
||
const persistAuth = (token, user) => {
|
||
try {
|
||
if (token) localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token);
|
||
else localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
|
||
if (user) localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(user));
|
||
else localStorage.removeItem(AUTH_USER_STORAGE_KEY);
|
||
} catch (_) {
|
||
// Private browsing, quota exceeded, etc. — non-fatal; the user
|
||
// just won't get the persistence convenience this session.
|
||
}
|
||
};
|
||
|
||
const loadStoredAuthToken = () => {
|
||
try { return localStorage.getItem(AUTH_TOKEN_STORAGE_KEY) || null; }
|
||
catch (_) { return null; }
|
||
};
|
||
|
||
const loadStoredAuthUser = () => {
|
||
try {
|
||
const raw = localStorage.getItem(AUTH_USER_STORAGE_KEY);
|
||
return raw ? JSON.parse(raw) : null;
|
||
} catch (_) { return null; }
|
||
};
|
||
const MOCK_DEFAULT_FUNDRAISING_GRID = {
|
||
columns: [
|
||
{ id: 'investor_name', label: 'Investor Name', type: 'text', width: 220 },
|
||
{ id: 'contacts', label: 'Contacts', type: 'contacts', width: 260 },
|
||
{ id: 'log_action', label: 'Log', type: 'action', readOnly: true, width: 90 },
|
||
{ id: 'notes', label: 'Notes / Communication / Outreach', type: 'longtext', width: 420 },
|
||
{ id: 'lead_source', label: 'Lead Source', type: 'text', width: 180 },
|
||
{ id: 'notes_last_modified', label: 'Notes Last Modified', type: 'date', readOnly: true, width: 180 },
|
||
{ id: 'last_communication_date', label: 'Last Communication Date', type: 'date', readOnly: true, width: 195 },
|
||
{ id: 'priority', label: 'Priority', type: 'checkbox', width: 110 },
|
||
{ id: 'follow_up', label: 'Follow up', type: 'checkbox', width: 110 },
|
||
{ id: 'lead', label: 'Lead', type: 'select', options: ['Grant', 'JK', 'GG', 'MB', 'Unassigned'], width: 130 },
|
||
{ id: 'graveyard', label: 'Graveyard', type: 'checkbox', width: 115 },
|
||
{ id: 'fund_i', label: 'Fund I', type: 'currency', isFund: true, width: 130 },
|
||
{ id: 'fund_ii', label: 'Fund II', type: 'currency', isFund: true, width: 130 },
|
||
{ id: 'fund_iii', label: 'Fund III', type: 'currency', isFund: true, width: 130 },
|
||
{ id: 'tactical_fund', label: 'Tactical Fund', type: 'currency', isFund: true, width: 140 },
|
||
{ id: 'pawn_to_e4', label: 'Pawn to E4', type: 'currency', isFund: true, width: 130 },
|
||
{ id: 'ten31_terahash', label: 'Ten31 Terahash', type: 'currency', isFund: true, width: 150 },
|
||
{ id: 'sats_and_stats', label: 'Sats and Stats', type: 'currency', isFund: true, width: 140 },
|
||
{ id: 'pawn_to_f4', label: 'Pawn to f4', type: 'currency', isFund: true, width: 130 },
|
||
{ id: 'join_the_fold', label: 'Join the Fold', type: 'currency', isFund: true, width: 130 },
|
||
{ id: 'total_invested', label: 'Total invested', type: 'rollup', readOnly: true, width: 150 },
|
||
{ id: 'tactical_fund_commit_date', label: 'Tactical Fund Commit Date', type: 'date', width: 180 }
|
||
],
|
||
rows: [
|
||
{
|
||
id: 'inv-1',
|
||
investor_name: 'Caprock / Grey Street',
|
||
contacts: [
|
||
{ name: 'Jeffrey Friedstein', email: 'jeffrey@example.com', title: '', city: 'New York City', state: 'NY', country: 'USA', location_query: 'New York City' },
|
||
{ name: 'Jay P', email: 'jay@example.com', title: 'Analyst', city: '', state: '', country: '', location_query: '' }
|
||
],
|
||
notes: 'Intro from Alan Handler. Potentially interested in Strike.',
|
||
notes_last_modified: '2026-02-10',
|
||
last_communication_date: '2026-02-10',
|
||
priority: true,
|
||
follow_up: true,
|
||
lead: 'JK',
|
||
graveyard: false,
|
||
fund_i: 0,
|
||
fund_ii: 2500000,
|
||
fund_iii: 0,
|
||
tactical_fund: 0,
|
||
pawn_to_e4: 0,
|
||
ten31_terahash: 0,
|
||
sats_and_stats: 0,
|
||
pawn_to_f4: 0,
|
||
join_the_fold: 0,
|
||
tactical_fund_commit_date: ''
|
||
},
|
||
{
|
||
id: 'inv-2',
|
||
investor_name: 'Comer Family Office',
|
||
contacts: [{ name: 'Michael O\'Shaughnessy', email: 'mike@example.com', title: '', city: 'Austin', state: 'TX', country: 'USA', location_query: 'Austin' }],
|
||
notes: 'Met in Austin. Wants updates in Q2.',
|
||
notes_last_modified: '2026-02-12',
|
||
last_communication_date: '2026-02-12',
|
||
priority: false,
|
||
follow_up: true,
|
||
lead: 'Grant',
|
||
graveyard: false,
|
||
fund_i: 0,
|
||
fund_ii: 1000000,
|
||
fund_iii: 0,
|
||
tactical_fund: 500000,
|
||
pawn_to_e4: 0,
|
||
ten31_terahash: 0,
|
||
sats_and_stats: 0,
|
||
pawn_to_f4: 0,
|
||
join_the_fold: 0,
|
||
tactical_fund_commit_date: '2026-03-15'
|
||
}
|
||
]
|
||
};
|
||
|
||
let mockDb = {
|
||
users: [
|
||
{ id: 'u-admin', username: 'admin', email: 'admin@fund.com', full_name: 'Fund Admin', role: 'admin', password: 'admin123' },
|
||
{ id: 'u-grant', username: 'grant', email: 'grant@fund.com', full_name: 'Grant', role: 'admin', password: 'password' }
|
||
],
|
||
contacts: [
|
||
{ id: 'c-1001', first_name: 'James', last_name: 'Chen', email: 'jchen@sovereign.com', phone: '555-1001', title: 'Managing Director', organization: 'Sovereign Wealth Holdings', organization_name: 'Sovereign Wealth Holdings', contact_type: 'investor', status: 'active', last_contact_date: '2026-02-12T15:00:00Z', communication_count: 3, comm_count: 3 },
|
||
{ id: 'c-1002', first_name: 'Sarah', last_name: 'Williams', email: 'swilliams@pacificcap.com', phone: '555-1002', title: 'CIO', organization: 'Pacific Capital Partners', organization_name: 'Pacific Capital Partners', contact_type: 'investor', status: 'active', last_contact_date: '2026-02-11T12:00:00Z', communication_count: 2, comm_count: 2 },
|
||
{ id: 'c-1003', first_name: 'David', last_name: 'Martinez', email: 'dmartinez@cascade.com', phone: '555-1003', title: 'Partner', organization: 'Cascade Wealth Management', organization_name: 'Cascade Wealth Management', contact_type: 'prospect', status: 'active', last_contact_date: '2026-02-10T10:00:00Z', communication_count: 2, comm_count: 2 },
|
||
{ id: 'c-1004', first_name: 'Jennifer', last_name: 'Taylor', email: 'jtaylor@blueharbor.org', phone: '555-1004', title: 'Executive Director', organization: 'Blue Harbor Foundation', organization_name: 'Blue Harbor Foundation', contact_type: 'prospect', status: 'active', last_contact_date: '2026-02-09T08:30:00Z', communication_count: 1, comm_count: 1 }
|
||
],
|
||
opportunities: [
|
||
{ id: 'o-2001', name: 'Cascade - Fund II', contact_id: 'c-1003', contact_name: 'David Martinez', stage: 'meeting', expected_amount: 10000000, commitment_amount: 0, probability: 35, priority: 'high', fund_name: 'Fund II', organization_name: 'Cascade Wealth Management', updated_at: '2026-02-12T10:00:00Z' },
|
||
{ id: 'o-2002', name: 'Blue Harbor - Fund II', contact_id: 'c-1004', contact_name: 'Jennifer Taylor', stage: 'due_diligence', expected_amount: 5000000, commitment_amount: 0, probability: 60, priority: 'medium', fund_name: 'Fund II', organization_name: 'Blue Harbor Foundation', updated_at: '2026-02-11T10:00:00Z' },
|
||
{ id: 'o-2003', name: 'Sovereign Re-up', contact_id: 'c-1001', contact_name: 'James Chen', stage: 'committed', expected_amount: 25000000, commitment_amount: 10000000, probability: 85, priority: 'high', fund_name: 'Fund III', organization_name: 'Sovereign Wealth Holdings', updated_at: '2026-02-10T10:00:00Z' }
|
||
],
|
||
communications: [
|
||
{ id: 'm-3001', contact_id: 'c-1001', contact_name: 'James Chen', type: 'meeting', subject: 'Q1 Strategy Review', body: 'Discussed deployment pace.', communication_date: '2026-02-12T15:00:00Z', outcome: 'positive' },
|
||
{ id: 'm-3002', contact_id: 'c-1003', contact_name: 'David Martinez', type: 'call', subject: 'Follow-up call', body: 'Requested portfolio details.', communication_date: '2026-02-10T10:00:00Z', outcome: 'positive', next_action: 'Send updated deck', next_action_date: '2026-02-20' },
|
||
{ id: 'm-3003', contact_id: 'c-1004', contact_name: 'Jennifer Taylor', type: 'email', subject: 'DDQ package', body: 'Sent due diligence package.', communication_date: '2026-02-11T09:00:00Z', outcome: 'neutral', next_action: 'Schedule IC call', next_action_date: '2026-02-22' }
|
||
],
|
||
lp_profiles: [
|
||
{ id: 'lp-4001', contact_id: 'c-1001', contact_name: 'James Chen', organization: 'Sovereign Wealth Holdings', commitment_amount: 25000000, funded_amount: 25000000, fund_name: 'Fund I', legal_docs_signed: true, wire_received: true, k1_sent: true },
|
||
{ id: 'lp-4002', contact_id: 'c-1002', contact_name: 'Sarah Williams', organization: 'Pacific Capital Partners', commitment_amount: 15000000, funded_amount: 15000000, fund_name: 'Fund I', legal_docs_signed: true, wire_received: true, k1_sent: false }
|
||
],
|
||
tags: [
|
||
{ id: 't-5001', name: 'High Priority', color: '#ef4444' },
|
||
{ id: 't-5002', name: 'Fund II Prospect', color: '#3b82c4' }
|
||
],
|
||
feature_requests: loadFeatureRequests(),
|
||
audit_log: []
|
||
};
|
||
|
||
const persistFeatureRequests = () => {
|
||
if (!MOCK_MODE) return;
|
||
try {
|
||
localStorage.setItem('venture_crm_feature_requests', JSON.stringify(mockDb.feature_requests || []));
|
||
} catch (_) {
|
||
// no-op
|
||
}
|
||
};
|
||
|
||
const loadMockFundraisingGrid = () => {
|
||
try {
|
||
const raw = localStorage.getItem(FUNDRAISING_GRID_STORAGE_KEY);
|
||
if (!raw) return clone(MOCK_DEFAULT_FUNDRAISING_GRID);
|
||
const parsed = JSON.parse(raw);
|
||
if (!parsed || !Array.isArray(parsed.columns) || !Array.isArray(parsed.rows)) return clone(MOCK_DEFAULT_FUNDRAISING_GRID);
|
||
return parsed;
|
||
} catch (_) {
|
||
return clone(MOCK_DEFAULT_FUNDRAISING_GRID);
|
||
}
|
||
};
|
||
|
||
const loadMockFundraisingViews = () => {
|
||
try {
|
||
const raw = localStorage.getItem(FUNDRAISING_VIEWS_STORAGE_KEY);
|
||
if (!raw) return clone(DEFAULT_GRID_VIEWS);
|
||
const parsed = JSON.parse(raw);
|
||
return Array.isArray(parsed) && parsed.length > 0 ? parsed : clone(DEFAULT_GRID_VIEWS);
|
||
} catch (_) {
|
||
return clone(DEFAULT_GRID_VIEWS);
|
||
}
|
||
};
|
||
|
||
const loadMockFundraisingVersion = () => {
|
||
const raw = localStorage.getItem(FUNDRAISING_VERSION_STORAGE_KEY);
|
||
const parsed = Number(raw);
|
||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
|
||
};
|
||
|
||
const persistMockFundraisingState = (grid, views, version) => {
|
||
try {
|
||
localStorage.setItem(FUNDRAISING_GRID_STORAGE_KEY, JSON.stringify(grid));
|
||
localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(views));
|
||
localStorage.setItem(FUNDRAISING_VERSION_STORAGE_KEY, String(version));
|
||
} catch (_) {
|
||
// no-op
|
||
}
|
||
};
|
||
|
||
const parseEndpoint = (endpoint) => {
|
||
const [path, queryString = ''] = endpoint.split('?');
|
||
return { path, params: new URLSearchParams(queryString) };
|
||
};
|
||
|
||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||
|
||
const refreshDerived = () => {
|
||
mockDb.contacts = mockDb.contacts.map((c) => {
|
||
const comms = mockDb.communications.filter((m) => m.contact_id === c.id);
|
||
const lastContact = comms.length ? comms.map((m) => m.communication_date).sort().slice(-1)[0] : null;
|
||
return {
|
||
...c,
|
||
communication_count: comms.length,
|
||
comm_count: comms.length,
|
||
last_contact_date: lastContact
|
||
};
|
||
});
|
||
|
||
mockDb.opportunities = mockDb.opportunities.map((o) => {
|
||
const c = mockDb.contacts.find((x) => x.id === o.contact_id);
|
||
return {
|
||
...o,
|
||
contact_name: contactName(c),
|
||
organization_name: c?.organization_name || c?.organization || o.organization_name || ''
|
||
};
|
||
});
|
||
|
||
mockDb.lp_profiles = mockDb.lp_profiles.map((lp) => {
|
||
const c = mockDb.contacts.find((x) => x.id === lp.contact_id);
|
||
return {
|
||
...lp,
|
||
contact_name: contactName(c),
|
||
organization: c?.organization_name || c?.organization || lp.organization || ''
|
||
};
|
||
});
|
||
|
||
mockDb.communications = mockDb.communications.map((m) => {
|
||
const c = mockDb.contacts.find((x) => x.id === m.contact_id);
|
||
return { ...m, contact_name: contactName(c) };
|
||
});
|
||
};
|
||
|
||
const mockApi = async (endpoint, options = {}) => {
|
||
refreshDerived();
|
||
const method = (options.method || 'GET').toUpperCase();
|
||
const body = options.body ? JSON.parse(options.body) : {};
|
||
const { path, params } = parseEndpoint(endpoint);
|
||
const makeResult = (payload) => payload;
|
||
|
||
if (path === '/api/auth/register' && method === 'POST') {
|
||
throw new Error('Registration is disabled. Ask an admin for an invite.');
|
||
}
|
||
|
||
if (path === '/api/auth/login' && method === 'POST') {
|
||
const username = (body.username || '').trim();
|
||
const password = body.password || '';
|
||
const user = mockDb.users.find((u) => u.username === username);
|
||
if (!user || user.password !== password) throw new Error('Invalid credentials');
|
||
return makeResult({ token: `mock-token-${user.id}`, user: { id: user.id, username: user.username, email: user.email, full_name: user.full_name, role: user.role } });
|
||
}
|
||
|
||
if (path === '/api/reports/dashboard' && method === 'GET') {
|
||
const metrics = {
|
||
total_lps: mockDb.contacts.filter((c) => c.contact_type === 'investor').length,
|
||
total_prospects: mockDb.contacts.filter((c) => c.contact_type === 'prospect').length,
|
||
total_committed: mockDb.lp_profiles.reduce((s, lp) => s + (lp.commitment_amount || 0), 0),
|
||
pipeline_value: mockDb.opportunities.filter((o) => !['funded', 'lost'].includes(o.stage)).reduce((s, o) => s + (o.expected_amount || 0), 0),
|
||
active_opportunities: mockDb.opportunities.filter((o) => !['funded', 'lost'].includes(o.stage)).length,
|
||
comms_this_month: mockDb.communications.length
|
||
};
|
||
const stages = ['lead', 'outreach', 'meeting', 'due_diligence', 'committed', 'funded']
|
||
.map((stage) => {
|
||
const rows = mockDb.opportunities.filter((o) => o.stage === stage);
|
||
return { stage, count: rows.length, total_value: rows.reduce((s, r) => s + (r.expected_amount || 0), 0) };
|
||
});
|
||
const upcoming = mockDb.communications.filter((m) => m.next_action).map((m) => ({ id: m.id, description: m.next_action, due_date: m.next_action_date }));
|
||
return makeResult({ data: { metrics, pipeline_stages: stages, recent_communications: mockDb.communications, upcoming_actions: upcoming } });
|
||
}
|
||
|
||
if (path === '/api/contacts' && method === 'GET') {
|
||
let rows = [...mockDb.contacts];
|
||
const type = params.get('type');
|
||
const search = (params.get('search') || '').toLowerCase();
|
||
const sort = params.get('sort') || 'updated_at';
|
||
const order = params.get('order') === 'asc' ? 1 : -1;
|
||
if (type) rows = rows.filter((r) => r.contact_type === type);
|
||
if (search) {
|
||
rows = rows.filter((r) => `${r.first_name} ${r.last_name} ${r.email || ''} ${r.organization || ''}`.toLowerCase().includes(search));
|
||
}
|
||
if (sort === 'name') rows.sort((a, b) => (`${a.first_name} ${a.last_name}`).localeCompare(`${b.first_name} ${b.last_name}`) * order);
|
||
return makeResult({ data: clone(rows), total: rows.length, limit: rows.length, offset: 0 });
|
||
}
|
||
|
||
if (path === '/api/contacts' && method === 'POST') {
|
||
const item = {
|
||
id: `c-${Date.now()}`,
|
||
first_name: body.first_name,
|
||
last_name: body.last_name,
|
||
email: body.email || '',
|
||
phone: body.phone || '',
|
||
title: body.title || '',
|
||
organization: body.organization || '',
|
||
organization_name: body.organization || '',
|
||
contact_type: body.contact_type || 'prospect',
|
||
status: body.status || 'active',
|
||
communication_count: 0,
|
||
comm_count: 0,
|
||
last_contact_date: null
|
||
};
|
||
mockDb.contacts.unshift(item);
|
||
return makeResult({ data: clone(item) }, 201);
|
||
}
|
||
|
||
if (/^\/api\/contacts\/[^/]+$/.test(path) && method === 'GET') {
|
||
const id = path.split('/').pop();
|
||
const item = mockDb.contacts.find((c) => c.id === id);
|
||
if (!item) throw new Error('Contact not found');
|
||
const opportunities = mockDb.opportunities.filter((o) => o.contact_id === id);
|
||
const communications = mockDb.communications.filter((m) => m.contact_id === id);
|
||
const lp = mockDb.lp_profiles.find((lpRow) => lpRow.contact_id === id) || null;
|
||
return makeResult({ data: clone({ ...item, opportunities, communications, lp_profile: lp }) });
|
||
}
|
||
|
||
if (/^\/api\/contacts\/[^/]+$/.test(path) && method === 'DELETE') {
|
||
const id = path.split('/').pop();
|
||
mockDb.contacts = mockDb.contacts.filter((c) => c.id !== id);
|
||
mockDb.opportunities = mockDb.opportunities.filter((o) => o.contact_id !== id);
|
||
mockDb.communications = mockDb.communications.filter((m) => m.contact_id !== id);
|
||
mockDb.lp_profiles = mockDb.lp_profiles.filter((lp) => lp.contact_id !== id);
|
||
return makeResult({ message: 'Contact deleted' });
|
||
}
|
||
|
||
if (path === '/api/opportunities' && method === 'GET') {
|
||
return makeResult({ data: clone(mockDb.opportunities), total: mockDb.opportunities.length });
|
||
}
|
||
|
||
if (path === '/api/opportunities' && method === 'POST') {
|
||
const c = mockDb.contacts.find((x) => x.id === body.contact_id);
|
||
if (!c) throw new Error('Valid contact is required');
|
||
const item = {
|
||
id: `o-${Date.now()}`,
|
||
name: body.name || 'Untitled Opportunity',
|
||
contact_id: body.contact_id,
|
||
contact_name: contactName(c),
|
||
stage: body.stage || 'lead',
|
||
expected_amount: Number(body.expected_amount) || 0,
|
||
commitment_amount: Number(body.commitment_amount) || 0,
|
||
probability: Number(body.probability) || 10,
|
||
priority: body.priority || 'medium',
|
||
fund_name: body.fund_name || '',
|
||
organization_name: c.organization_name || c.organization || ''
|
||
};
|
||
mockDb.opportunities.unshift(item);
|
||
return makeResult({ data: clone(item) }, 201);
|
||
}
|
||
|
||
if (/^\/api\/opportunities\/[^/]+$/.test(path) && method === 'GET') {
|
||
const id = path.split('/').pop();
|
||
const item = mockDb.opportunities.find((o) => o.id === id);
|
||
if (!item) throw new Error('Opportunity not found');
|
||
const comms = mockDb.communications.filter((m) => m.contact_id === item.contact_id);
|
||
return makeResult({ data: clone({ ...item, communications: comms }) });
|
||
}
|
||
|
||
if (/^\/api\/opportunities\/[^/]+$/.test(path) && method === 'DELETE') {
|
||
const id = path.split('/').pop();
|
||
mockDb.opportunities = mockDb.opportunities.filter((o) => o.id !== id);
|
||
return makeResult({ message: 'Opportunity deleted' });
|
||
}
|
||
|
||
if (/^\/api\/opportunities\/[^/]+\/stage$/.test(path) && method === 'PATCH') {
|
||
const id = path.split('/')[3];
|
||
mockDb.opportunities = mockDb.opportunities.map((o) => (o.id === id ? { ...o, stage: body.stage || o.stage } : o));
|
||
const item = mockDb.opportunities.find((o) => o.id === id);
|
||
return makeResult({ data: clone(item) });
|
||
}
|
||
|
||
if (path === '/api/communications' && method === 'GET') {
|
||
const type = params.get('type');
|
||
const search = (params.get('search') || '').toLowerCase();
|
||
let rows = [...mockDb.communications];
|
||
if (type) rows = rows.filter((r) => r.type === type);
|
||
if (search) rows = rows.filter((r) => `${r.subject || ''} ${r.body || ''} ${r.contact_name || ''}`.toLowerCase().includes(search));
|
||
return makeResult({ data: clone(rows), total: rows.length });
|
||
}
|
||
|
||
if (path === '/api/communications' && method === 'POST') {
|
||
const c = mockDb.contacts.find((x) => x.id === body.contact_id);
|
||
if (!c) throw new Error('Valid contact is required');
|
||
const item = {
|
||
id: `m-${Date.now()}`,
|
||
contact_id: body.contact_id,
|
||
contact_name: contactName(c),
|
||
type: body.type || 'note',
|
||
subject: body.subject || '',
|
||
body: body.body || '',
|
||
communication_date: body.communication_date || new Date().toISOString(),
|
||
outcome: body.outcome || ''
|
||
};
|
||
mockDb.communications.unshift(item);
|
||
return makeResult({ data: clone(item) }, 201);
|
||
}
|
||
|
||
if (path === '/api/fundraising/log-communication' && method === 'POST') {
|
||
const investorName = body.investor_name || '';
|
||
const contactPayload = body.contact || {};
|
||
const createInvestorIfMissing = !!body.create_investor_if_missing;
|
||
const byEmail = (contactPayload.email || '').toLowerCase();
|
||
let c = null;
|
||
if (byEmail) c = mockDb.contacts.find((x) => String(x.email || '').toLowerCase() === byEmail) || null;
|
||
if (!c) {
|
||
const full = String(contactPayload.name || '').trim();
|
||
const parts = full.split(/\s+/).filter(Boolean);
|
||
c = {
|
||
id: `c-${Date.now()}`,
|
||
first_name: parts[0] || 'Unknown',
|
||
last_name: parts.slice(1).join(' '),
|
||
email: contactPayload.email || '',
|
||
title: contactPayload.title || '',
|
||
organization: investorName || '',
|
||
organization_name: investorName || '',
|
||
contact_type: 'investor',
|
||
status: 'active'
|
||
};
|
||
mockDb.contacts.unshift(c);
|
||
}
|
||
const item = {
|
||
id: `m-${Date.now()}`,
|
||
contact_id: c.id,
|
||
contact_name: contactName(c),
|
||
type: body.type || 'note',
|
||
subject: body.subject || '',
|
||
body: body.body || '',
|
||
communication_date: new Date().toISOString(),
|
||
outcome: body.outcome || ''
|
||
};
|
||
mockDb.communications.unshift(item);
|
||
const gridRows = (mockDb.fundraisingGrid && Array.isArray(mockDb.fundraisingGrid.rows)) ? mockDb.fundraisingGrid.rows : [];
|
||
let row = gridRows.find((r) => (body.row_id && r.id === body.row_id) || (investorName && r.investor_name === investorName));
|
||
if (!row && createInvestorIfMissing && investorName) {
|
||
row = {
|
||
id: `inv-${Date.now()}`,
|
||
investor_name: investorName,
|
||
contacts: [],
|
||
notes: '',
|
||
lead_source: '',
|
||
notes_last_modified: '',
|
||
last_communication_date: '',
|
||
lead: '',
|
||
priority: false,
|
||
follow_up: false,
|
||
graveyard: false
|
||
};
|
||
gridRows.unshift(row);
|
||
}
|
||
if (row) {
|
||
const d = new Date().toISOString().slice(0, 10);
|
||
row.last_communication_date = d;
|
||
if (body.append_note) {
|
||
const line = `${d} [${item.type}] ${contactPayload.name || ''}: ${item.subject || item.body || ''}`.trim();
|
||
row.notes = row.notes ? `${row.notes}\n${line}` : line;
|
||
row.notes_last_modified = d;
|
||
}
|
||
}
|
||
return makeResult({ data: { communication: clone(item), row: row ? clone(row) : null, version: loadMockFundraisingVersion() + 1 } }, 201);
|
||
}
|
||
|
||
if (/^\/api\/communications\/[^/]+$/.test(path) && method === 'DELETE') {
|
||
const id = path.split('/').pop();
|
||
mockDb.communications = mockDb.communications.filter((m) => m.id !== id);
|
||
return makeResult({ message: 'Communication deleted' });
|
||
}
|
||
|
||
if (path === '/api/lp-profiles' && method === 'GET') {
|
||
const search = (params.get('search') || '').toLowerCase();
|
||
let rows = [...mockDb.lp_profiles];
|
||
if (search) rows = rows.filter((r) => `${r.contact_name || ''} ${r.organization || ''}`.toLowerCase().includes(search));
|
||
return makeResult({ data: clone(rows), total: rows.length });
|
||
}
|
||
|
||
if (path === '/api/lp-profiles' && method === 'POST') {
|
||
const c = mockDb.contacts.find((x) => x.id === body.contact_id);
|
||
if (!c) throw new Error('Valid contact is required');
|
||
const item = {
|
||
id: `lp-${Date.now()}`,
|
||
contact_id: body.contact_id,
|
||
contact_name: contactName(c),
|
||
organization: c.organization_name || c.organization || '',
|
||
commitment_amount: Number(body.commitment_amount) || 0,
|
||
funded_amount: Number(body.funded_amount) || 0,
|
||
fund_name: body.fund_name || '',
|
||
legal_docs_signed: !!body.legal_docs_signed,
|
||
wire_received: !!body.wire_received,
|
||
k1_sent: !!body.k1_sent
|
||
};
|
||
mockDb.lp_profiles.unshift(item);
|
||
return makeResult({ data: clone(item) }, 201);
|
||
}
|
||
|
||
if (/^\/api\/lp-profiles\/[^/]+$/.test(path) && method === 'DELETE') {
|
||
const id = path.split('/').pop();
|
||
mockDb.lp_profiles = mockDb.lp_profiles.filter((lp) => lp.id !== id);
|
||
return makeResult({ message: 'LP deleted' });
|
||
}
|
||
|
||
if (path === '/api/import/csv' && method === 'POST') {
|
||
const rows = Array.isArray(body.data) ? body.data : [];
|
||
return makeResult({ data: { created: rows.length, updated: 0, skipped: 0, errors: [] }, dry_run: !!body.dry_run });
|
||
}
|
||
|
||
if (path === '/api/fundraising/state' && method === 'GET') {
|
||
const grid = loadMockFundraisingGrid();
|
||
const views = loadMockFundraisingViews();
|
||
const version = loadMockFundraisingVersion();
|
||
return makeResult({
|
||
data: {
|
||
grid: clone(grid),
|
||
views: clone(views),
|
||
version,
|
||
updated_at: new Date().toISOString()
|
||
}
|
||
});
|
||
}
|
||
|
||
if (path === '/api/fundraising/state' && method === 'PUT') {
|
||
const grid = body.grid;
|
||
const views = body.views;
|
||
const expectedVersion = Number(body.expected_version);
|
||
const currentVersion = loadMockFundraisingVersion();
|
||
if (Number.isFinite(expectedVersion) && expectedVersion !== currentVersion) {
|
||
const conflict = new Error('Version conflict');
|
||
conflict.status = 409;
|
||
conflict.payload = { error: 'Version conflict', current_version: currentVersion };
|
||
throw conflict;
|
||
}
|
||
if (!grid || !Array.isArray(grid.columns) || !Array.isArray(grid.rows)) {
|
||
throw new Error('grid must include columns and rows');
|
||
}
|
||
const nextViews = Array.isArray(views) ? views : loadMockFundraisingViews();
|
||
const nextVersion = currentVersion + 1;
|
||
persistMockFundraisingState(grid, nextViews, nextVersion);
|
||
return makeResult({ data: { version: nextVersion, updated_at: new Date().toISOString() } });
|
||
}
|
||
|
||
if (path === '/api/fundraising/collab/state' && method === 'GET') {
|
||
return makeResult({ data: { presence: [], locks: [] } });
|
||
}
|
||
|
||
if (path === '/api/fundraising/collab/heartbeat' && method === 'POST') {
|
||
return makeResult({ data: { presence: [], locks: [], lock_conflict: null } });
|
||
}
|
||
|
||
if (path === '/api/feature-requests' && method === 'GET') {
|
||
const status = params.get('status') || '';
|
||
const search = (params.get('search') || '').toLowerCase();
|
||
let rows = [...mockDb.feature_requests];
|
||
if (status) rows = rows.filter((r) => r.status === status);
|
||
if (search) {
|
||
rows = rows.filter((r) => `${r.title || ''} ${r.description || ''} ${r.requested_by || ''} ${r.category || ''}`.toLowerCase().includes(search));
|
||
}
|
||
rows.sort((a, b) => (a.created_at > b.created_at ? -1 : 1));
|
||
return makeResult({ data: clone(rows), total: rows.length });
|
||
}
|
||
|
||
if (path === '/api/feature-requests' && method === 'POST') {
|
||
if (!body.title || !body.title.trim()) throw new Error('Title is required');
|
||
const item = {
|
||
id: `fr-${Date.now()}`,
|
||
title: body.title.trim(),
|
||
description: (body.description || '').trim(),
|
||
category: body.category || 'other',
|
||
priority: body.priority || 'medium',
|
||
status: 'new',
|
||
requested_by: (body.requested_by || 'Unknown').trim(),
|
||
page: body.page || '',
|
||
created_at: new Date().toISOString(),
|
||
updated_at: new Date().toISOString()
|
||
};
|
||
mockDb.feature_requests.unshift(item);
|
||
persistFeatureRequests();
|
||
return makeResult({ data: clone(item) }, 201);
|
||
}
|
||
|
||
if (/^\/api\/feature-requests\/[^/]+$/.test(path) && method === 'PATCH') {
|
||
const id = path.split('/').pop();
|
||
const allowed = ['status', 'priority', 'category', 'page'];
|
||
mockDb.feature_requests = mockDb.feature_requests.map((r) => {
|
||
if (r.id !== id) return r;
|
||
const next = { ...r, updated_at: new Date().toISOString() };
|
||
allowed.forEach((field) => {
|
||
if (Object.prototype.hasOwnProperty.call(body, field)) next[field] = body[field];
|
||
});
|
||
return next;
|
||
});
|
||
persistFeatureRequests();
|
||
const item = mockDb.feature_requests.find((r) => r.id === id);
|
||
if (!item) throw new Error('Feature request not found');
|
||
return makeResult({ data: clone(item) });
|
||
}
|
||
|
||
throw new Error(`Mock endpoint not implemented: ${method} ${path}`);
|
||
};
|
||
|
||
const AuthProvider = ({ children }) => {
|
||
// Lazy initializers: read once on mount so refreshes / new tabs
|
||
// start signed-in if a valid token is in localStorage. The server
|
||
// still verifies the JWT on every request, so a forged or expired
|
||
// token fails fast and the 'crm:unauthorized' handler below clears
|
||
// the stored auth back to a logged-out state.
|
||
const [token, setToken] = useState(() => (
|
||
MOCK_MODE ? 'mock-token-u-admin' : loadStoredAuthToken()
|
||
));
|
||
const [user, setUser] = useState(() => {
|
||
if (MOCK_MODE) {
|
||
return {
|
||
id: 'u-admin',
|
||
username: 'admin',
|
||
email: 'admin@fund.com',
|
||
full_name: 'Fund Admin',
|
||
role: 'admin'
|
||
};
|
||
}
|
||
return loadStoredAuthUser();
|
||
});
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const clearAuth = useCallback(() => {
|
||
setToken(null);
|
||
setUser(null);
|
||
if (!MOCK_MODE) persistAuth(null, null);
|
||
}, []);
|
||
|
||
// Auto-clear stored auth when any API call is rejected as 401.
|
||
// api() dispatches this event so we don't have to thread the
|
||
// auth context through every fetch caller.
|
||
useEffect(() => {
|
||
if (MOCK_MODE) return;
|
||
const handler = () => clearAuth();
|
||
window.addEventListener('crm:unauthorized', handler);
|
||
return () => window.removeEventListener('crm:unauthorized', handler);
|
||
}, [clearAuth]);
|
||
|
||
const login = useCallback(async (username, password) => {
|
||
setLoading(true);
|
||
try {
|
||
const data = await api('/api/auth/login', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ username, password })
|
||
});
|
||
setToken(data.token);
|
||
setUser(data.user);
|
||
if (!MOCK_MODE) persistAuth(data.token, data.user);
|
||
return true;
|
||
} catch (err) {
|
||
throw new Error(getErrorMessage(err, 'Login failed'));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const setupFirstAdmin = useCallback(async ({ username, password, email, full_name }) => {
|
||
setLoading(true);
|
||
try {
|
||
const data = await api('/api/auth/register', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ username, password, email, full_name })
|
||
});
|
||
setToken(data.token);
|
||
setUser(data.user);
|
||
if (!MOCK_MODE) persistAuth(data.token, data.user);
|
||
return true;
|
||
} catch (err) {
|
||
throw new Error(getErrorMessage(err, 'Setup failed'));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const logout = useCallback(() => {
|
||
if (MOCK_MODE) return;
|
||
clearAuth();
|
||
}, [clearAuth]);
|
||
|
||
return (
|
||
<AuthContext.Provider value={{ token, user, loading, login, setupFirstAdmin, logout }}>
|
||
{children}
|
||
</AuthContext.Provider>
|
||
);
|
||
};
|
||
|
||
const useAuth = () => {
|
||
const ctx = useContext(AuthContext);
|
||
if (!ctx) throw new Error('useAuth outside AuthProvider');
|
||
return ctx;
|
||
};
|
||
|
||
// ==================== Utilities ====================
|
||
const api = async (endpoint, options = {}, token) => {
|
||
if (MOCK_MODE) {
|
||
return mockApi(endpoint, options);
|
||
}
|
||
|
||
const headers = {
|
||
'Content-Type': 'application/json',
|
||
...options.headers
|
||
};
|
||
|
||
if (token) {
|
||
headers.Authorization = `Bearer ${token}`;
|
||
}
|
||
|
||
const res = await fetch(endpoint, {
|
||
...options,
|
||
headers
|
||
});
|
||
|
||
if (!res.ok) {
|
||
let err = {};
|
||
try {
|
||
err = await res.json();
|
||
} catch (_) {
|
||
err = {};
|
||
}
|
||
// Stored token rejected (expired, server restarted with a new
|
||
// signing key, etc.) — tell AuthProvider to drop persisted auth
|
||
// and bounce the user to the login screen. Only fires if we
|
||
// actually sent a token; an unauthenticated 401 (e.g. login
|
||
// failure) is handled by the caller as a normal error.
|
||
if (res.status === 401 && token) {
|
||
try { window.dispatchEvent(new CustomEvent('crm:unauthorized')); }
|
||
catch (_) {}
|
||
}
|
||
const error = new Error(getErrorMessage(err, `API error: ${res.status}`));
|
||
error.status = res.status;
|
||
error.payload = err;
|
||
throw error;
|
||
}
|
||
|
||
return res.json();
|
||
};
|
||
|
||
const formatCurrency = (amount) => {
|
||
if (!amount) return '$0';
|
||
const num = Math.abs(amount);
|
||
if (num >= 1000000) {
|
||
return (amount / 1000000).toFixed(1) + 'M';
|
||
}
|
||
if (num >= 1000) {
|
||
return (amount / 1000).toFixed(1) + 'K';
|
||
}
|
||
return amount.toFixed(0);
|
||
};
|
||
|
||
const formatCurrencyLong = (amount) => {
|
||
if (!amount) return '$0';
|
||
return '$' + formatCurrency(amount);
|
||
};
|
||
|
||
const formatDate = (date) => {
|
||
if (!date) return '';
|
||
const d = new Date(date);
|
||
const now = new Date();
|
||
const diff = now - d;
|
||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||
|
||
if (days === 0) return 'Today';
|
||
if (days === 1) return 'Yesterday';
|
||
if (days < 7) return days + ' days ago';
|
||
if (days < 30) return Math.floor(days / 7) + ' weeks ago';
|
||
|
||
return d.toLocaleDateString();
|
||
};
|
||
|
||
const formatDateLong = (date) => {
|
||
if (!date) return '';
|
||
const d = new Date(date);
|
||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
};
|
||
|
||
const parseNumericInput = (value) => {
|
||
if (value === null || value === undefined) return 0;
|
||
const cleaned = String(value).replace(/[^0-9.-]/g, '');
|
||
const parsed = Number(cleaned);
|
||
return Number.isFinite(parsed) ? parsed : 0;
|
||
};
|
||
|
||
const normalizeKey = (value) => {
|
||
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||
};
|
||
|
||
const subsequenceScore = (needle, haystack) => {
|
||
const q = normalizeKey(needle).replace(/\s+/g, '');
|
||
const t = normalizeKey(haystack).replace(/\s+/g, '');
|
||
if (!q || !t) return 0;
|
||
let qi = 0;
|
||
let first = -1;
|
||
let last = -1;
|
||
for (let ti = 0; ti < t.length && qi < q.length; ti += 1) {
|
||
if (q[qi] === t[ti]) {
|
||
if (first === -1) first = ti;
|
||
last = ti;
|
||
qi += 1;
|
||
}
|
||
}
|
||
if (qi !== q.length || first === -1 || last === -1) return 0;
|
||
const span = Math.max(1, last - first + 1);
|
||
return Math.min(1, (q.length / span) * 0.9 + (q.length / t.length) * 0.1);
|
||
};
|
||
|
||
const fuzzyScore = (query, text) => {
|
||
const q = normalizeKey(query);
|
||
const t = normalizeKey(text);
|
||
if (!q || !t) return 0;
|
||
if (t.includes(q)) return 1;
|
||
|
||
const qTokens = q.split(' ').filter(Boolean);
|
||
const tTokens = t.split(' ').filter(Boolean);
|
||
if (qTokens.length === 0 || tTokens.length === 0) return subsequenceScore(q, t);
|
||
|
||
let tokenScore = 0;
|
||
qTokens.forEach((token) => {
|
||
let best = 0;
|
||
tTokens.forEach((word) => {
|
||
if (word.startsWith(token)) {
|
||
best = Math.max(best, 0.92 * (token.length / Math.max(token.length, word.length)));
|
||
} else if (word.includes(token)) {
|
||
best = Math.max(best, 0.78 * (token.length / Math.max(token.length, word.length)));
|
||
} else {
|
||
best = Math.max(best, 0.55 * subsequenceScore(token, word));
|
||
}
|
||
});
|
||
tokenScore += best;
|
||
});
|
||
|
||
const avgTokenScore = tokenScore / qTokens.length;
|
||
const fullScore = subsequenceScore(q, t);
|
||
return Math.max(avgTokenScore, fullScore * 0.85);
|
||
};
|
||
|
||
const tokenizeFormula = (input) => {
|
||
const tokens = [];
|
||
let i = 0;
|
||
const text = String(input || '');
|
||
while (i < text.length) {
|
||
const ch = text[i];
|
||
if (/\s/.test(ch)) { i += 1; continue; }
|
||
if (ch === '{') {
|
||
const end = text.indexOf('}', i + 1);
|
||
if (end < 0) return null;
|
||
tokens.push({ type: 'field', value: text.slice(i + 1, end).trim() });
|
||
i = end + 1;
|
||
continue;
|
||
}
|
||
if (ch === '"' || ch === '\'') {
|
||
const quote = ch;
|
||
let j = i + 1;
|
||
let out = '';
|
||
while (j < text.length) {
|
||
const c = text[j];
|
||
if (c === '\\' && j + 1 < text.length) {
|
||
out += text[j + 1];
|
||
j += 2;
|
||
continue;
|
||
}
|
||
if (c === quote) break;
|
||
out += c;
|
||
j += 1;
|
||
}
|
||
if (j >= text.length || text[j] !== quote) return null;
|
||
tokens.push({ type: 'string', value: out });
|
||
i = j + 1;
|
||
continue;
|
||
}
|
||
const two = text.slice(i, i + 2);
|
||
if (['>=', '<=', '!=', '==', '&&', '||'].includes(two)) {
|
||
tokens.push({ type: 'op', value: two });
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if ('+-*/(),><&'.includes(ch)) {
|
||
tokens.push({ type: ch === '&' ? 'op' : 'sym', value: ch === '&' ? '+' : ch });
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (/[0-9.]/.test(ch)) {
|
||
let j = i + 1;
|
||
while (j < text.length && /[0-9.]/.test(text[j])) j += 1;
|
||
tokens.push({ type: 'number', value: Number(text.slice(i, j)) });
|
||
i = j;
|
||
continue;
|
||
}
|
||
if (/[A-Za-z_]/.test(ch)) {
|
||
let j = i + 1;
|
||
while (j < text.length && /[A-Za-z0-9_]/.test(text[j])) j += 1;
|
||
tokens.push({ type: 'ident', value: text.slice(i, j).toUpperCase() });
|
||
i = j;
|
||
continue;
|
||
}
|
||
return null;
|
||
}
|
||
return tokens;
|
||
};
|
||
|
||
const evaluateFormulaSafe = (expression, getFieldValue) => {
|
||
const tokens = tokenizeFormula(expression);
|
||
if (!tokens) return '#ERR';
|
||
let idx = 0;
|
||
const peek = () => tokens[idx];
|
||
const take = () => tokens[idx++];
|
||
const match = (type, value = null) => {
|
||
const t = peek();
|
||
if (!t || t.type !== type) return false;
|
||
if (value !== null && t.value !== value) return false;
|
||
idx += 1;
|
||
return true;
|
||
};
|
||
const parsePrimary = () => {
|
||
const t = peek();
|
||
if (!t) return null;
|
||
if (t.type === 'number' || t.type === 'string') return take();
|
||
if (t.type === 'field') return take();
|
||
if (t.type === 'ident') {
|
||
const ident = take();
|
||
if (match('sym', '(')) {
|
||
const args = [];
|
||
if (!match('sym', ')')) {
|
||
while (true) {
|
||
const arg = parseExpression();
|
||
if (!arg) return null;
|
||
args.push(arg);
|
||
if (match('sym', ')')) break;
|
||
if (!match('sym', ',')) return null;
|
||
}
|
||
}
|
||
return { type: 'call', name: ident.value, args };
|
||
}
|
||
return ident;
|
||
}
|
||
if (match('sym', '(')) {
|
||
const inner = parseExpression();
|
||
if (!inner || !match('sym', ')')) return null;
|
||
return inner;
|
||
}
|
||
if (match('sym', '-')) {
|
||
const value = parsePrimary();
|
||
if (!value) return null;
|
||
return { type: 'neg', value };
|
||
}
|
||
return null;
|
||
};
|
||
const parseMul = () => {
|
||
let left = parsePrimary();
|
||
if (!left) return null;
|
||
while (true) {
|
||
if (match('sym', '*')) {
|
||
const right = parsePrimary();
|
||
if (!right) return null;
|
||
left = { type: 'bin', op: '*', left, right };
|
||
} else if (match('sym', '/')) {
|
||
const right = parsePrimary();
|
||
if (!right) return null;
|
||
left = { type: 'bin', op: '/', left, right };
|
||
} else break;
|
||
}
|
||
return left;
|
||
};
|
||
const parseAdd = () => {
|
||
let left = parseMul();
|
||
if (!left) return null;
|
||
while (true) {
|
||
if (match('sym', '+') || match('op', '+')) {
|
||
const right = parseMul();
|
||
if (!right) return null;
|
||
left = { type: 'bin', op: '+', left, right };
|
||
} else if (match('sym', '-')) {
|
||
const right = parseMul();
|
||
if (!right) return null;
|
||
left = { type: 'bin', op: '-', left, right };
|
||
} else break;
|
||
}
|
||
return left;
|
||
};
|
||
const parseCompare = () => {
|
||
let left = parseAdd();
|
||
if (!left) return null;
|
||
while (true) {
|
||
const op = peek();
|
||
if (!op || op.type !== 'op' || !['>', '<', '>=', '<=', '==', '!='].includes(op.value)) break;
|
||
take();
|
||
const right = parseAdd();
|
||
if (!right) return null;
|
||
left = { type: 'bin', op: op.value, left, right };
|
||
}
|
||
return left;
|
||
};
|
||
const parseLogicalAnd = () => {
|
||
let left = parseCompare();
|
||
if (!left) return null;
|
||
while (match('op', '&&')) {
|
||
const right = parseCompare();
|
||
if (!right) return null;
|
||
left = { type: 'bin', op: '&&', left, right };
|
||
}
|
||
return left;
|
||
};
|
||
const parseExpression = () => {
|
||
let left = parseLogicalAnd();
|
||
if (!left) return null;
|
||
while (match('op', '||')) {
|
||
const right = parseLogicalAnd();
|
||
if (!right) return null;
|
||
left = { type: 'bin', op: '||', left, right };
|
||
}
|
||
return left;
|
||
};
|
||
const ast = parseExpression();
|
||
if (!ast || idx !== tokens.length) return '#ERR';
|
||
|
||
const asNum = (v) => parseNumericInput(v);
|
||
const asBool = (v) => !!v;
|
||
const evalNode = (node) => {
|
||
if (!node) return null;
|
||
if (node.type === 'number') return node.value;
|
||
if (node.type === 'string') return node.value;
|
||
if (node.type === 'field') return getFieldValue(node.value);
|
||
if (node.type === 'ident') {
|
||
if (node.value === 'TRUE') return true;
|
||
if (node.value === 'FALSE') return false;
|
||
if (node.value === 'NULL') return null;
|
||
return node.value;
|
||
}
|
||
if (node.type === 'neg') return -asNum(evalNode(node.value));
|
||
if (node.type === 'bin') {
|
||
const a = evalNode(node.left);
|
||
const b = evalNode(node.right);
|
||
if (node.op === '+') return (typeof a === 'string' || typeof b === 'string') ? `${a ?? ''}${b ?? ''}` : asNum(a) + asNum(b);
|
||
if (node.op === '-') return asNum(a) - asNum(b);
|
||
if (node.op === '*') return asNum(a) * asNum(b);
|
||
if (node.op === '/') return asNum(b) === 0 ? 0 : asNum(a) / asNum(b);
|
||
if (node.op === '>') return asNum(a) > asNum(b);
|
||
if (node.op === '<') return asNum(a) < asNum(b);
|
||
if (node.op === '>=') return asNum(a) >= asNum(b);
|
||
if (node.op === '<=') return asNum(a) <= asNum(b);
|
||
if (node.op === '==') return String(a ?? '') === String(b ?? '');
|
||
if (node.op === '!=') return String(a ?? '') !== String(b ?? '');
|
||
if (node.op === '&&') return asBool(a) && asBool(b);
|
||
if (node.op === '||') return asBool(a) || asBool(b);
|
||
}
|
||
if (node.type === 'call') {
|
||
const name = node.name;
|
||
const args = node.args.map(evalNode);
|
||
if (name === 'IF') return args[0] ? (args[1] ?? '') : (args[2] ?? '');
|
||
if (name === 'AND') return args.every((v) => !!v);
|
||
if (name === 'OR') return args.some((v) => !!v);
|
||
if (name === 'NOT') return !args[0];
|
||
if (name === 'MIN') return Math.min(...args.map(asNum));
|
||
if (name === 'MAX') return Math.max(...args.map(asNum));
|
||
if (name === 'ABS') return Math.abs(asNum(args[0]));
|
||
if (name === 'ROUND') {
|
||
const places = Math.max(0, Math.min(10, Math.floor(asNum(args[1] ?? 0))));
|
||
return Number(asNum(args[0]).toFixed(places));
|
||
}
|
||
if (name === 'SUM') return args.reduce((sum, x) => sum + asNum(x), 0);
|
||
if (name === 'COALESCE') return args.find((v) => v !== null && v !== undefined && String(v) !== '') ?? '';
|
||
if (name === 'CONCAT') return args.map((v) => String(v ?? '')).join('');
|
||
return '#ERR';
|
||
}
|
||
return '#ERR';
|
||
};
|
||
try {
|
||
return evalNode(ast);
|
||
} catch (_) {
|
||
return '#ERR';
|
||
}
|
||
};
|
||
|
||
const parseBoolCell = (value) => {
|
||
const normalized = String(value || '').toLowerCase().trim();
|
||
return ['true', '1', 'yes', 'y', 'checked', '✓'].includes(normalized);
|
||
};
|
||
|
||
const parseCsvRecords = (csvText) => {
|
||
const text = String(csvText || '');
|
||
if (!text.trim()) return [];
|
||
const rows = [];
|
||
let row = [];
|
||
let cell = '';
|
||
let inQuotes = false;
|
||
|
||
for (let i = 0; i < text.length; i += 1) {
|
||
const ch = text[i];
|
||
const next = text[i + 1];
|
||
|
||
if (ch === '"') {
|
||
if (inQuotes && next === '"') {
|
||
cell += '"';
|
||
i += 1;
|
||
} else {
|
||
inQuotes = !inQuotes;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (ch === ',' && !inQuotes) {
|
||
row.push(cell);
|
||
cell = '';
|
||
continue;
|
||
}
|
||
|
||
if ((ch === '\n' || ch === '\r') && !inQuotes) {
|
||
if (ch === '\r' && next === '\n') i += 1;
|
||
row.push(cell);
|
||
rows.push(row);
|
||
row = [];
|
||
cell = '';
|
||
continue;
|
||
}
|
||
|
||
cell += ch;
|
||
}
|
||
|
||
if (cell.length > 0 || row.length > 0) {
|
||
row.push(cell);
|
||
rows.push(row);
|
||
}
|
||
|
||
if (rows.length === 0) return [];
|
||
const headers = rows[0].map((h) => h.trim());
|
||
return rows.slice(1).filter((r) => r.some((c) => String(c || '').trim() !== '')).map((r) => {
|
||
const out = {};
|
||
headers.forEach((h, idx) => {
|
||
out[h] = (r[idx] || '').trim();
|
||
});
|
||
return out;
|
||
});
|
||
};
|
||
|
||
const GRID_VIEW_STORAGE_KEY = 'venture_crm_fundraising_views_v1';
|
||
const FOOTER_AGGREGATE_OPTIONS = [
|
||
{ value: 'none', label: 'None' },
|
||
{ value: 'sum', label: 'Sum' },
|
||
{ value: 'avg', label: 'Average' },
|
||
{ value: 'median', label: 'Median' },
|
||
{ value: 'min', label: 'Min' },
|
||
{ value: 'max', label: 'Max' },
|
||
{ value: 'range', label: 'Range' },
|
||
{ value: 'stddev', label: 'Std Dev' },
|
||
{ value: 'filled', label: 'Filled' },
|
||
{ value: 'empty', label: 'Empty' }
|
||
];
|
||
const FOOTER_AGGREGATE_ALLOWED = new Set(FOOTER_AGGREGATE_OPTIONS.map((o) => o.value));
|
||
const DEFAULT_GRID_VIEWS = [
|
||
{ id: 'view-main', name: 'Main Fundraising', filters: { includeGraveyard: false, graveyardOnly: false, followUpOnly: false, lead: '' }, quickSearch: '', hiddenColumns: [], columnFilters: [], footerAggs: {}, rowDensity: 'expanded', columnOrder: [], columnWidths: {} },
|
||
{ id: 'view-followup', name: 'Follow-up List', filters: { includeGraveyard: false, graveyardOnly: false, followUpOnly: true, lead: '' }, quickSearch: '', hiddenColumns: [], columnFilters: [], footerAggs: {}, rowDensity: 'expanded', columnOrder: [], columnWidths: {} },
|
||
{ id: 'view-graveyard', name: 'Graveyard', filters: { includeGraveyard: true, graveyardOnly: true, followUpOnly: false, lead: '' }, quickSearch: '', hiddenColumns: [], columnFilters: [], footerAggs: {}, rowDensity: 'expanded', columnOrder: [], columnWidths: {} },
|
||
{ id: 'view-all', name: 'All Investors', filters: { includeGraveyard: true, graveyardOnly: false, followUpOnly: false, lead: '' }, quickSearch: '', hiddenColumns: [], columnFilters: [], footerAggs: {}, rowDensity: 'expanded', columnOrder: [], columnWidths: {} }
|
||
];
|
||
const PROTECTED_VIEW_IDS = new Set(DEFAULT_GRID_VIEWS.map((v) => v.id));
|
||
|
||
const sanitizeGridViews = (views) => {
|
||
if (!Array.isArray(views)) return DEFAULT_GRID_VIEWS;
|
||
const next = views
|
||
.filter((v) => v && v.id !== 'view-longshot')
|
||
.map((v) => {
|
||
const n = { ...v };
|
||
if (n.filters && typeof n.filters === 'object') {
|
||
const f = { ...n.filters };
|
||
delete f.longshotOnly;
|
||
if (n.id === 'view-graveyard') {
|
||
f.graveyardOnly = true;
|
||
f.includeGraveyard = true;
|
||
} else {
|
||
f.graveyardOnly = !!f.graveyardOnly;
|
||
}
|
||
if (n.id === 'view-followup') {
|
||
f.followUpOnly = true;
|
||
} else {
|
||
f.followUpOnly = !!f.followUpOnly;
|
||
}
|
||
n.filters = f;
|
||
}
|
||
const footerAggs = (n.footerAggs && typeof n.footerAggs === 'object') ? { ...n.footerAggs } : {};
|
||
Object.keys(footerAggs).forEach((k) => {
|
||
if (!FOOTER_AGGREGATE_ALLOWED.has(footerAggs[k])) delete footerAggs[k];
|
||
});
|
||
n.footerAggs = footerAggs;
|
||
n.rowDensity = n.rowDensity === 'compact' ? 'compact' : 'expanded';
|
||
n.columnOrder = Array.isArray(n.columnOrder) ? n.columnOrder.map((id) => String(id || '').trim()).filter(Boolean) : [];
|
||
const columnWidths = (n.columnWidths && typeof n.columnWidths === 'object') ? { ...n.columnWidths } : {};
|
||
Object.keys(columnWidths).forEach((k) => {
|
||
const parsed = Number(columnWidths[k]);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) delete columnWidths[k];
|
||
else columnWidths[k] = Math.max(90, Math.floor(parsed));
|
||
});
|
||
n.columnWidths = columnWidths;
|
||
return n;
|
||
});
|
||
return next.length > 0 ? next : DEFAULT_GRID_VIEWS;
|
||
};
|
||
|
||
const loadGridViews = () => {
|
||
try {
|
||
const raw = localStorage.getItem(GRID_VIEW_STORAGE_KEY);
|
||
if (!raw) return DEFAULT_GRID_VIEWS;
|
||
const parsed = JSON.parse(raw);
|
||
return sanitizeGridViews(parsed);
|
||
} catch (_) {
|
||
return DEFAULT_GRID_VIEWS;
|
||
}
|
||
};
|
||
|
||
// ==================== Components ====================
|
||
const Toast = ({ message, type = 'info', onClose }) => {
|
||
useEffect(() => {
|
||
const timer = setTimeout(onClose, 3000);
|
||
return () => clearTimeout(timer);
|
||
}, [onClose]);
|
||
|
||
return (
|
||
<div className={`toast ${type}`}>
|
||
{message}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const Spinner = () => <div className="spinner"></div>;
|
||
|
||
const SkeletonBlock = ({ lines = 5 }) => {
|
||
const widths = ['w-90', 'w-75', 'w-60', 'w-45', 'w-30'];
|
||
return (
|
||
<div className="skeleton-block">
|
||
{Array.from({ length: lines }).map((_, i) => (
|
||
<div key={i} className={`skeleton-line ${widths[i % widths.length]}`}></div>
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const ConfirmDialog = ({ title, message, onConfirm, onCancel }) => (
|
||
<div className="confirmation-dialog">
|
||
<div className="confirmation-box">
|
||
<div className="confirmation-title">{title}</div>
|
||
<div className="confirmation-message">{message}</div>
|
||
<div className="confirmation-actions">
|
||
<button className="button-secondary" onClick={onCancel}>Cancel</button>
|
||
<button className="button-danger" onClick={onConfirm}>Confirm</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const LoginPage = () => {
|
||
const [username, setUsername] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [error, setError] = useState('');
|
||
const [setupRequired, setSetupRequired] = useState(false);
|
||
const [setupChecking, setSetupChecking] = useState(true);
|
||
const [setupForm, setSetupForm] = useState({ username: '', full_name: '', email: '', password: '' });
|
||
const { login, setupFirstAdmin, loading } = useAuth();
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
const checkSetup = async () => {
|
||
try {
|
||
const result = await fetch('/api/bootstrap/status');
|
||
const data = await result.json();
|
||
if (!cancelled) {
|
||
setSetupRequired(Boolean(data?.data?.setup_required));
|
||
}
|
||
} catch (_) {
|
||
if (!cancelled) setSetupRequired(false);
|
||
} finally {
|
||
if (!cancelled) setSetupChecking(false);
|
||
}
|
||
};
|
||
checkSetup();
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
const handleSubmit = async (e) => {
|
||
e.preventDefault();
|
||
setError('');
|
||
|
||
try {
|
||
await login(username, password);
|
||
} catch (err) {
|
||
setError(getErrorMessage(err, 'Login failed'));
|
||
}
|
||
};
|
||
|
||
const handleSetupSubmit = async (e) => {
|
||
e.preventDefault();
|
||
setError('');
|
||
try {
|
||
await setupFirstAdmin(setupForm);
|
||
} catch (err) {
|
||
setError(getErrorMessage(err, 'Setup failed'));
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="login-container">
|
||
<div className="login-card">
|
||
<div className="login-logo-wrap">
|
||
<img className="login-logo-img" src="/assets/ten31-inverted-square.png" alt="Ten31" />
|
||
</div>
|
||
<div className="login-subtitle">
|
||
{setupRequired ? 'Create your first admin account' : 'Sign in to your account'}
|
||
</div>
|
||
{MOCK_MODE && (
|
||
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '12px' }}>
|
||
Prototype mode: use `admin / admin123` or `grant / password`
|
||
</div>
|
||
)}
|
||
|
||
{error && <div className="toast error" style={{ position: 'static', marginBottom: '16px' }}>{error}</div>}
|
||
|
||
{setupChecking ? (
|
||
<SkeletonBlock lines={3} />
|
||
) : setupRequired ? (
|
||
<form className="login-form" onSubmit={handleSetupSubmit}>
|
||
<div className="form-group">
|
||
<label className="form-label">Username</label>
|
||
<input type="text" className="text-input" value={setupForm.username} onChange={(e) => setSetupForm((f) => ({ ...f, username: e.target.value }))} required />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Full Name</label>
|
||
<input type="text" className="text-input" value={setupForm.full_name} onChange={(e) => setSetupForm((f) => ({ ...f, full_name: e.target.value }))} required />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Email</label>
|
||
<input type="email" className="text-input" value={setupForm.email} onChange={(e) => setSetupForm((f) => ({ ...f, email: e.target.value }))} required />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Password (min 8)</label>
|
||
<input type="password" className="text-input" value={setupForm.password} onChange={(e) => setSetupForm((f) => ({ ...f, password: e.target.value }))} required />
|
||
</div>
|
||
<button type="submit" className="login-button" disabled={loading}>
|
||
{loading ? <Spinner /> : 'Create Admin'}
|
||
</button>
|
||
</form>
|
||
) : (
|
||
<form className="login-form" onSubmit={handleSubmit}>
|
||
<div className="form-group">
|
||
<label className="form-label">Username</label>
|
||
<input
|
||
type="text"
|
||
className="text-input"
|
||
value={username}
|
||
onChange={(e) => setUsername(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label className="form-label">Password</label>
|
||
<input
|
||
type="password"
|
||
className="text-input"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<button type="submit" className="login-button" disabled={loading}>
|
||
{loading ? <Spinner /> : 'Sign In'}
|
||
</button>
|
||
</form>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
class AppErrorBoundary extends React.Component {
|
||
constructor(props) {
|
||
super(props);
|
||
this.state = { hasError: false, message: '' };
|
||
}
|
||
|
||
static getDerivedStateFromError(error) {
|
||
return { hasError: true, message: String(error?.message || error || 'Unknown error') };
|
||
}
|
||
|
||
componentDidCatch(error) {
|
||
console.error('App runtime error:', error);
|
||
}
|
||
|
||
render() {
|
||
if (this.state.hasError) {
|
||
return (
|
||
<div className="page-container">
|
||
<div className="toast error" style={{ position: 'static' }}>
|
||
Runtime error: {this.state.message}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
return this.props.children;
|
||
}
|
||
}
|
||
|
||
const DashboardPage = ({ token }) => {
|
||
const [data, setData] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
|
||
useEffect(() => {
|
||
const fetchDashboard = async () => {
|
||
try {
|
||
const result = await api('/api/reports/dashboard', {}, token);
|
||
setData(result.data);
|
||
} catch (err) {
|
||
setError(err.message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
fetchDashboard();
|
||
}, [token]);
|
||
|
||
if (loading) return <div style={{ padding: '20px' }}><SkeletonBlock lines={7} /></div>;
|
||
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
|
||
if (!data) return <div className="empty-state">No data</div>;
|
||
|
||
const metrics = data.metrics || {};
|
||
const stages = data.pipeline_stages || [];
|
||
const communications = data.recent_communications || [];
|
||
const actions = data.upcoming_actions || [];
|
||
|
||
return (
|
||
<div className="page-container">
|
||
<h2 className="section-title" style={{ marginBottom: '20px' }}>Dashboard</h2>
|
||
|
||
<div className="kpi-grid">
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Total LPs</div>
|
||
<div className="kpi-value">{metrics.total_lps || 0}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Total Committed</div>
|
||
<div className="kpi-value">{formatCurrencyLong(metrics.total_committed)}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Pipeline Value</div>
|
||
<div className="kpi-value">{formatCurrencyLong(metrics.pipeline_value)}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Active Opportunities</div>
|
||
<div className="kpi-value">{metrics.active_opportunities || 0}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Prospects</div>
|
||
<div className="kpi-value">{metrics.prospects ?? metrics.total_prospects ?? 0}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Comms This Month</div>
|
||
<div className="kpi-value">{metrics.communications_month ?? metrics.comms_this_month ?? 0}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="section">
|
||
<div className="section-title">Pipeline by Stage</div>
|
||
<div className="pipeline-summary">
|
||
{stages.map((stage) => (
|
||
<div key={stage.stage} className="pipeline-stage-card">
|
||
<div className="pipeline-stage-name">{stage.stage}</div>
|
||
<div className="pipeline-stage-count">{stage.count}</div>
|
||
<div className="pipeline-stage-amount">{formatCurrencyLong(stage.total_amount ?? stage.total_value ?? 0)}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="section">
|
||
<div className="section-title">Recent Communications</div>
|
||
{communications.length === 0 ? (
|
||
<div className="empty-state">No recent communications</div>
|
||
) : (
|
||
<div className="timeline">
|
||
{communications.slice(0, 10).map((comm) => (
|
||
<div key={comm.id} className="timeline-item">
|
||
<div className="timeline-marker"></div>
|
||
<div className="timeline-content">
|
||
<div className="timeline-header">
|
||
{contactName(comm)} - {comm.type}
|
||
</div>
|
||
<div className="timeline-meta">{formatDate(comm.communication_date)}</div>
|
||
{comm.subject && <div className="timeline-body">{comm.subject}</div>}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="section">
|
||
<div className="section-title">Upcoming Actions</div>
|
||
{actions.length === 0 ? (
|
||
<div className="empty-state">No upcoming actions</div>
|
||
) : (
|
||
<div className="timeline">
|
||
{actions.slice(0, 10).map((action) => (
|
||
<div key={action.id} className="timeline-item">
|
||
<div className="timeline-marker"></div>
|
||
<div className="timeline-content">
|
||
<div className="timeline-header">{action.description || action.next_action || '-'}</div>
|
||
<div className="timeline-meta">Due: {formatDate(action.due_date || action.next_action_date)}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const ContactsPage = ({ token, onShowToast }) => {
|
||
const CONTACTS_PAGE_SIZE = 100;
|
||
const [contacts, setContacts] = useState([]);
|
||
const [contactsTotal, setContactsTotal] = useState(0);
|
||
const [contactsPage, setContactsPage] = useState(1);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [tab, setTab] = useState('all');
|
||
const [search, setSearch] = useState('');
|
||
const [sort, setSort] = useState('last_name');
|
||
const [order, setOrder] = useState('asc');
|
||
const [selectedContact, setSelectedContact] = useState(null);
|
||
const [deleting, setDeleting] = useState(null);
|
||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||
|
||
const tabFilter = tab === 'all' ? '' : tab === 'investors' ? 'investor' : 'prospect';
|
||
const contactsOffset = (contactsPage - 1) * CONTACTS_PAGE_SIZE;
|
||
const contactsMaxPage = Math.max(1, Math.ceil((Number(contactsTotal) || 0) / CONTACTS_PAGE_SIZE));
|
||
|
||
useEffect(() => {
|
||
setContactsPage(1);
|
||
}, [tab, search, sort, order]);
|
||
|
||
useEffect(() => {
|
||
const fetchContacts = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const result = await api(`/api/contacts?search=${encodeURIComponent(search)}&type=${tabFilter}&sort=${sort}&order=${order}&limit=${CONTACTS_PAGE_SIZE}&offset=${contactsOffset}`, {}, token);
|
||
setContacts(result.data || []);
|
||
setContactsTotal(Number(result.total) || 0);
|
||
} catch (err) {
|
||
setError(err.message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
fetchContacts();
|
||
}, [token, tab, search, sort, order, contactsOffset]);
|
||
|
||
const handleDeleteContact = async (id) => {
|
||
setDeleting(id);
|
||
try {
|
||
await api(`/api/contacts/${id}`, { method: 'DELETE' }, token);
|
||
setContacts(contacts.filter(c => c.id !== id));
|
||
setConfirmDelete(null);
|
||
setSelectedContact(null);
|
||
onShowToast('Contact deleted', 'success');
|
||
} catch (err) {
|
||
onShowToast(err.message, 'error');
|
||
} finally {
|
||
setDeleting(null);
|
||
}
|
||
};
|
||
|
||
const contactTypeTooltip = (type) => {
|
||
const badges = {
|
||
'investor': 'badge-investor',
|
||
'prospect': 'badge-prospect',
|
||
'advisor': 'badge-advisor',
|
||
'other': 'badge-other'
|
||
};
|
||
return badges[type] || 'badge-other';
|
||
};
|
||
|
||
const handleSort = (column) => {
|
||
if (sort === column) {
|
||
setOrder(order === 'asc' ? 'desc' : 'asc');
|
||
} else {
|
||
setSort(column);
|
||
setOrder('asc');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="page-container">
|
||
<h2 className="section-title">Contacts</h2>
|
||
|
||
<div className="section">
|
||
<div className="tabs">
|
||
{['all', 'investors', 'prospects'].map(t => (
|
||
<button
|
||
key={t}
|
||
className={`tab ${tab === t ? 'active' : ''}`}
|
||
onClick={() => setTab(t)}
|
||
>
|
||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="controls">
|
||
<input
|
||
type="text"
|
||
className="search-input"
|
||
placeholder="Search contacts..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
/>
|
||
<span style={{ fontSize: '12px', color: '#8a93a6', alignSelf: 'center' }}>New people are added from the Fundraising Grid; click anyone here to view or edit.</span>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<SkeletonBlock lines={8} />
|
||
) : contacts.length === 0 ? (
|
||
<div className="empty-state">No contacts found</div>
|
||
) : (
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th onClick={() => handleSort('last_name')} style={{ cursor: 'pointer' }}>
|
||
Name {sort === 'last_name' && (order === 'asc' ? '▲' : '▼')}
|
||
</th>
|
||
<th>Organization</th>
|
||
<th onClick={() => handleSort('source')} style={{ cursor: 'pointer' }}>
|
||
Lead Source {sort === 'source' && (order === 'asc' ? '▲' : '▼')}
|
||
</th>
|
||
<th>Type</th>
|
||
<th>Email</th>
|
||
<th>Last Contact</th>
|
||
<th>Communications</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{contacts.map(contact => (
|
||
<tr key={contact.id} onClick={() => setSelectedContact(contact)}>
|
||
<td>{contact.first_name} {contact.last_name}</td>
|
||
<td>{contact.organization || contact.organization_name || '-'}</td>
|
||
<td>{contact.source || '-'}</td>
|
||
<td>
|
||
<span className={`badge ${contactTypeTooltip(contact.contact_type)}`}>
|
||
{contact.contact_type}
|
||
</span>
|
||
</td>
|
||
<td>{contact.email || '-'}</td>
|
||
<td>{formatDate(contact.last_contact_date)}</td>
|
||
<td>{contact.communication_count ?? contact.comm_count ?? 0}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
{!loading && contactsTotal > CONTACTS_PAGE_SIZE && (
|
||
<div style={{ marginTop: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '10px', flexWrap: 'wrap' }}>
|
||
<div style={{ fontSize: '12px', color: '#8ea2b7' }}>
|
||
Showing {contacts.length === 0 ? 0 : contactsOffset + 1}-{Math.min(contactsOffset + contacts.length, contactsTotal)} of {contactsTotal}
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<button
|
||
type="button"
|
||
className="button-secondary"
|
||
onClick={() => setContactsPage((p) => Math.max(1, p - 1))}
|
||
disabled={contactsPage <= 1}
|
||
>
|
||
Prev
|
||
</button>
|
||
<span style={{ fontSize: '12px', color: '#c7d3e0' }}>Page {contactsPage} / {contactsMaxPage}</span>
|
||
<button
|
||
type="button"
|
||
className="button-secondary"
|
||
onClick={() => setContactsPage((p) => Math.min(contactsMaxPage, p + 1))}
|
||
disabled={contactsPage >= contactsMaxPage}
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{selectedContact && (
|
||
<ContactDetailPanel
|
||
contact={selectedContact}
|
||
onClose={() => setSelectedContact(null)}
|
||
onDelete={() => setConfirmDelete(selectedContact.id)}
|
||
token={token}
|
||
onRefresh={() => {
|
||
const result = api(`/api/contacts?search=${encodeURIComponent(search)}&type=${tabFilter}&sort=${sort}&order=${order}&limit=${CONTACTS_PAGE_SIZE}&offset=${contactsOffset}`, {}, token);
|
||
result.then(r => {
|
||
setContacts(r.data || []);
|
||
setContactsTotal(Number(r.total) || 0);
|
||
});
|
||
}}
|
||
onShowToast={onShowToast}
|
||
/>
|
||
)}
|
||
|
||
{confirmDelete && (
|
||
<ConfirmDialog
|
||
title="Delete Contact"
|
||
message="Are you sure you want to delete this contact? This action cannot be undone."
|
||
onConfirm={() => handleDeleteContact(confirmDelete)}
|
||
onCancel={() => setConfirmDelete(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const ContactDetailPanel = ({ contact, onClose, onDelete, token, onShowToast, onRefresh }) => {
|
||
const [details, setDetails] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [editMode, setEditMode] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [editDraft, setEditDraft] = useState({
|
||
first_name: '',
|
||
last_name: '',
|
||
email: '',
|
||
phone: '',
|
||
title: '',
|
||
organization: '',
|
||
source: '',
|
||
linkedin_url: '',
|
||
city: '',
|
||
state: '',
|
||
country: '',
|
||
contact_type: 'prospect',
|
||
status: 'active'
|
||
});
|
||
|
||
useEffect(() => {
|
||
const fetchDetails = async () => {
|
||
try {
|
||
const result = await api(`/api/contacts/${contact.id}`, {}, token);
|
||
setDetails(result.data);
|
||
} catch (err) {
|
||
onShowToast(err.message, 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
fetchDetails();
|
||
}, [contact.id, token, onShowToast]);
|
||
|
||
useEffect(() => {
|
||
if (!details) return;
|
||
setEditDraft({
|
||
first_name: details.first_name || '',
|
||
last_name: details.last_name || '',
|
||
email: details.email || '',
|
||
phone: details.phone || '',
|
||
title: details.title || '',
|
||
organization: details.organization || details.organization_name || '',
|
||
source: details.source || '',
|
||
linkedin_url: details.linkedin_url || '',
|
||
city: details.city || '',
|
||
state: details.state || '',
|
||
country: details.country || '',
|
||
contact_type: details.contact_type || 'prospect',
|
||
status: details.status || 'active'
|
||
});
|
||
}, [details]);
|
||
|
||
const handleSaveEdit = async () => {
|
||
if (!String(editDraft.first_name || '').trim() || !String(editDraft.last_name || '').trim()) {
|
||
onShowToast('First and last name are required', 'error');
|
||
return;
|
||
}
|
||
setSaving(true);
|
||
try {
|
||
const result = await api(`/api/contacts/${contact.id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
first_name: editDraft.first_name.trim(),
|
||
last_name: editDraft.last_name.trim(),
|
||
email: editDraft.email.trim(),
|
||
phone: editDraft.phone.trim(),
|
||
title: editDraft.title.trim(),
|
||
organization: editDraft.organization.trim(),
|
||
source: editDraft.source.trim(),
|
||
linkedin_url: editDraft.linkedin_url.trim(),
|
||
city: editDraft.city.trim(),
|
||
state: editDraft.state.trim(),
|
||
country: editDraft.country.trim(),
|
||
contact_type: editDraft.contact_type,
|
||
status: editDraft.status
|
||
})
|
||
}, token);
|
||
setDetails(result?.data || details);
|
||
setEditMode(false);
|
||
if (onRefresh) onRefresh();
|
||
onShowToast('Contact updated', 'success');
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to update contact'), 'error');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="slide-over">
|
||
<div style={{ padding: '20px' }}><SkeletonBlock lines={6} /></div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!details) return null;
|
||
|
||
return (
|
||
<div className="slide-over">
|
||
<div className="slide-over-header">
|
||
<div className="slide-over-title">{details.first_name} {details.last_name}</div>
|
||
<button className="close-btn" onClick={onClose}>×</button>
|
||
</div>
|
||
|
||
<div className="detail-section">
|
||
<div className="detail-section-title">Information</div>
|
||
{editMode ? (
|
||
<>
|
||
<div className="form-group">
|
||
<label className="form-label">First Name *</label>
|
||
<input className="text-input" value={editDraft.first_name} onChange={(e) => setEditDraft((d) => ({ ...d, first_name: e.target.value }))} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Last Name *</label>
|
||
<input className="text-input" value={editDraft.last_name} onChange={(e) => setEditDraft((d) => ({ ...d, last_name: e.target.value }))} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Email</label>
|
||
<input type="email" className="text-input" value={editDraft.email} onChange={(e) => setEditDraft((d) => ({ ...d, email: e.target.value }))} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Phone</label>
|
||
<input className="text-input" value={editDraft.phone} onChange={(e) => setEditDraft((d) => ({ ...d, phone: e.target.value }))} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Title</label>
|
||
<input className="text-input" value={editDraft.title} onChange={(e) => setEditDraft((d) => ({ ...d, title: e.target.value }))} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Organization</label>
|
||
<input className="text-input" value={editDraft.organization} onChange={(e) => setEditDraft((d) => ({ ...d, organization: e.target.value }))} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Lead Source</label>
|
||
<input className="text-input" value={editDraft.source} onChange={(e) => setEditDraft((d) => ({ ...d, source: e.target.value }))} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">LinkedIn URL</label>
|
||
<input className="text-input" value={editDraft.linkedin_url} onChange={(e) => setEditDraft((d) => ({ ...d, linkedin_url: e.target.value }))} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">City</label>
|
||
<input className="text-input" value={editDraft.city} onChange={(e) => setEditDraft((d) => ({ ...d, city: e.target.value }))} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">State</label>
|
||
<input className="text-input" value={editDraft.state} onChange={(e) => setEditDraft((d) => ({ ...d, state: e.target.value }))} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Country</label>
|
||
<input className="text-input" value={editDraft.country} onChange={(e) => setEditDraft((d) => ({ ...d, country: e.target.value }))} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Type</label>
|
||
<select className="select-input" value={editDraft.contact_type} onChange={(e) => setEditDraft((d) => ({ ...d, contact_type: e.target.value }))}>
|
||
<option value="investor">Investor</option>
|
||
<option value="prospect">Prospect</option>
|
||
<option value="advisor">Advisor</option>
|
||
<option value="other">Other</option>
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Status</label>
|
||
<select className="select-input" value={editDraft.status} onChange={(e) => setEditDraft((d) => ({ ...d, status: e.target.value }))}>
|
||
<option value="active">Active</option>
|
||
<option value="inactive">Inactive</option>
|
||
<option value="prospect">Prospect</option>
|
||
</select>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Email</span>
|
||
<span className="detail-value">{details.email || '-'}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Phone</span>
|
||
<span className="detail-value">{details.phone || '-'}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Title</span>
|
||
<span className="detail-value">{details.title || '-'}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Organization</span>
|
||
<span className="detail-value">{details.organization || details.organization_name || '-'}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Lead Source</span>
|
||
<span className="detail-value">{details.source || '-'}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">LinkedIn</span>
|
||
<span className="detail-value">{details.linkedin_url || '-'}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Location</span>
|
||
<span className="detail-value">
|
||
{[details.city, details.state, details.country].filter(Boolean).join(', ') || details.location_query || '-'}
|
||
</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Type</span>
|
||
<span className="detail-value">
|
||
<span className={`badge ${details.contact_type === 'investor' ? 'badge-investor' : 'badge-prospect'}`}>
|
||
{details.contact_type}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{details.opportunities && details.opportunities.length > 0 && (
|
||
<div className="detail-section">
|
||
<div className="detail-section-title">Opportunities</div>
|
||
{details.opportunities.map(opp => (
|
||
<div key={opp.id} style={{ marginBottom: '12px', paddingBottom: '12px', borderBottom: '1px solid #263548' }}>
|
||
<div style={{ color: '#e5edf5', fontWeight: 500 }}>{opp.name}</div>
|
||
<div style={{ fontSize: '12px', color: '#8ea2b7' }}>
|
||
{opp.stage} · {formatCurrencyLong(opp.expected_amount)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{details.communications && details.communications.length > 0 && (
|
||
<div className="detail-section">
|
||
<div className="detail-section-title">Communications</div>
|
||
<div className="timeline">
|
||
{details.communications.map(comm => (
|
||
<div key={comm.id} className="timeline-item">
|
||
<div className="timeline-marker"></div>
|
||
<div className="timeline-content">
|
||
<div className="timeline-header">{comm.type}</div>
|
||
<div className="timeline-meta">{formatDate(comm.communication_date)}</div>
|
||
{comm.subject && <div className="timeline-body">{comm.subject}</div>}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ marginTop: '20px', paddingTop: '20px', borderTop: '1px solid #263548' }}>
|
||
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
|
||
{editMode ? (
|
||
<>
|
||
<button className="button-secondary" style={{ flex: 1 }} onClick={() => setEditMode(false)} disabled={saving}>Cancel</button>
|
||
<button style={{ flex: 1 }} onClick={handleSaveEdit} disabled={saving}>
|
||
{saving ? <Spinner /> : 'Save Contact'}
|
||
</button>
|
||
</>
|
||
) : (
|
||
<button className="button-secondary" style={{ width: '100%' }} onClick={() => setEditMode(true)}>Edit Contact</button>
|
||
)}
|
||
</div>
|
||
<button className="button-danger" style={{ width: '100%' }} onClick={onDelete} disabled={saving}>Delete Contact</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const PipelinePage = ({ token, onShowToast }) => {
|
||
const [opportunities, setOpportunities] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [selectedOpp, setSelectedOpp] = useState(null);
|
||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||
|
||
const stages = ['lead', 'outreach', 'meeting', 'due_diligence', 'committed', 'funded'];
|
||
|
||
useEffect(() => {
|
||
const fetchOpportunities = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const oppResult = await api('/api/opportunities?limit=1000', {}, token);
|
||
setOpportunities(oppResult.data || []);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to load pipeline'), 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
fetchOpportunities();
|
||
}, [token, onShowToast]);
|
||
|
||
const handleDeleteOpp = async (id) => {
|
||
try {
|
||
await api(`/api/opportunities/${id}`, { method: 'DELETE' }, token);
|
||
setOpportunities(opportunities.filter(o => o.id !== id));
|
||
setConfirmDelete(null);
|
||
setSelectedOpp(null);
|
||
onShowToast('Opportunity deleted', 'success');
|
||
} catch (err) {
|
||
onShowToast(err.message, 'error');
|
||
}
|
||
};
|
||
|
||
const handleChangeStage = async (oppId, newStage) => {
|
||
try {
|
||
await api(`/api/opportunities/${oppId}/stage`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ stage: newStage })
|
||
}, token);
|
||
|
||
setOpportunities(opportunities.map(o =>
|
||
o.id === oppId ? { ...o, stage: newStage } : o
|
||
));
|
||
onShowToast('Stage updated', 'success');
|
||
} catch (err) {
|
||
onShowToast(err.message, 'error');
|
||
}
|
||
};
|
||
|
||
const getPriorityBadge = (priority) => {
|
||
const badges = {
|
||
'high': 'badge-high',
|
||
'medium': 'badge-medium',
|
||
'low': 'badge-low'
|
||
};
|
||
return badges[priority] || 'badge-medium';
|
||
};
|
||
|
||
const opportunitiesByStage = useMemo(() => {
|
||
const result = {};
|
||
stages.forEach(s => result[s] = []);
|
||
opportunities.forEach(o => {
|
||
if (result[o.stage]) result[o.stage].push(o);
|
||
});
|
||
return result;
|
||
}, [opportunities]);
|
||
|
||
const stageTotals = useMemo(() => {
|
||
const totals = {};
|
||
stages.forEach(s => {
|
||
totals[s] = opportunitiesByStage[s]?.reduce((sum, o) => sum + (o.expected_amount || 0), 0) || 0;
|
||
});
|
||
return totals;
|
||
}, [opportunitiesByStage]);
|
||
|
||
return (
|
||
<div className="page-container">
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||
<h2 className="section-title">Pipeline</h2>
|
||
<span style={{ fontSize: '12px', color: '#8ea2b7' }}>Add deals from the Fundraising Grid — "+ Pipeline" on an investor row</span>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div style={{ padding: '20px' }}><SkeletonBlock lines={7} /></div>
|
||
) : (
|
||
<>
|
||
<div className="pipeline-summary">
|
||
{stages.map(stage => (
|
||
<div key={stage} className="pipeline-stage-card">
|
||
<div className="pipeline-stage-name">{stage.replace('_', ' ')}</div>
|
||
<div className="pipeline-stage-count">{opportunitiesByStage[stage].length}</div>
|
||
<div className="pipeline-stage-amount">{formatCurrencyLong(stageTotals[stage])}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="kanban-board">
|
||
{stages.map(stage => (
|
||
<div key={stage} className="kanban-column">
|
||
<div className="kanban-header">{stage.replace(/_/g, ' ')}</div>
|
||
{opportunitiesByStage[stage].map(opp => (
|
||
<div
|
||
key={opp.id}
|
||
className="kanban-card"
|
||
onClick={() => setSelectedOpp(opp)}
|
||
>
|
||
<div className="kanban-card-title">{opp.name}</div>
|
||
<div className="kanban-card-subtitle">{contactName(opp)}</div>
|
||
<div className="kanban-card-amount">{formatCurrencyLong(opp.expected_amount)}</div>
|
||
<span className={`badge ${getPriorityBadge(opp.priority)}`}>
|
||
{opp.priority}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{selectedOpp && (
|
||
<OpportunityDetailPanel
|
||
opp={selectedOpp}
|
||
onClose={() => setSelectedOpp(null)}
|
||
onStageChange={handleChangeStage}
|
||
onDelete={() => setConfirmDelete(selectedOpp.id)}
|
||
token={token}
|
||
stages={stages}
|
||
onShowToast={onShowToast}
|
||
/>
|
||
)}
|
||
|
||
{confirmDelete && (
|
||
<ConfirmDialog
|
||
title="Delete Opportunity"
|
||
message="Are you sure? This cannot be undone."
|
||
onConfirm={() => handleDeleteOpp(confirmDelete)}
|
||
onCancel={() => setConfirmDelete(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const OpportunityDetailPanel = ({ opp, onClose, onStageChange, onDelete, token, stages, onShowToast }) => {
|
||
const [details, setDetails] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
const fetchDetails = async () => {
|
||
try {
|
||
const result = await api(`/api/opportunities/${opp.id}`, {}, token);
|
||
setDetails(result.data);
|
||
} catch (err) {
|
||
onShowToast(err.message, 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
fetchDetails();
|
||
}, [opp.id, token, onShowToast]);
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="slide-over">
|
||
<div style={{ padding: '20px' }}><SkeletonBlock lines={6} /></div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!details) return null;
|
||
|
||
return (
|
||
<div className="slide-over">
|
||
<div className="slide-over-header">
|
||
<div className="slide-over-title">{details.name}</div>
|
||
<button className="close-btn" onClick={onClose}>×</button>
|
||
</div>
|
||
|
||
<div className="detail-section">
|
||
<div className="detail-section-title">Details</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Contact</span>
|
||
<span className="detail-value">{contactName(details)}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Organization</span>
|
||
<span className="detail-value">{details.organization_name || '-'}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Stage</span>
|
||
<span className="detail-value">
|
||
<select
|
||
className="select-input"
|
||
value={details.stage}
|
||
onChange={(e) => {
|
||
onStageChange(details.id, e.target.value);
|
||
setDetails({ ...details, stage: e.target.value });
|
||
}}
|
||
style={{ width: '100%' }}
|
||
>
|
||
{stages.map(s => (
|
||
<option key={s} value={s}>{s.replace(/_/g, ' ')}</option>
|
||
))}
|
||
</select>
|
||
</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Expected Amount</span>
|
||
<span className="detail-value">{formatCurrencyLong(details.expected_amount)}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Probability</span>
|
||
<span className="detail-value">{(details.probability || 0) > 1 ? `${details.probability}%` : `${Math.round((details.probability || 0) * 100)}%`}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Priority</span>
|
||
<span className="detail-value">
|
||
<span className={`badge ${details.priority === 'high' ? 'badge-high' : details.priority === 'low' ? 'badge-low' : 'badge-medium'}`}>
|
||
{details.priority}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Fund</span>
|
||
<span className="detail-value">{details.fund_name || '-'}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Expected Close</span>
|
||
<span className="detail-value">{formatDateLong(details.expected_close_date) || '-'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{details.communications && details.communications.length > 0 && (
|
||
<div className="detail-section">
|
||
<div className="detail-section-title">Communications</div>
|
||
<div className="timeline">
|
||
{details.communications.map(comm => (
|
||
<div key={comm.id} className="timeline-item">
|
||
<div className="timeline-marker"></div>
|
||
<div className="timeline-content">
|
||
<div className="timeline-header">{comm.type}</div>
|
||
<div className="timeline-meta">{formatDate(comm.communication_date)}</div>
|
||
{comm.subject && <div className="timeline-body">{comm.subject}</div>}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ marginTop: '20px', paddingTop: '20px', borderTop: '1px solid #263548' }}>
|
||
<button className="button-danger" style={{ width: '100%' }} onClick={onDelete}>Delete Opportunity</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const CommunicationsPage = ({ token, user, onShowToast }) => {
|
||
// Repurposed (v0.1.0:80): the Communications tab is now the admin-only
|
||
// email-activity panel over the captured email_* tables. The classic
|
||
// manual "Log Communication" surface was retired (grid is canonical).
|
||
const isAdmin = user?.role === 'admin';
|
||
const [data, setData] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [disabled, setDisabled] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [investorId, setInvestorId] = useState('');
|
||
const [accountId, setAccountId] = useState('');
|
||
const [direction, setDirection] = useState('');
|
||
const [since, setSince] = useState('');
|
||
const [until, setUntil] = useState('');
|
||
const [search, setSearch] = useState('');
|
||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||
const [expandedId, setExpandedId] = useState(null);
|
||
const [detailCache, setDetailCache] = useState({}); // id -> {loading, data, error}
|
||
const [mode, setMode] = useState('filter'); // 'filter' | 'search' (semantic content)
|
||
const [contentQuery, setContentQuery] = useState('');
|
||
const [searchResults, setSearchResults] = useState(null); // {results, count} | null
|
||
const [searchLoading, setSearchLoading] = useState(false);
|
||
const [searchError, setSearchError] = useState('');
|
||
|
||
// Debounce the free-text box so each keystroke doesn't hit the server.
|
||
useEffect(() => {
|
||
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||
return () => clearTimeout(t);
|
||
}, [search]);
|
||
|
||
useEffect(() => {
|
||
if (!isAdmin) { setLoading(false); return undefined; }
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
setLoading(true);
|
||
const params = new URLSearchParams();
|
||
if (investorId) params.set('investor_id', investorId);
|
||
if (accountId) params.set('account_id', accountId);
|
||
if (direction) params.set('direction', direction);
|
||
if (since) params.set('since', since + 'T00:00:00');
|
||
// `until` is exclusive in the query; send the day AFTER the picked
|
||
// date at midnight so the whole "to" day is included regardless of
|
||
// the stored timestamp's precision/zone suffix.
|
||
if (until) {
|
||
const u = new Date(until + 'T00:00:00');
|
||
u.setDate(u.getDate() + 1);
|
||
const nd = `${u.getFullYear()}-${String(u.getMonth() + 1).padStart(2, '0')}-${String(u.getDate()).padStart(2, '0')}`;
|
||
params.set('until', nd + 'T00:00:00');
|
||
}
|
||
if (debouncedSearch) params.set('q', debouncedSearch);
|
||
params.set('limit', '200');
|
||
const res = await api(`/api/email/activity?${params.toString()}`, {}, token);
|
||
if (cancelled) return;
|
||
setData(res);
|
||
setDisabled(false);
|
||
setError('');
|
||
} catch (err) {
|
||
if (cancelled) return;
|
||
// Integration off on the server -> show the disabled state, not an error.
|
||
if (err?.status === 503 || /disabl/i.test(err?.payload?.error || '')) {
|
||
setDisabled(true);
|
||
setData(null);
|
||
} else {
|
||
setError(getErrorMessage(err, 'Failed to load email activity'));
|
||
}
|
||
} finally {
|
||
if (!cancelled) setLoading(false);
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [token, isAdmin, investorId, accountId, direction, since, until, debouncedSearch]);
|
||
|
||
// Lazily fetch and cache the full body when a row is expanded.
|
||
const toggleExpand = async (id) => {
|
||
if (expandedId === id) { setExpandedId(null); return; }
|
||
setExpandedId(id);
|
||
if (detailCache[id]) return;
|
||
setDetailCache((c) => ({ ...c, [id]: { loading: true } }));
|
||
try {
|
||
const res = await api(`/api/email/detail?id=${encodeURIComponent(id)}`, {}, token);
|
||
setDetailCache((c) => ({ ...c, [id]: { loading: false, data: res } }));
|
||
} catch (err) {
|
||
setDetailCache((c) => ({ ...c, [id]: { loading: false, error: getErrorMessage(err, 'Failed to load email') } }));
|
||
}
|
||
};
|
||
|
||
// Shared expanded-body panel (used by both the filter list and search results).
|
||
const renderExpandedBody = (id) => {
|
||
const det = detailCache[id];
|
||
return (
|
||
<div style={{ marginTop: '8px', border: '1px solid #263548', borderRadius: '6px', background: '#0d1622', padding: '10px' }}>
|
||
{det?.loading ? <SkeletonBlock lines={4} />
|
||
: det?.error ? <div className="toast error" style={{ position: 'static' }}>{det.error}</div>
|
||
: det?.data ? (() => {
|
||
const d = det.data;
|
||
const rcpt = (kind) => (d.recipients || []).filter((r) => r.kind === kind)
|
||
.map((r) => r.display_name ? `${r.display_name} <${r.address}>` : r.address).join(', ');
|
||
const to = rcpt('to'), cc = rcpt('cc');
|
||
return (
|
||
<>
|
||
{to && <div style={{ fontSize: '11px', color: '#8ea2b7' }}><b>To:</b> {to}</div>}
|
||
{cc && <div style={{ fontSize: '11px', color: '#8ea2b7' }}><b>Cc:</b> {cc}</div>}
|
||
{(d.attachments || []).length > 0 && (
|
||
<div style={{ fontSize: '11px', color: '#8ea2b7', marginTop: '2px' }}>
|
||
<b>Attachments:</b> {d.attachments.map((a) => a.filename).join(', ')}
|
||
</div>
|
||
)}
|
||
<pre style={{ margin: '8px 0 0', whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: '12px', lineHeight: 1.5, color: '#cdd9e5', fontFamily: 'inherit' }}>
|
||
{d.body_text || (d.has_html ? '(HTML-only email — open in Gmail to view formatting)' : '(no body captured)')}
|
||
</pre>
|
||
</>
|
||
);
|
||
})() : null}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Semantic search over email *content* (bodies) — heavier (Spark + Qdrant),
|
||
// so it runs on submit, not per keystroke.
|
||
const runContentSearch = async () => {
|
||
const query = contentQuery.trim();
|
||
if (!query) { setSearchResults(null); setSearchError(''); return; }
|
||
setSearchLoading(true); setSearchError('');
|
||
try {
|
||
const res = await api(`/api/email/search?q=${encodeURIComponent(query)}`, {}, token);
|
||
setSearchResults(res);
|
||
} catch (err) {
|
||
if (err?.status === 503) {
|
||
setSearchError('Content search is unavailable right now (Spark/Qdrant not reachable).');
|
||
} else {
|
||
setSearchError(getErrorMessage(err, 'Search failed'));
|
||
}
|
||
setSearchResults(null);
|
||
} finally {
|
||
setSearchLoading(false);
|
||
}
|
||
};
|
||
|
||
if (!isAdmin) {
|
||
return (
|
||
<div className="page-container">
|
||
<h2 className="section-title" style={{ marginBottom: '20px' }}>Communications</h2>
|
||
<div className="empty-state">Communications is an admin-only view (captured email activity).</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const emails = data?.emails || [];
|
||
const accounts = data?.accounts || [];
|
||
const investors = data?.investors || [];
|
||
const hasFilter = !!(investorId || accountId || direction || since || until || debouncedSearch);
|
||
|
||
return (
|
||
<div className="page-container">
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||
<h2 className="section-title">Communications</h2>
|
||
<span className="form-help">Captured email activity. Logging & drafts live in the Fundraising Grid and Outreach.</span>
|
||
</div>
|
||
<div className="section">
|
||
<div style={{ display: 'flex', gap: '8px', marginBottom: '14px' }}>
|
||
<button type="button" onClick={() => setMode('filter')}
|
||
style={mode === 'filter' ? {} : { background: 'transparent', color: '#8ea2b7', border: '1px solid #2a3a4d', boxShadow: 'none' }}>
|
||
Filter
|
||
</button>
|
||
<button type="button" onClick={() => setMode('search')}
|
||
style={mode === 'search' ? {} : { background: 'transparent', color: '#8ea2b7', border: '1px solid #2a3a4d', boxShadow: 'none' }}>
|
||
Search content
|
||
</button>
|
||
<span className="form-help" style={{ alignSelf: 'center' }}>
|
||
{mode === 'filter' ? 'Structured filters over captured email metadata.' : 'Semantic search over email bodies (finds by meaning, not just keywords).'}
|
||
</span>
|
||
</div>
|
||
{mode === 'filter' && (<>
|
||
<div className="controls">
|
||
<input
|
||
type="text"
|
||
className="search-input"
|
||
placeholder="Search subject, sender, snippet..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
/>
|
||
<select className="select-input" value={investorId} onChange={(e) => setInvestorId(e.target.value)}>
|
||
<option value="">All investors</option>
|
||
{investors.map((iv) => <option key={iv.id} value={iv.id}>{iv.name}</option>)}
|
||
</select>
|
||
<select className="select-input" value={accountId} onChange={(e) => setAccountId(e.target.value)}>
|
||
<option value="">All mailboxes</option>
|
||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.email_address}</option>)}
|
||
</select>
|
||
<select className="select-input" value={direction} onChange={(e) => setDirection(e.target.value)}>
|
||
<option value="">In & out</option>
|
||
<option value="inbound">Received</option>
|
||
<option value="outbound">Sent</option>
|
||
</select>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: '#8ea2b7' }}>
|
||
from <input type="date" value={since} max={until || undefined} onChange={(e) => setSince(e.target.value)} />
|
||
</label>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: '#8ea2b7' }}>
|
||
to <input type="date" value={until} min={since || undefined} onChange={(e) => setUntil(e.target.value)} />
|
||
</label>
|
||
{(since || until) && (
|
||
<button type="button" onClick={() => { setSince(''); setUntil(''); }}
|
||
style={{ background: 'transparent', color: '#8ea2b7', border: '1px solid #2a3a4d', boxShadow: 'none', padding: '6px 10px' }}>
|
||
Clear dates
|
||
</button>
|
||
)}
|
||
</div>
|
||
{loading ? (
|
||
<SkeletonBlock lines={8} />
|
||
) : error ? (
|
||
<div className="toast error" style={{ position: 'static' }}>{error}</div>
|
||
) : disabled ? (
|
||
<span className="index-job-pill idle"><span className="index-job-dot" /> Email integration is disabled — no Gmail service-account key on the server.</span>
|
||
) : emails.length === 0 ? (
|
||
<div className="empty-state">{hasFilter ? 'No email activity matches these filters.' : 'No captured email yet. Enroll mailboxes in Email Capture.'}</div>
|
||
) : (
|
||
<>
|
||
<div className="form-help" style={{ marginBottom: '10px' }}>
|
||
Showing {emails.length}{data?.truncated ? '+ (refine filters to see more)' : ''} email{emails.length === 1 ? '' : 's'}.
|
||
</div>
|
||
<div className="timeline">
|
||
{emails.map((em) => {
|
||
const sent = em.direction === 'outbound';
|
||
const who = em.from_name ? `${em.from_name} <${em.from_email}>` : (em.from_email || 'Unknown sender');
|
||
const tags = (em.investors || []).map((iv) => iv.name);
|
||
const open = expandedId === em.id;
|
||
return (
|
||
<div key={em.id} className="timeline-item">
|
||
<div className="timeline-marker"></div>
|
||
<div className="timeline-content">
|
||
<div className="timeline-header" style={{ cursor: 'pointer' }} onClick={() => toggleExpand(em.id)}>
|
||
<span style={{ color: '#6b7c90', marginRight: '4px' }}>{open ? '▾' : '▸'}</span>
|
||
<span style={{ color: sent ? '#7fd1a8' : '#7fb0e0' }}>{sent ? '↗ Sent' : '↘ Received'}</span>
|
||
{' · '}{who}{em.has_attachments ? ' 📎' : ''}
|
||
</div>
|
||
<div className="timeline-meta">
|
||
{formatDate(em.sent_at)}
|
||
{(em.mailboxes || []).length > 0 && <span> · {em.mailboxes.join(', ')}</span>}
|
||
</div>
|
||
{em.subject && <div className="timeline-body" style={{ fontWeight: 600, cursor: 'pointer' }} onClick={() => toggleExpand(em.id)}>{em.subject}</div>}
|
||
{!open && em.snippet && <div className="timeline-body" style={{ marginTop: '4px', color: '#9fb0c2' }}>{em.snippet}</div>}
|
||
{open && renderExpandedBody(em.id)}
|
||
{tags.length > 0 ? (
|
||
<div style={{ marginTop: '6px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||
{tags.map((t) => (
|
||
<span key={t} style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '10px', border: '1px solid #263548', background: '#0d1622', color: em.is_matched ? '#cfe0f2' : '#9fb0c2' }}>{t}</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div style={{ marginTop: '6px' }}>
|
||
<span style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '10px', border: '1px dashed #4a3a2a', color: '#c0a070' }}>Unmatched</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</>
|
||
)}
|
||
</>)}
|
||
{mode === 'search' && (
|
||
<>
|
||
<div className="controls">
|
||
<input
|
||
type="text"
|
||
className="search-input"
|
||
placeholder="Find emails by content, e.g. “the mining deal wire timeline”"
|
||
value={contentQuery}
|
||
onChange={(e) => setContentQuery(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') runContentSearch(); }}
|
||
/>
|
||
<button type="button" onClick={runContentSearch} disabled={searchLoading || !contentQuery.trim()}>
|
||
{searchLoading ? <Spinner /> : 'Search'}
|
||
</button>
|
||
</div>
|
||
{searchLoading ? (
|
||
<SkeletonBlock lines={6} />
|
||
) : searchError ? (
|
||
<div className="toast error" style={{ position: 'static' }}>{searchError}</div>
|
||
) : searchResults === null ? (
|
||
<div className="empty-state">Search the text of captured (investor-matched) emails. Only matched email bodies are indexed.</div>
|
||
) : (searchResults.results || []).length === 0 ? (
|
||
<div className="empty-state">No emails matched “{contentQuery.trim()}”.</div>
|
||
) : (
|
||
<>
|
||
<div className="form-help" style={{ marginBottom: '10px' }}>
|
||
{searchResults.results.length} result{searchResults.results.length === 1 ? '' : 's'}, most relevant first.
|
||
</div>
|
||
<div className="timeline">
|
||
{searchResults.results.map((r) => {
|
||
const sent = r.direction === 'outbound';
|
||
const who = r.from_name ? `${r.from_name} <${r.from_email}>` : (r.from_email || 'Unknown sender');
|
||
const open = expandedId === r.email_id;
|
||
return (
|
||
<div key={r.email_id} className="timeline-item">
|
||
<div className="timeline-marker"></div>
|
||
<div className="timeline-content">
|
||
<div className="timeline-header" style={{ cursor: 'pointer' }} onClick={() => toggleExpand(r.email_id)}>
|
||
<span style={{ color: '#6b7c90', marginRight: '4px' }}>{open ? '▾' : '▸'}</span>
|
||
<span style={{ color: sent ? '#7fd1a8' : '#7fb0e0' }}>{sent ? '↗ Sent' : '↘ Received'}</span>
|
||
{' · '}{who}{r.has_attachments ? ' 📎' : ''}
|
||
</div>
|
||
<div className="timeline-meta">
|
||
{formatDate(r.sent_at)}
|
||
{r.lp_name && <span> · {r.lp_name}</span>}
|
||
{typeof r.score === 'number' && <span> · score {r.score.toFixed(2)}</span>}
|
||
</div>
|
||
{r.subject && <div className="timeline-body" style={{ fontWeight: 600, cursor: 'pointer' }} onClick={() => toggleExpand(r.email_id)}>{r.subject}</div>}
|
||
{!open && r.excerpt && <div className="timeline-body" style={{ marginTop: '4px', color: '#9fb0c2', fontStyle: 'italic' }}>…{r.excerpt}…</div>}
|
||
{open && renderExpandedBody(r.email_id)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const FeatureRequestsPage = ({ token, onShowToast, user }) => {
|
||
const [requests, setRequests] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [search, setSearch] = useState('');
|
||
const [statusFilter, setStatusFilter] = useState('');
|
||
const [showForm, setShowForm] = useState(false);
|
||
const [formError, setFormError] = useState('');
|
||
const [formData, setFormData] = useState({
|
||
title: '',
|
||
description: '',
|
||
category: 'ui_ux',
|
||
priority: 'medium',
|
||
page: '',
|
||
requested_by: user?.full_name || user?.username || ''
|
||
});
|
||
|
||
const fetchRequests = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
const result = await api(`/api/feature-requests?status=${statusFilter}&search=${encodeURIComponent(search)}`, {}, token);
|
||
setRequests(result.data || []);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to load feature requests'), 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [statusFilter, search, token, onShowToast]);
|
||
|
||
useEffect(() => {
|
||
fetchRequests();
|
||
}, [fetchRequests]);
|
||
|
||
const handleSubmit = async (e) => {
|
||
e.preventDefault();
|
||
setFormError('');
|
||
try {
|
||
await api('/api/feature-requests', {
|
||
method: 'POST',
|
||
body: JSON.stringify(formData)
|
||
}, token);
|
||
setShowForm(false);
|
||
setFormData({
|
||
title: '',
|
||
description: '',
|
||
category: 'ui_ux',
|
||
priority: 'medium',
|
||
page: '',
|
||
requested_by: user?.full_name || user?.username || ''
|
||
});
|
||
await fetchRequests();
|
||
onShowToast('Feature request submitted', 'success');
|
||
} catch (err) {
|
||
setFormError(getErrorMessage(err, 'Failed to submit request'));
|
||
}
|
||
};
|
||
|
||
const handleStatusChange = async (id, status) => {
|
||
try {
|
||
await api(`/api/feature-requests/${id}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ status })
|
||
}, token);
|
||
setRequests((prev) => prev.map((r) => (r.id === id ? { ...r, status } : r)));
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to update status'), 'error');
|
||
}
|
||
};
|
||
|
||
const handlePriorityChange = async (id, priority) => {
|
||
try {
|
||
await api(`/api/feature-requests/${id}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ priority })
|
||
}, token);
|
||
setRequests((prev) => prev.map((r) => (r.id === id ? { ...r, priority } : r)));
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to update priority'), 'error');
|
||
}
|
||
};
|
||
|
||
const counts = useMemo(() => ({
|
||
total: requests.length,
|
||
newCount: requests.filter((r) => r.status === 'new').length,
|
||
planned: requests.filter((r) => r.status === 'planned').length,
|
||
done: requests.filter((r) => r.status === 'done').length
|
||
}), [requests]);
|
||
|
||
return (
|
||
<div className="page-container">
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||
<h2 className="section-title">Feature Requests</h2>
|
||
<button onClick={() => setShowForm(true)}>+ Submit Feedback</button>
|
||
</div>
|
||
|
||
<div className="kpi-grid">
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Total Requests</div>
|
||
<div className="kpi-value">{counts.total}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">New</div>
|
||
<div className="kpi-value">{counts.newCount}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Planned</div>
|
||
<div className="kpi-value">{counts.planned}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Done</div>
|
||
<div className="kpi-value">{counts.done}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="section">
|
||
<div className="controls">
|
||
<input
|
||
type="text"
|
||
className="search-input"
|
||
placeholder="Search requests..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
/>
|
||
<select
|
||
className="select-input"
|
||
value={statusFilter}
|
||
onChange={(e) => setStatusFilter(e.target.value)}
|
||
>
|
||
<option value="">All Statuses</option>
|
||
<option value="new">New</option>
|
||
<option value="planned">Planned</option>
|
||
<option value="in_progress">In Progress</option>
|
||
<option value="done">Done</option>
|
||
<option value="wont_do">Won't Do</option>
|
||
</select>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<SkeletonBlock lines={6} />
|
||
) : requests.length === 0 ? (
|
||
<div className="empty-state">No feature requests yet</div>
|
||
) : (
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th>Title</th>
|
||
<th>Requested By</th>
|
||
<th>Category</th>
|
||
<th>Priority</th>
|
||
<th>Status</th>
|
||
<th>Submitted</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{requests.map((r) => (
|
||
<tr key={r.id}>
|
||
<td>
|
||
<div style={{ fontWeight: 600 }}>{r.title}</div>
|
||
{r.description && <div style={{ fontSize: '12px', color: '#8ea2b7', marginTop: '4px' }}>{r.description}</div>}
|
||
</td>
|
||
<td>{r.requested_by || '-'}</td>
|
||
<td>{(r.category || 'other').replace('_', ' ')}</td>
|
||
<td>
|
||
<select
|
||
className="select-input"
|
||
style={{ minWidth: '120px' }}
|
||
value={r.priority || 'medium'}
|
||
onChange={(e) => handlePriorityChange(r.id, e.target.value)}
|
||
>
|
||
<option value="low">Low</option>
|
||
<option value="medium">Medium</option>
|
||
<option value="high">High</option>
|
||
</select>
|
||
</td>
|
||
<td>
|
||
<select
|
||
className="select-input"
|
||
style={{ minWidth: '140px' }}
|
||
value={r.status || 'new'}
|
||
onChange={(e) => handleStatusChange(r.id, e.target.value)}
|
||
>
|
||
<option value="new">New</option>
|
||
<option value="planned">Planned</option>
|
||
<option value="in_progress">In Progress</option>
|
||
<option value="done">Done</option>
|
||
<option value="wont_do">Won't Do</option>
|
||
</select>
|
||
</td>
|
||
<td>{formatDateLong(r.created_at)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
|
||
{showForm && (
|
||
<div className="modal-overlay">
|
||
<div className="modal">
|
||
<div className="modal-header">Submit Feature Request</div>
|
||
{formError && <div className="toast error" style={{ position: 'static', marginBottom: '16px' }}>{formError}</div>}
|
||
<form onSubmit={handleSubmit}>
|
||
<div className="form-group">
|
||
<label className="form-label">Title *</label>
|
||
<input
|
||
type="text"
|
||
className="text-input"
|
||
value={formData.title}
|
||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Description</label>
|
||
<textarea
|
||
className="text-input"
|
||
rows="4"
|
||
value={formData.description}
|
||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Category</label>
|
||
<select
|
||
className="select-input"
|
||
value={formData.category}
|
||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||
>
|
||
<option value="ui_ux">UI / UX</option>
|
||
<option value="workflow">Workflow</option>
|
||
<option value="reporting">Reporting</option>
|
||
<option value="integrations">Integrations</option>
|
||
<option value="bugs">Bug</option>
|
||
<option value="other">Other</option>
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Priority</label>
|
||
<select
|
||
className="select-input"
|
||
value={formData.priority}
|
||
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
|
||
>
|
||
<option value="low">Low</option>
|
||
<option value="medium">Medium</option>
|
||
<option value="high">High</option>
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Page / Area</label>
|
||
<input
|
||
type="text"
|
||
className="text-input"
|
||
placeholder="e.g., Pipeline, LP Tracker"
|
||
value={formData.page}
|
||
onChange={(e) => setFormData({ ...formData, page: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Requested By</label>
|
||
<input
|
||
type="text"
|
||
className="text-input"
|
||
value={formData.requested_by}
|
||
onChange={(e) => setFormData({ ...formData, requested_by: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="form-actions">
|
||
<button type="button" className="button-secondary" onClick={() => setShowForm(false)}>Cancel</button>
|
||
<button type="submit">Submit</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const FundraisingGridPage = ({ user, token, onShowToast, views, activeView, setActiveView, setViews, uiAction, onUiActionHandled }) => {
|
||
const STORAGE_KEY = FUNDRAISING_GRID_STORAGE_KEY;
|
||
|
||
const teamMembers = ['Grant', 'JK', 'GG', 'MB', 'Unassigned'];
|
||
const locationCatalog = [
|
||
{ label: 'New York City, NY, USA', city: 'New York City', state: 'NY', country: 'USA', aliases: ['new york', 'nyc'] },
|
||
{ label: 'Austin, TX, USA', city: 'Austin', state: 'TX', country: 'USA', aliases: ['austin'] },
|
||
{ label: 'San Francisco, CA, USA', city: 'San Francisco', state: 'CA', country: 'USA', aliases: ['san francisco', 'sf'] },
|
||
{ label: 'Miami, FL, USA', city: 'Miami', state: 'FL', country: 'USA', aliases: ['miami'] },
|
||
{ label: 'Los Angeles, CA, USA', city: 'Los Angeles', state: 'CA', country: 'USA', aliases: ['los angeles', 'la'] },
|
||
{ label: 'London, England, UK', city: 'London', state: 'England', country: 'UK', aliases: ['london'] }
|
||
];
|
||
|
||
const defaultColumns = [
|
||
{ id: 'investor_name', label: 'Investor Name', type: 'text', width: 220 },
|
||
{ id: 'contacts', label: 'Contacts', type: 'contacts', width: 260 },
|
||
{ id: 'log_action', label: 'Log', type: 'action', readOnly: true, width: 90 },
|
||
{ id: 'notes', label: 'Notes / Communication / Outreach', type: 'longtext', width: 420 },
|
||
{ id: 'lead_source', label: 'Lead Source', type: 'text', width: 180 },
|
||
{ id: 'notes_last_modified', label: 'Notes Last Modified', type: 'date', readOnly: true, width: 180 },
|
||
{ id: 'last_communication_date', label: 'Last Communication Date', type: 'date', readOnly: true, width: 195 },
|
||
{ id: 'priority', label: 'Priority', type: 'checkbox', width: 110 },
|
||
{ id: 'follow_up', label: 'Follow up', type: 'checkbox', width: 110 },
|
||
{ id: 'lead', label: 'Lead', type: 'select', options: teamMembers, width: 130 },
|
||
{ id: 'graveyard', label: 'Graveyard', type: 'checkbox', width: 115 },
|
||
{ id: 'pipeline', label: 'Pipeline', type: 'action', readOnly: true, width: 120 },
|
||
{ id: 'pipeline_stage', label: 'Pipeline Stage', type: 'text', readOnly: true, width: 150 },
|
||
{ id: 'fund_i', label: 'Fund I', type: 'currency', isFund: true, width: 130 },
|
||
{ id: 'fund_ii', label: 'Fund II', type: 'currency', isFund: true, width: 130 },
|
||
{ id: 'fund_iii', label: 'Fund III', type: 'currency', isFund: true, width: 130 },
|
||
{ id: 'tactical_fund', label: 'Tactical Fund', type: 'currency', isFund: true, width: 140 },
|
||
{ id: 'pawn_to_e4', label: 'Pawn to E4', type: 'currency', isFund: true, width: 130 },
|
||
{ id: 'ten31_terahash', label: 'Ten31 Terahash', type: 'currency', isFund: true, width: 150 },
|
||
{ id: 'sats_and_stats', label: 'Sats and Stats', type: 'currency', isFund: true, width: 140 },
|
||
{ id: 'pawn_to_f4', label: 'Pawn to f4', type: 'currency', isFund: true, width: 130 },
|
||
{ id: 'join_the_fold', label: 'Join the Fold', type: 'currency', isFund: true, width: 130 },
|
||
{ id: 'total_invested', label: 'Total invested', type: 'rollup', readOnly: true, width: 150 },
|
||
{ id: 'tactical_fund_commit_date', label: 'Tactical Fund Commit Date', type: 'date', width: 180 }
|
||
];
|
||
|
||
const defaultRows = [];
|
||
|
||
|
||
const loadGrid = () => {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
if (!raw) return { columns: defaultColumns, rows: defaultRows };
|
||
const parsed = JSON.parse(raw);
|
||
if (!Array.isArray(parsed.columns) || !Array.isArray(parsed.rows)) return { columns: defaultColumns, rows: defaultRows };
|
||
return parsed;
|
||
} catch (_) {
|
||
return { columns: defaultColumns, rows: defaultRows };
|
||
}
|
||
};
|
||
|
||
const initialGrid = loadGrid();
|
||
const [columns, setColumns] = useState(initialGrid.columns);
|
||
const [rows, setRows] = useState(initialGrid.rows);
|
||
const [quickSearch, setQuickSearch] = useState('');
|
||
const [filters, setFilters] = useState({ includeGraveyard: false, graveyardOnly: false, followUpOnly: false, lead: '' });
|
||
const [editing, setEditing] = useState(null);
|
||
const [showColumnModal, setShowColumnModal] = useState(false);
|
||
const [showViewModal, setShowViewModal] = useState(false);
|
||
const [showImportModal, setShowImportModal] = useState(false);
|
||
const [showColumnVisibilityModal, setShowColumnVisibilityModal] = useState(false);
|
||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||
const [newColumn, setNewColumn] = useState({ label: '', type: 'text', options: '', isFund: false, formula: '' });
|
||
const [columnModalMode, setColumnModalMode] = useState('add');
|
||
const [editingColumnId, setEditingColumnId] = useState(null);
|
||
const [newViewName, setNewViewName] = useState('');
|
||
const [importCsvText, setImportCsvText] = useState('');
|
||
const [importError, setImportError] = useState('');
|
||
const [createMissingColumns, setCreateMissingColumns] = useState(true);
|
||
const [hiddenColumns, setHiddenColumns] = useState([]);
|
||
const [columnFilters, setColumnFilters] = useState([]);
|
||
const [footerAggs, setFooterAggs] = useState({});
|
||
const [rowDensity, setRowDensity] = useState('expanded');
|
||
const [sortState, setSortState] = useState({ colId: '', dir: 'asc' });
|
||
const [newRule, setNewRule] = useState({ colId: '', op: 'contains', value: '' });
|
||
const [resizing, setResizing] = useState(null);
|
||
const suppressNextHeaderSortRef = useRef(false);
|
||
const gridTableRef = useRef(null);
|
||
const [selectedCell, setSelectedCell] = useState(null);
|
||
const [contextMenu, setContextMenu] = useState(null);
|
||
const [newColumnInsertIndex, setNewColumnInsertIndex] = useState(null);
|
||
const [draggingColId, setDraggingColId] = useState(null);
|
||
const [dragOverColId, setDragOverColId] = useState(null);
|
||
const [showLogCommModal, setShowLogCommModal] = useState(false);
|
||
const [logCommContext, setLogCommContext] = useState(null);
|
||
const [logCommForm, setLogCommForm] = useState({ type: 'note', subject: '', body: '', outcome: '', next_action: '', next_action_date: '', append_note: true });
|
||
const [logCommSubmitting, setLogCommSubmitting] = useState(false);
|
||
const [showContactCardModal, setShowContactCardModal] = useState(false);
|
||
const [contactCardContext, setContactCardContext] = useState(null);
|
||
const [contactCardLoading, setContactCardLoading] = useState(false);
|
||
const contactLookupCacheRef = useRef(new Map());
|
||
const [showCreateOppModal, setShowCreateOppModal] = useState(false);
|
||
const [createOppContext, setCreateOppContext] = useState(null);
|
||
const [createOppForm, setCreateOppForm] = useState({ name: '', contactIndex: 0, stage: 'lead', expected_amount: '', probability: 35, fund_name: '' });
|
||
const [createOppSubmitting, setCreateOppSubmitting] = useState(false);
|
||
const [remoteVersion, setRemoteVersion] = useState(null);
|
||
const [stateHydrated, setStateHydrated] = useState(false);
|
||
const saveTimerRef = useRef(null);
|
||
const lastSyncedSnapshotRef = useRef('');
|
||
const lastAppliedViewIdRef = useRef(null);
|
||
const [importDebugOpen, setImportDebugOpen] = useState(false);
|
||
const [importDebugEvents, setImportDebugEvents] = useState([]);
|
||
const [importDebugServerSnapshot, setImportDebugServerSnapshot] = useState(null);
|
||
const rowCountRef = useRef(null);
|
||
const [collabPresence, setCollabPresence] = useState([]);
|
||
const [collabLocks, setCollabLocks] = useState([]);
|
||
const collabPollRef = useRef(null);
|
||
const collabSyncTimerRef = useRef(null);
|
||
|
||
const activeViewConfig = useMemo(() => views.find((v) => v.id === activeView) || null, [views, activeView]);
|
||
|
||
const columnsForActiveView = useMemo(() => {
|
||
const order = Array.isArray(activeViewConfig?.columnOrder) ? activeViewConfig.columnOrder : [];
|
||
const widths = (activeViewConfig?.columnWidths && typeof activeViewConfig.columnWidths === 'object') ? activeViewConfig.columnWidths : {};
|
||
const byId = new Map(columns.map((c) => [c.id, c]));
|
||
const ordered = [];
|
||
const used = new Set();
|
||
order.forEach((id) => {
|
||
const col = byId.get(id);
|
||
if (!col || used.has(id)) return;
|
||
used.add(id);
|
||
ordered.push(col);
|
||
});
|
||
columns.forEach((c) => {
|
||
if (used.has(c.id)) return;
|
||
ordered.push(c);
|
||
});
|
||
return ordered.map((c) => {
|
||
const w = Number(widths[c.id]);
|
||
if (Number.isFinite(w) && w > 0) return { ...c, width: Math.max(90, Math.floor(w)) };
|
||
return c;
|
||
});
|
||
}, [columns, activeViewConfig]);
|
||
|
||
const ensureNotesLastModifiedSchema = (incomingColumns, incomingRows) => {
|
||
const cols = (Array.isArray(incomingColumns) ? [...incomingColumns] : [...defaultColumns]).filter((c) => c && c.id !== 'longshot_followup');
|
||
let changed = false;
|
||
if (Array.isArray(incomingColumns) && incomingColumns.length !== cols.length) {
|
||
changed = true;
|
||
}
|
||
const hasCol = cols.some((c) => c.id === 'notes_last_modified');
|
||
if (!hasCol) {
|
||
const insertAt = Math.max(0, cols.findIndex((c) => c.id === 'priority'));
|
||
const col = { id: 'notes_last_modified', label: 'Notes Last Modified', type: 'date', readOnly: true, width: 180 };
|
||
if (insertAt > 0) cols.splice(insertAt, 0, col);
|
||
else cols.push(col);
|
||
changed = true;
|
||
}
|
||
const hasLogAction = cols.some((c) => c.id === 'log_action');
|
||
if (!hasLogAction) {
|
||
const contactsIdx = cols.findIndex((c) => c.id === 'contacts');
|
||
const col = { id: 'log_action', label: 'Log', type: 'action', readOnly: true, width: 90 };
|
||
if (contactsIdx >= 0) cols.splice(contactsIdx + 1, 0, col);
|
||
else cols.unshift(col);
|
||
changed = true;
|
||
}
|
||
const hasLeadSource = cols.some((c) => c.id === 'lead_source');
|
||
if (!hasLeadSource) {
|
||
const notesIdx = cols.findIndex((c) => c.id === 'notes');
|
||
const col = { id: 'lead_source', label: 'Lead Source', type: 'text', width: 180 };
|
||
if (notesIdx >= 0) cols.splice(notesIdx + 1, 0, col);
|
||
else cols.push(col);
|
||
changed = true;
|
||
}
|
||
const hasCommCol = cols.some((c) => c.id === 'last_communication_date');
|
||
if (!hasCommCol) {
|
||
const insertAt = Math.max(0, cols.findIndex((c) => c.id === 'priority'));
|
||
const col = { id: 'last_communication_date', label: 'Last Communication Date', type: 'date', readOnly: true, width: 195 };
|
||
if (insertAt > 0) cols.splice(insertAt, 0, col);
|
||
else cols.push(col);
|
||
changed = true;
|
||
}
|
||
// Pipeline-adoption columns: a per-row "Add to / In Pipeline" control and a
|
||
// read-only mirror of the linked opportunity's stage. Both are injected here so
|
||
// existing saved grids pick them up; their VALUES are server-computed on read.
|
||
const hasPipeline = cols.some((c) => c.id === 'pipeline');
|
||
if (!hasPipeline) {
|
||
const gy = cols.findIndex((c) => c.id === 'graveyard');
|
||
const col = { id: 'pipeline', label: 'Pipeline', type: 'action', readOnly: true, width: 120 };
|
||
if (gy >= 0) cols.splice(gy + 1, 0, col);
|
||
else cols.push(col);
|
||
changed = true;
|
||
}
|
||
const hasPipelineStage = cols.some((c) => c.id === 'pipeline_stage');
|
||
if (!hasPipelineStage) {
|
||
const pi = cols.findIndex((c) => c.id === 'pipeline');
|
||
const col = { id: 'pipeline_stage', label: 'Pipeline Stage', type: 'text', readOnly: true, width: 150 };
|
||
if (pi >= 0) cols.splice(pi + 1, 0, col);
|
||
else cols.push(col);
|
||
changed = true;
|
||
}
|
||
const rowsIn = Array.isArray(incomingRows) ? incomingRows : defaultRows;
|
||
const rowsOut = rowsIn.map((r) => {
|
||
const next = { ...r };
|
||
if (Object.prototype.hasOwnProperty.call(next, 'longshot_followup')) {
|
||
delete next.longshot_followup;
|
||
changed = true;
|
||
}
|
||
if (typeof next.notes_last_modified !== 'string') {
|
||
next.notes_last_modified = '';
|
||
changed = true;
|
||
}
|
||
if (typeof next.last_communication_date !== 'string') {
|
||
next.last_communication_date = '';
|
||
changed = true;
|
||
}
|
||
return next;
|
||
});
|
||
return { columns: cols, rows: rowsOut, changed };
|
||
};
|
||
|
||
// `pipeline` / `pipeline_stage` are read-only, server-computed (from the linked
|
||
// opportunity) and injected on GET. They must never participate in dirty-detection
|
||
// or be persisted — otherwise a link/unlink flips a value and triggers a no-op
|
||
// autosave + version bump. Strip them at every snapshot / persist boundary.
|
||
const stripComputedRows = (rs) => (Array.isArray(rs) ? rs.map((r) => {
|
||
if (!r || typeof r !== 'object') return r;
|
||
const { pipeline, pipeline_stage, ...rest } = r;
|
||
return rest;
|
||
}) : rs);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
const hydrateFundraisingState = async () => {
|
||
try {
|
||
const result = await api('/api/fundraising/state', {}, token);
|
||
const data = result?.data || {};
|
||
const grid = data.grid || {};
|
||
const incomingColumnsRaw = Array.isArray(grid.columns) ? grid.columns : defaultColumns;
|
||
const incomingRowsRaw = Array.isArray(grid.rows) ? grid.rows : defaultRows;
|
||
const normalized = ensureNotesLastModifiedSchema(incomingColumnsRaw, incomingRowsRaw);
|
||
const incomingColumns = normalized.columns;
|
||
const incomingRows = normalized.rows;
|
||
const incomingViews = sanitizeGridViews(Array.isArray(data.views) && data.views.length > 0 ? data.views : DEFAULT_GRID_VIEWS);
|
||
const incomingVersion = Number(data.version);
|
||
pushImportDebugEvent('hydrate-success', {
|
||
rows: incomingRows.length,
|
||
cols: incomingColumns.length,
|
||
version: Number.isFinite(incomingVersion) ? incomingVersion : null,
|
||
views: incomingViews.length
|
||
});
|
||
|
||
if (cancelled) return;
|
||
|
||
setColumns(incomingColumns);
|
||
setRows(incomingRows);
|
||
setViews(incomingViews);
|
||
if (!incomingViews.find((v) => v.id === activeView)) {
|
||
setActiveView(incomingViews[0]?.id || 'view-main');
|
||
}
|
||
setRemoteVersion(Number.isFinite(incomingVersion) && incomingVersion > 0 ? incomingVersion : 1);
|
||
lastSyncedSnapshotRef.current = JSON.stringify({ columns: incomingColumns, rows: stripComputedRows(incomingRows), views: incomingViews });
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns: incomingColumns, rows: stripComputedRows(incomingRows) }));
|
||
localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(incomingViews));
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Using local fundraising data'), 'error');
|
||
pushImportDebugEvent('hydrate-error', { message: getErrorMessage(err, 'hydrate failed') });
|
||
setRemoteVersion(loadMockFundraisingVersion());
|
||
} finally {
|
||
if (!cancelled) setStateHydrated(true);
|
||
}
|
||
};
|
||
hydrateFundraisingState();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [token, onShowToast, pushImportDebugEvent]);
|
||
|
||
useEffect(() => {
|
||
if (!stateHydrated || !Number.isFinite(remoteVersion)) return;
|
||
const persistRows = stripComputedRows(rows);
|
||
const snapshot = JSON.stringify({ columns, rows: persistRows, views });
|
||
if (snapshot === lastSyncedSnapshotRef.current) return;
|
||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||
|
||
saveTimerRef.current = setTimeout(async () => {
|
||
try {
|
||
const response = await api('/api/fundraising/state', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
grid: { columns, rows: persistRows },
|
||
views,
|
||
expected_version: remoteVersion
|
||
})
|
||
}, token);
|
||
|
||
const nextVersion = Number(response?.data?.version);
|
||
if (Number.isFinite(nextVersion) && nextVersion > 0) {
|
||
setRemoteVersion(nextVersion);
|
||
} else {
|
||
setRemoteVersion((v) => (Number.isFinite(v) ? v + 1 : 1));
|
||
}
|
||
pushImportDebugEvent('autosave-success', {
|
||
rows: rows.length,
|
||
cols: columns.length,
|
||
views: views.length,
|
||
next_version: Number.isFinite(nextVersion) ? nextVersion : null
|
||
});
|
||
lastSyncedSnapshotRef.current = snapshot;
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns, rows: persistRows }));
|
||
localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(views));
|
||
} catch (err) {
|
||
if (err?.status === 409) {
|
||
const payload = err?.payload || {};
|
||
const latestVersion = Number(payload?.current_version);
|
||
const who = payload?.current_updated_by ? ` by ${payload.current_updated_by}` : '';
|
||
const when = payload?.current_updated_at ? ` at ${formatDateLong(payload.current_updated_at)}` : '';
|
||
onShowToast(`Grid changed elsewhere${who}${when}. Retrying your save on latest version.`, 'error');
|
||
pushImportDebugEvent('autosave-conflict', {
|
||
current_version: Number.isFinite(latestVersion) ? latestVersion : null,
|
||
updated_by: payload?.current_updated_by || '',
|
||
updated_at: payload?.current_updated_at || ''
|
||
});
|
||
try {
|
||
if (Number.isFinite(latestVersion) && latestVersion > 0) {
|
||
setRemoteVersion(latestVersion);
|
||
} else {
|
||
const latest = await api('/api/fundraising/state', {}, token);
|
||
const latestData = latest?.data || {};
|
||
const fromServer = Number(latestData.version);
|
||
setRemoteVersion(Number.isFinite(fromServer) && fromServer > 0 ? fromServer : 1);
|
||
}
|
||
} catch (reloadErr) {
|
||
onShowToast(getErrorMessage(reloadErr, 'Failed to refresh version for retry'), 'error');
|
||
pushImportDebugEvent('autosave-conflict-refresh-error', { message: getErrorMessage(reloadErr, 'refresh failed') });
|
||
}
|
||
return;
|
||
}
|
||
onShowToast(getErrorMessage(err, 'Failed to save fundraising state'), 'error');
|
||
pushImportDebugEvent('autosave-error', { message: getErrorMessage(err, 'save failed') });
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns, rows: persistRows }));
|
||
localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(views));
|
||
}
|
||
}, 550);
|
||
|
||
return () => {
|
||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||
};
|
||
}, [columns, rows, views, remoteVersion, stateHydrated, token, setViews, onShowToast, pushImportDebugEvent]);
|
||
|
||
useEffect(() => {
|
||
if (!stateHydrated) return undefined;
|
||
sendCollabHeartbeat({ quiet: true });
|
||
if (collabPollRef.current) clearInterval(collabPollRef.current);
|
||
collabPollRef.current = setInterval(() => {
|
||
sendCollabHeartbeat({ quiet: true });
|
||
}, 7000);
|
||
return () => {
|
||
if (collabPollRef.current) clearInterval(collabPollRef.current);
|
||
collabPollRef.current = null;
|
||
sendCollabHeartbeat({ editingOverride: null, quiet: true });
|
||
};
|
||
}, [stateHydrated, sendCollabHeartbeat]);
|
||
|
||
useEffect(() => {
|
||
if (!stateHydrated) return undefined;
|
||
if (collabSyncTimerRef.current) clearTimeout(collabSyncTimerRef.current);
|
||
collabSyncTimerRef.current = setTimeout(() => {
|
||
sendCollabHeartbeat({ quiet: true });
|
||
}, 120);
|
||
return () => {
|
||
if (collabSyncTimerRef.current) clearTimeout(collabSyncTimerRef.current);
|
||
};
|
||
}, [selectedCell, editing, activeView, stateHydrated, sendCollabHeartbeat]);
|
||
|
||
useEffect(() => {
|
||
const selected = views.find((v) => v.id === activeView);
|
||
if (selected) {
|
||
if (lastAppliedViewIdRef.current === activeView) return;
|
||
const nextFilters = selected.filters || { includeGraveyard: false, graveyardOnly: false, followUpOnly: false, lead: '' };
|
||
setFilters({
|
||
includeGraveyard: !!nextFilters.includeGraveyard,
|
||
graveyardOnly: selected.id === 'view-graveyard' ? true : !!nextFilters.graveyardOnly,
|
||
followUpOnly: selected.id === 'view-followup' ? true : !!nextFilters.followUpOnly,
|
||
lead: nextFilters.lead || ''
|
||
});
|
||
setQuickSearch(selected.quickSearch || '');
|
||
setHiddenColumns(Array.isArray(selected.hiddenColumns) ? selected.hiddenColumns.filter((id) => id !== 'investor_name') : []);
|
||
setColumnFilters(Array.isArray(selected.columnFilters) ? selected.columnFilters : []);
|
||
setFooterAggs((selected.footerAggs && typeof selected.footerAggs === 'object') ? selected.footerAggs : {});
|
||
setRowDensity(selected.rowDensity === 'compact' ? 'compact' : 'expanded');
|
||
lastAppliedViewIdRef.current = activeView;
|
||
}
|
||
}, [activeView, views]);
|
||
|
||
useEffect(() => {
|
||
if (uiAction === 'open-import') {
|
||
setShowImportModal(true);
|
||
if (onUiActionHandled) onUiActionHandled();
|
||
return;
|
||
}
|
||
if (uiAction === 'open-save-view') {
|
||
setShowViewModal(true);
|
||
if (onUiActionHandled) onUiActionHandled();
|
||
return;
|
||
}
|
||
if (uiAction === 'save-active-view') {
|
||
setViews((prev) => prev.map((v) => (v.id === activeView ? {
|
||
...v,
|
||
filters: { ...filters },
|
||
quickSearch,
|
||
hiddenColumns: hiddenColumns.filter((id) => id !== 'investor_name'),
|
||
columnFilters: [...columnFilters],
|
||
footerAggs: { ...footerAggs },
|
||
rowDensity,
|
||
columnOrder: columnsForActiveView.map((c) => c.id),
|
||
columnWidths: Object.fromEntries(columnsForActiveView.map((c) => [c.id, Math.max(90, Number(c.width) || 140)]))
|
||
} : v)));
|
||
onShowToast('View updated', 'success');
|
||
if (onUiActionHandled) onUiActionHandled();
|
||
}
|
||
}, [uiAction, onUiActionHandled, setViews, activeView, filters, quickSearch, hiddenColumns, columnFilters, footerAggs, rowDensity, columnsForActiveView, onShowToast]);
|
||
|
||
const fundColumnIds = useMemo(() => columns.filter((c) => c.isFund).map((c) => c.id), [columns]);
|
||
|
||
const defaultTotalInvestedFormula = useMemo(() => {
|
||
const ids = columns
|
||
.filter((c) => (c.isFund || c.type === 'currency') && c.id !== 'total_invested')
|
||
.map((c) => c.id);
|
||
return ids.length > 0 ? ids.map((id) => `{${id}}`).join(' + ') : '0';
|
||
}, [columns]);
|
||
|
||
const computeRollup = useCallback((row) => {
|
||
return fundColumnIds.reduce((sum, colId) => sum + parseNumericInput(row[colId]), 0);
|
||
}, [fundColumnIds]);
|
||
|
||
const applyLocationAutoFill = (query) => {
|
||
const q = String(query || '').toLowerCase().trim();
|
||
if (!q) return null;
|
||
return locationCatalog.find((l) => l.label.toLowerCase().includes(q) || l.aliases.some((a) => a.includes(q)));
|
||
};
|
||
|
||
const getColumnById = useCallback((colId) => columns.find((c) => c.id === colId), [columns]);
|
||
|
||
const evaluateFormulaCell = useCallback((row, col, depth = 0) => {
|
||
const expression = String(col?.formula || '').trim();
|
||
if (!expression) return '';
|
||
if (depth > 6) return '#ERR';
|
||
|
||
const __FIELD = (fieldRef) => {
|
||
const key = String(fieldRef || '').trim();
|
||
if (!key) return '';
|
||
if (normalizeKey(key) === normalizeKey('total invested')) return computeRollup(row);
|
||
const target = columns.find((c) => c.id === key)
|
||
|| columns.find((c) => normalizeKey(c.id) === normalizeKey(key))
|
||
|| columns.find((c) => normalizeKey(c.label) === normalizeKey(key));
|
||
if (!target) return '';
|
||
if (target.id === 'total_invested') return computeRollup(row);
|
||
if (target.type === 'formula') return evaluateFormulaCell(row, target, depth + 1);
|
||
if (target.type === 'currency' || target.type === 'number') return parseNumericInput(row[target.id]);
|
||
return row[target.id];
|
||
};
|
||
const result = evaluateFormulaSafe(expression, __FIELD);
|
||
return result ?? '';
|
||
}, [columns, computeRollup]);
|
||
|
||
const getFilterableValue = useCallback((row, col) => {
|
||
if (!col) return '';
|
||
const value = row[col.id];
|
||
if (col.id === 'total_invested') {
|
||
if (col.formula) return parseNumericInput(evaluateFormulaCell(row, col));
|
||
return computeRollup(row);
|
||
}
|
||
if (col.type === 'formula') return evaluateFormulaCell(row, col);
|
||
if (col.type === 'contacts') {
|
||
return (Array.isArray(value) ? value : []).map((c) => `${c.name || ''} ${c.email || ''} ${c.title || ''} ${c.city || ''} ${c.state || ''} ${c.country || ''}`).join(' ');
|
||
}
|
||
return value;
|
||
}, [computeRollup, evaluateFormulaCell]);
|
||
|
||
const evaluateFilterRule = useCallback((row, rule) => {
|
||
const col = getColumnById(rule.colId);
|
||
if (!col) return true;
|
||
const cellValue = getFilterableValue(row, col);
|
||
const op = rule.op || 'contains';
|
||
const ruleValue = rule.value ?? '';
|
||
|
||
if (op === 'contains') return String(cellValue || '').toLowerCase().includes(String(ruleValue).toLowerCase());
|
||
if (op === 'equals') return String(cellValue || '').toLowerCase() === String(ruleValue).toLowerCase();
|
||
if (op === 'not_equals') return String(cellValue || '').toLowerCase() !== String(ruleValue).toLowerCase();
|
||
if (op === 'is_true') return !!cellValue;
|
||
if (op === 'is_false') return !cellValue;
|
||
if (op === 'gt') return parseNumericInput(cellValue) > parseNumericInput(ruleValue);
|
||
if (op === 'lt') return parseNumericInput(cellValue) < parseNumericInput(ruleValue);
|
||
if (op === 'on_or_after') return String(cellValue || '') >= String(ruleValue || '');
|
||
if (op === 'on_or_before') return String(cellValue || '') <= String(ruleValue || '');
|
||
return true;
|
||
}, [getColumnById, getFilterableValue]);
|
||
|
||
const displayedRows = useMemo(() => {
|
||
const withRollup = rows.map((r) => ({ ...r, total_invested: computeRollup(r) }));
|
||
const baseFiltered = withRollup.filter((r) => {
|
||
if (filters.graveyardOnly && !r.graveyard) return false;
|
||
if (!filters.graveyardOnly && !filters.includeGraveyard && r.graveyard) return false;
|
||
if (filters.followUpOnly && !r.follow_up) return false;
|
||
if (filters.lead && r.lead !== filters.lead) return false;
|
||
if (!Array.isArray(columnFilters) || columnFilters.length === 0) return true;
|
||
return columnFilters.every((rule) => evaluateFilterRule(r, rule));
|
||
});
|
||
|
||
const applySort = (items) => {
|
||
if (!sortState.colId) return items;
|
||
const col = getColumnById(sortState.colId);
|
||
if (!col) return items;
|
||
const dir = sortState.dir === 'desc' ? -1 : 1;
|
||
const normalized = items.map((row, idx) => ({ row, idx }));
|
||
normalized.sort((a, b) => {
|
||
const av = getFilterableValue(a.row, col);
|
||
const bv = getFilterableValue(b.row, col);
|
||
let cmp = 0;
|
||
if (col.type === 'currency' || col.type === 'number' || col.id === 'total_invested' || col.type === 'formula') {
|
||
cmp = parseNumericInput(av) - parseNumericInput(bv);
|
||
} else if (col.type === 'checkbox') {
|
||
cmp = (av === bv) ? 0 : (av ? 1 : -1);
|
||
} else {
|
||
cmp = String(av || '').localeCompare(String(bv || ''), undefined, { numeric: true, sensitivity: 'base' });
|
||
}
|
||
if (cmp === 0) return a.idx - b.idx;
|
||
return cmp * dir;
|
||
});
|
||
return normalized.map((x) => x.row);
|
||
};
|
||
|
||
const search = quickSearch.trim();
|
||
if (!search) return applySort(baseFiltered);
|
||
|
||
const rowText = (r) => {
|
||
const contactText = (r.contacts || []).map((c) => `${c.name || ''} ${c.email || ''} ${c.city || ''} ${c.state || ''} ${c.country || ''}`).join(' ');
|
||
return `${r.investor_name || ''} ${r.notes || ''} ${contactText}`;
|
||
};
|
||
|
||
const lowerSearch = search.toLowerCase();
|
||
const exact = baseFiltered.filter((r) => rowText(r).toLowerCase().includes(lowerSearch));
|
||
|
||
// Keep exact contains as primary behavior; add fuzzy only when exact is sparse.
|
||
|
||
if (exact.length >= 3) return applySort(exact);
|
||
|
||
const fuzzyRanked = baseFiltered
|
||
.map((r, index) => ({ row: r, index, score: fuzzyScore(search, rowText(r)) }))
|
||
.filter((x) => x.score >= 0.45)
|
||
.sort((a, b) => (b.score - a.score) || (a.index - b.index))
|
||
.map((x) => x.row);
|
||
|
||
if (exact.length === 0) return applySort(fuzzyRanked);
|
||
|
||
const seen = new Set(exact.map((r) => r.id));
|
||
const extras = fuzzyRanked.filter((r) => !seen.has(r.id));
|
||
return applySort([...exact, ...extras]);
|
||
}, [rows, filters, quickSearch, computeRollup, columnFilters, evaluateFilterRule, sortState, getColumnById, getFilterableValue]);
|
||
|
||
const visibleColumns = useMemo(() => {
|
||
return columnsForActiveView.filter((c) => !hiddenColumns.includes(c.id));
|
||
}, [columnsForActiveView, hiddenColumns]);
|
||
|
||
const isNumericColumn = useCallback((col) => {
|
||
if (!col) return false;
|
||
if (col.id === 'total_invested') return true;
|
||
return col.type === 'currency' || col.type === 'number' || col.type === 'rollup' || col.type === 'formula';
|
||
}, []);
|
||
|
||
const numericValuesByColumn = useMemo(() => {
|
||
const parseStrictNumber = (raw) => {
|
||
if (raw === null || raw === undefined) return null;
|
||
const cleaned = String(raw).replace(/[^0-9.-]/g, '').trim();
|
||
if (!cleaned || cleaned === '-' || cleaned === '.' || cleaned === '-.') return null;
|
||
const parsed = Number(cleaned);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
};
|
||
const result = {};
|
||
visibleColumns.forEach((col) => {
|
||
if (!isNumericColumn(col)) return;
|
||
const values = [];
|
||
displayedRows.forEach((row) => {
|
||
let raw = null;
|
||
if (col.id === 'total_invested') {
|
||
raw = col.formula ? evaluateFormulaCell(row, col) : computeRollup(row);
|
||
} else if (col.type === 'formula') {
|
||
raw = evaluateFormulaCell(row, col);
|
||
} else {
|
||
raw = row[col.id];
|
||
}
|
||
const num = parseStrictNumber(raw);
|
||
if (num !== null) values.push(num);
|
||
});
|
||
result[col.id] = values;
|
||
});
|
||
return result;
|
||
}, [visibleColumns, displayedRows, isNumericColumn, evaluateFormulaCell, computeRollup]);
|
||
|
||
const formatAggregateValue = useCallback((col, value) => {
|
||
if (value === null || value === undefined || (typeof value === 'number' && !Number.isFinite(value))) return '-';
|
||
if (col?.type === 'currency' || col?.id === 'total_invested') return formatCurrencyLong(value);
|
||
if (typeof value === 'number') return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
||
return String(value);
|
||
}, []);
|
||
|
||
const getAggregateForColumn = useCallback((col) => {
|
||
if (!isNumericColumn(col)) return { op: 'none', label: '', value: '' };
|
||
const op = footerAggs[col.id] || 'sum';
|
||
const values = numericValuesByColumn[col.id] || [];
|
||
const n = values.length;
|
||
const sorted = [...values].sort((a, b) => a - b);
|
||
const sum = values.reduce((acc, v) => acc + v, 0);
|
||
const mean = n > 0 ? sum / n : null;
|
||
let agg = null;
|
||
if (op === 'none') agg = '';
|
||
else if (op === 'sum') agg = sum;
|
||
else if (op === 'avg') agg = mean;
|
||
else if (op === 'median') agg = n === 0 ? null : (n % 2 ? sorted[(n - 1) / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2);
|
||
else if (op === 'min') agg = n === 0 ? null : sorted[0];
|
||
else if (op === 'max') agg = n === 0 ? null : sorted[n - 1];
|
||
else if (op === 'range') agg = n === 0 ? null : sorted[n - 1] - sorted[0];
|
||
else if (op === 'stddev') {
|
||
if (n === 0 || mean === null) agg = null;
|
||
else {
|
||
const variance = values.reduce((acc, v) => acc + ((v - mean) ** 2), 0) / n;
|
||
agg = Math.sqrt(variance);
|
||
}
|
||
} else if (op === 'filled') agg = n;
|
||
else if (op === 'empty') agg = Math.max(0, displayedRows.length - n);
|
||
const opLabel = FOOTER_AGGREGATE_OPTIONS.find((o) => o.value === op)?.label || 'Sum';
|
||
return { op, label: opLabel, value: agg };
|
||
}, [footerAggs, numericValuesByColumn, displayedRows.length, isNumericColumn]);
|
||
|
||
const pushImportDebugEvent = useCallback((kind, payload = {}) => {
|
||
const stamp = new Date().toISOString();
|
||
setImportDebugEvents((prev) => [{ ts: stamp, kind, payload }, ...prev].slice(0, 80));
|
||
}, []);
|
||
|
||
const loadImportDebugSnapshot = useCallback(async () => {
|
||
try {
|
||
const [stateRes, summaryRes] = await Promise.all([
|
||
api('/api/fundraising/state', {}, token),
|
||
api('/api/fundraising/relational-summary', {}, token)
|
||
]);
|
||
const serverRows = Array.isArray(stateRes?.data?.grid?.rows) ? stateRes.data.grid.rows : [];
|
||
const relationalInvestors = Number(summaryRes?.data?.investors?.count || 0);
|
||
const version = Number(stateRes?.data?.version || 0);
|
||
const snapshot = {
|
||
fetched_at: new Date().toISOString(),
|
||
server_rows_count: serverRows.length,
|
||
relational_investors_count: relationalInvestors,
|
||
server_version: Number.isFinite(version) ? version : null
|
||
};
|
||
setImportDebugServerSnapshot(snapshot);
|
||
pushImportDebugEvent('server-snapshot', snapshot);
|
||
return snapshot;
|
||
} catch (err) {
|
||
pushImportDebugEvent('server-snapshot-error', { message: getErrorMessage(err, 'snapshot failed') });
|
||
return null;
|
||
}
|
||
}, [token, pushImportDebugEvent]);
|
||
|
||
const getCellKey = useCallback((rowId, colId) => `${rowId || ''}:${colId || ''}`, []);
|
||
const getCellLockByOther = useCallback((rowId, colId) => {
|
||
const key = getCellKey(rowId, colId);
|
||
return collabLocks.find((lock) => lock.cell_key === key && lock.locked_by_user_id !== user?.id) || null;
|
||
}, [collabLocks, getCellKey, user?.id]);
|
||
|
||
useEffect(() => {
|
||
const previous = rowCountRef.current;
|
||
if (previous === null) {
|
||
rowCountRef.current = rows.length;
|
||
pushImportDebugEvent('rows-init', { rows: rows.length, displayed: displayedRows.length });
|
||
return;
|
||
}
|
||
if (previous !== rows.length) {
|
||
pushImportDebugEvent('rows-changed', {
|
||
from: previous,
|
||
to: rows.length,
|
||
displayed: displayedRows.length,
|
||
active_view: activeView,
|
||
quick_search: quickSearch,
|
||
include_graveyard: !!filters.includeGraveyard,
|
||
graveyard_only: !!filters.graveyardOnly,
|
||
follow_up_only: !!filters.followUpOnly
|
||
});
|
||
rowCountRef.current = rows.length;
|
||
}
|
||
}, [rows.length, displayedRows.length, activeView, quickSearch, filters.includeGraveyard, filters.graveyardOnly, filters.followUpOnly, pushImportDebugEvent]);
|
||
|
||
const sendCollabHeartbeat = useCallback(async ({ selectedOverride = null, editingOverride = undefined, quiet = true } = {}) => {
|
||
try {
|
||
const selected = selectedOverride || (selectedCell ? { row_id: selectedCell.rowId, col_id: selectedCell.colId } : {});
|
||
let editingPayload = {};
|
||
if (editingOverride !== undefined) {
|
||
editingPayload = editingOverride ? { row_id: editingOverride.rowId, col_id: editingOverride.colId } : {};
|
||
} else if (editing) {
|
||
editingPayload = { row_id: editing.rowId, col_id: editing.colId };
|
||
}
|
||
const result = await api('/api/fundraising/collab/heartbeat', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
active_view: activeView,
|
||
selected,
|
||
editing: editingPayload,
|
||
ttl_seconds: 25
|
||
})
|
||
}, token);
|
||
const data = result?.data || {};
|
||
setCollabPresence(Array.isArray(data.presence) ? data.presence : []);
|
||
setCollabLocks(Array.isArray(data.locks) ? data.locks : []);
|
||
return data;
|
||
} catch (err) {
|
||
if (!quiet) onShowToast(getErrorMessage(err, 'Failed to sync collaboration state'), 'error');
|
||
return null;
|
||
}
|
||
}, [selectedCell, editing, activeView, token, onShowToast]);
|
||
|
||
const updateCell = (rowId, colId, value) => {
|
||
setRows((prev) => prev.map((r) => {
|
||
if (r.id !== rowId) return r;
|
||
const next = { ...r, [colId]: value };
|
||
if (colId === 'notes') {
|
||
next.notes_last_modified = new Date().toISOString().slice(0, 10);
|
||
}
|
||
return next;
|
||
}));
|
||
};
|
||
|
||
const buildEmptyRow = () => {
|
||
const base = { id: `inv-${Date.now()}`, contacts: [], investor_name: '', notes: '', lead_source: '', notes_last_modified: '', last_communication_date: '', lead: '', priority: false, follow_up: false, graveyard: false };
|
||
columns.forEach((c) => {
|
||
if (c.type === 'checkbox') base[c.id] = false;
|
||
if (c.type === 'currency' || c.type === 'number') base[c.id] = 0;
|
||
if (c.type === 'date' || c.type === 'text' || c.type === 'longtext' || c.type === 'select') base[c.id] = '';
|
||
if (c.type === 'contacts') base[c.id] = [];
|
||
});
|
||
return base;
|
||
};
|
||
|
||
const openLogCommunicationModal = (row, contact = null) => {
|
||
if (!row) return;
|
||
setLogCommContext({ rowId: row.id, investorName: row.investor_name || '', contact: contact || null });
|
||
setLogCommForm({ type: 'note', subject: '', body: '', outcome: '', next_action: '', next_action_date: '', append_note: true });
|
||
setShowLogCommModal(true);
|
||
};
|
||
|
||
const openContactCardModal = async (row, contact = null) => {
|
||
if (!row || !contact) return;
|
||
const norm = (v) => String(v || '').trim().toLowerCase();
|
||
const normName = (v) => norm(v).replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||
const contactName = norm(contact.name);
|
||
const contactNameNorm = normName(contact.name);
|
||
const contactEmail = norm(contact.email);
|
||
const investorName = String(row.investor_name || '').trim();
|
||
const investorNameNorm = normName(investorName);
|
||
const cacheKey = `${contactEmail}|${contactNameNorm}|${investorNameNorm}`;
|
||
|
||
setContactCardContext({
|
||
investorName,
|
||
leadSource: row.lead_source || '',
|
||
contact: { ...contact }
|
||
});
|
||
setShowContactCardModal(true);
|
||
setContactCardLoading(true);
|
||
try {
|
||
if (contactLookupCacheRef.current.has(cacheKey)) {
|
||
const cached = contactLookupCacheRef.current.get(cacheKey);
|
||
if (cached) {
|
||
setContactCardContext({
|
||
investorName: cached.organization_name || cached.organization || investorName,
|
||
leadSource: cached.source || row.lead_source || '',
|
||
contact: {
|
||
name: `${cached.first_name || ''} ${cached.last_name || ''}`.trim() || contact.name || '',
|
||
email: cached.email || contact.email || '',
|
||
title: cached.title || contact.title || '',
|
||
city: cached.city || contact.city || '',
|
||
state: cached.state || contact.state || '',
|
||
country: cached.country || contact.country || '',
|
||
linkedin_url: cached.linkedin_url || '',
|
||
source: cached.source || row.lead_source || '',
|
||
organization_name: cached.organization_name || cached.organization || investorName
|
||
}
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
const scoreCandidate = (c) => {
|
||
const cEmail = norm(c?.email);
|
||
const cNameNorm = normName(`${c?.first_name || ''} ${c?.last_name || ''}`);
|
||
const cOrgNorm = normName(c?.organization_name || c?.organization || '');
|
||
let score = 0;
|
||
if (contactEmail && cEmail && cEmail === contactEmail) score += 100;
|
||
if (contactNameNorm && cNameNorm && cNameNorm === contactNameNorm) score += 40;
|
||
if (investorNameNorm && cOrgNorm && cOrgNorm === investorNameNorm) score += 20;
|
||
if (contactName && norm(`${c?.first_name || ''} ${c?.last_name || ''}`) === contactName) score += 10;
|
||
return score;
|
||
};
|
||
|
||
const searchTerms = [];
|
||
if (contactEmail) searchTerms.push(contactEmail);
|
||
if (contactName) searchTerms.push(contactName);
|
||
if (investorName) searchTerms.push(investorName);
|
||
|
||
let candidates = [];
|
||
for (const term of searchTerms) {
|
||
if (!term) continue;
|
||
const result = await api(`/api/contacts?search=${encodeURIComponent(term)}&limit=200`, {}, token);
|
||
const rows = Array.isArray(result?.data) ? result.data : [];
|
||
candidates = candidates.concat(rows);
|
||
}
|
||
const dedup = [];
|
||
const seen = new Set();
|
||
candidates.forEach((c) => {
|
||
if (!c || !c.id || seen.has(c.id)) return;
|
||
seen.add(c.id);
|
||
dedup.push(c);
|
||
});
|
||
dedup.sort((a, b) => scoreCandidate(b) - scoreCandidate(a));
|
||
const matched = dedup.length > 0 && scoreCandidate(dedup[0]) >= 40 ? dedup[0] : null;
|
||
if (matched) {
|
||
contactLookupCacheRef.current.set(cacheKey, matched);
|
||
const merged = {
|
||
name: `${matched.first_name || ''} ${matched.last_name || ''}`.trim() || contact.name || '',
|
||
email: matched.email || contact.email || '',
|
||
title: matched.title || contact.title || '',
|
||
city: matched.city || contact.city || '',
|
||
state: matched.state || contact.state || '',
|
||
country: matched.country || contact.country || '',
|
||
linkedin_url: matched.linkedin_url || '',
|
||
source: matched.source || row.lead_source || '',
|
||
organization_name: matched.organization_name || matched.organization || investorName
|
||
};
|
||
setContactCardContext({
|
||
investorName: merged.organization_name || investorName,
|
||
leadSource: merged.source || '',
|
||
contact: merged
|
||
});
|
||
}
|
||
} catch (_) {
|
||
// Keep fallback row-level contact values if lookup fails.
|
||
} finally {
|
||
setContactCardLoading(false);
|
||
}
|
||
};
|
||
|
||
const submitLogCommunication = async () => {
|
||
if (!logCommContext?.rowId) return;
|
||
setLogCommSubmitting(true);
|
||
try {
|
||
const payload = {
|
||
row_id: logCommContext.rowId,
|
||
investor_name: logCommContext.investorName || '',
|
||
contact: logCommContext.contact,
|
||
type: logCommForm.type || 'note',
|
||
subject: logCommForm.subject || '',
|
||
body: logCommForm.body || '',
|
||
outcome: logCommForm.outcome || '',
|
||
next_action: logCommForm.next_action || '',
|
||
next_action_date: logCommForm.next_action_date || '',
|
||
append_note: !!logCommForm.append_note
|
||
};
|
||
const result = await api('/api/fundraising/log-communication', {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload)
|
||
}, token);
|
||
const nextRow = result?.data?.row || null;
|
||
const nextVersion = Number(result?.data?.version);
|
||
if (nextRow && nextRow.id) {
|
||
setRows((prev) => prev.map((r) => (r.id === nextRow.id ? { ...r, ...nextRow } : r)));
|
||
}
|
||
if (Number.isFinite(nextVersion) && nextVersion > 0) {
|
||
setRemoteVersion(nextVersion);
|
||
}
|
||
onShowToast('Communication logged', 'success');
|
||
setShowLogCommModal(false);
|
||
setLogCommContext(null);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to log communication'), 'error');
|
||
} finally {
|
||
setLogCommSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const splitFullName = (fullName) => {
|
||
const parts = String(fullName || '').trim().split(/\s+/).filter(Boolean);
|
||
if (parts.length === 0) return { first: 'Unknown', last: '' };
|
||
if (parts.length === 1) return { first: parts[0], last: '' };
|
||
return { first: parts[0], last: parts.slice(1).join(' ') };
|
||
};
|
||
|
||
const openCreateOpportunityModal = (row) => {
|
||
if (!row) return;
|
||
const contacts = Array.isArray(row.contacts) ? row.contacts : [];
|
||
if (contacts.length === 0) {
|
||
onShowToast('Add at least one contact to this investor row before adding it to the pipeline', 'error');
|
||
return;
|
||
}
|
||
const defaultName = `${row.investor_name || 'Investor'} — Pipeline`;
|
||
setCreateOppContext({ rowId: row.id, investorName: row.investor_name || '', contacts });
|
||
setCreateOppForm({
|
||
name: defaultName,
|
||
contactIndex: 0,
|
||
stage: 'lead',
|
||
expected_amount: '',
|
||
probability: row.priority ? 55 : 35,
|
||
fund_name: ''
|
||
});
|
||
setShowCreateOppModal(true);
|
||
};
|
||
|
||
// Grid is canonical: the server resolves the contact from the row's already-synced
|
||
// contact pill (no POST /api/contacts side-door) and links the opp durably, so the
|
||
// bot/board can never spawn a duplicate. We only pass the seed.
|
||
const submitCreateOpportunity = async () => {
|
||
if (!createOppContext?.rowId) return;
|
||
setCreateOppSubmitting(true);
|
||
const rowId = createOppContext.rowId;
|
||
const payload = {
|
||
source_row_id: rowId,
|
||
contact_index: Number(createOppForm.contactIndex) || 0,
|
||
name: String(createOppForm.name || '').trim(),
|
||
stage: createOppForm.stage || 'lead',
|
||
expected_amount: parseNumericInput(createOppForm.expected_amount),
|
||
probability: Number(createOppForm.probability) || 35,
|
||
fund_name: String(createOppForm.fund_name || '').trim()
|
||
};
|
||
const doLink = () => api('/api/fundraising/pipeline/link', { method: 'POST', body: JSON.stringify(payload) }, token);
|
||
try {
|
||
let resp;
|
||
try {
|
||
resp = await doLink();
|
||
} catch (err) {
|
||
// A row added moments ago may not have autosaved/synced yet — wait for the
|
||
// debounced save to flush, then retry once.
|
||
if (err?.status === 404) {
|
||
await new Promise((r) => setTimeout(r, 700));
|
||
resp = await doLink();
|
||
} else {
|
||
throw err;
|
||
}
|
||
}
|
||
const stage = resp?.data?.stage || payload.stage;
|
||
const already = resp?.already_linked;
|
||
setRows((prev) => prev.map((r) => (r.id === rowId ? { ...r, pipeline: true, pipeline_stage: stage } : r)));
|
||
onShowToast(already ? 'Already in the pipeline' : 'Added to the pipeline', already ? 'info' : 'success');
|
||
setShowCreateOppModal(false);
|
||
setCreateOppContext(null);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to add to the pipeline'), 'error');
|
||
} finally {
|
||
setCreateOppSubmitting(false);
|
||
}
|
||
};
|
||
|
||
// Remove from pipeline: archives (soft-deletes) the linked opportunity. The grid
|
||
// investor row — contacts, commitments, notes — is left fully intact.
|
||
const removeFromPipeline = async (row) => {
|
||
if (!row?.id) return;
|
||
const label = row.investor_name || 'this investor';
|
||
if (!window.confirm(`Remove ${label} from the pipeline? The deal is archived (recoverable); the grid row is untouched.`)) return;
|
||
try {
|
||
await api('/api/fundraising/pipeline/unlink', { method: 'POST', body: JSON.stringify({ source_row_id: row.id }) }, token);
|
||
setRows((prev) => prev.map((r) => (r.id === row.id ? { ...r, pipeline: false, pipeline_stage: '' } : r)));
|
||
onShowToast('Removed from the pipeline', 'success');
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to remove from the pipeline'), 'error');
|
||
}
|
||
};
|
||
|
||
const addRow = () => {
|
||
const base = buildEmptyRow();
|
||
setRows((prev) => [base, ...prev]);
|
||
};
|
||
|
||
const addRowBelow = (rowId) => {
|
||
const base = buildEmptyRow();
|
||
setRows((prev) => {
|
||
const idx = prev.findIndex((r) => r.id === rowId);
|
||
if (idx < 0) return [base, ...prev];
|
||
const next = [...prev];
|
||
next.splice(idx + 1, 0, base);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const addColumn = () => {
|
||
if (!newColumn.label.trim()) return;
|
||
if (newColumn.type === 'rollup') {
|
||
onShowToast('Use Formula for custom computed columns. Rollup is reserved for system columns.', 'error');
|
||
return;
|
||
}
|
||
if (newColumn.type === 'formula' && !String(newColumn.formula || '').trim()) {
|
||
onShowToast('Formula column requires a formula', 'error');
|
||
return;
|
||
}
|
||
const id = newColumn.label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') + '_' + Date.now().toString().slice(-4);
|
||
const col = {
|
||
id,
|
||
label: newColumn.label.trim(),
|
||
type: newColumn.type,
|
||
width: newColumn.type === 'longtext' ? 280 : 150,
|
||
isFund: newColumn.type === 'currency' ? !!newColumn.isFund : false,
|
||
options: newColumn.type === 'select' ? newColumn.options.split(',').map((s) => s.trim()).filter(Boolean) : undefined,
|
||
formula: newColumn.type === 'formula' ? newColumn.formula : undefined,
|
||
readOnly: newColumn.type === 'formula'
|
||
};
|
||
setColumns((prev) => {
|
||
const next = [...prev];
|
||
if (newColumnInsertIndex !== null && Number.isFinite(newColumnInsertIndex)) {
|
||
const idx = Math.max(0, Math.min(next.length, newColumnInsertIndex));
|
||
next.splice(idx, 0, col);
|
||
} else {
|
||
const rollupIdx = next.findIndex((c) => c.id === 'total_invested');
|
||
if (rollupIdx >= 0) next.splice(rollupIdx, 0, col);
|
||
else next.push(col);
|
||
}
|
||
return next;
|
||
});
|
||
setRows((prev) => prev.map((r) => ({ ...r, [id]: col.type === 'checkbox' ? false : col.type === 'currency' || col.type === 'number' ? 0 : col.type === 'contacts' ? [] : '' })));
|
||
setNewColumn({ label: '', type: 'text', options: '', isFund: false, formula: '' });
|
||
setNewColumnInsertIndex(null);
|
||
setShowColumnModal(false);
|
||
onShowToast('Column added', 'success');
|
||
};
|
||
|
||
const openAddColumnModal = (insertIndex = null) => {
|
||
setColumnModalMode('add');
|
||
setEditingColumnId(null);
|
||
setNewColumn({ label: '', type: 'text', options: '', isFund: false, formula: '' });
|
||
setNewColumnInsertIndex(insertIndex);
|
||
setShowColumnModal(true);
|
||
};
|
||
|
||
const openEditColumnModal = (colId) => {
|
||
const col = columns.find((c) => c.id === colId);
|
||
if (!col) return;
|
||
setColumnModalMode('edit');
|
||
setEditingColumnId(col.id);
|
||
setNewColumn({
|
||
label: col.label || '',
|
||
type: col.type || 'text',
|
||
options: Array.isArray(col.options) ? col.options.join(', ') : '',
|
||
isFund: !!col.isFund,
|
||
formula: col.id === 'total_invested'
|
||
? String(col.formula || defaultTotalInvestedFormula)
|
||
: String(col.formula || '')
|
||
});
|
||
setNewColumnInsertIndex(null);
|
||
setShowColumnModal(true);
|
||
};
|
||
|
||
const saveColumnModal = () => {
|
||
if (columnModalMode === 'add') {
|
||
addColumn();
|
||
return;
|
||
}
|
||
if (!editingColumnId) return;
|
||
if (!newColumn.label.trim()) {
|
||
onShowToast('Column name is required', 'error');
|
||
return;
|
||
}
|
||
if ((newColumn.type === 'formula' || editingColumnId === 'total_invested') && !String(newColumn.formula || '').trim()) {
|
||
onShowToast('Formula is required for this column', 'error');
|
||
return;
|
||
}
|
||
setColumns((prev) => prev.map((c) => {
|
||
if (c.id !== editingColumnId) return c;
|
||
const next = { ...c, label: newColumn.label.trim() };
|
||
if (c.id === 'total_invested') {
|
||
next.type = 'rollup';
|
||
next.readOnly = true;
|
||
next.formula = String(newColumn.formula || '').trim();
|
||
return next;
|
||
}
|
||
next.type = newColumn.type;
|
||
next.options = newColumn.type === 'select'
|
||
? newColumn.options.split(',').map((s) => s.trim()).filter(Boolean)
|
||
: undefined;
|
||
if (newColumn.type === 'formula') {
|
||
next.readOnly = true;
|
||
next.formula = String(newColumn.formula || '').trim();
|
||
} else {
|
||
next.readOnly = false;
|
||
if (Object.prototype.hasOwnProperty.call(next, 'formula')) delete next.formula;
|
||
}
|
||
if (newColumn.type === 'currency') {
|
||
next.isFund = !!newColumn.isFund;
|
||
} else if (Object.prototype.hasOwnProperty.call(next, 'isFund')) {
|
||
next.isFund = false;
|
||
}
|
||
return next;
|
||
}));
|
||
setShowColumnModal(false);
|
||
setEditingColumnId(null);
|
||
setColumnModalMode('add');
|
||
onShowToast('Column updated', 'success');
|
||
};
|
||
|
||
const ensureUniqueColumnId = (base, existingCols) => {
|
||
let candidate = base;
|
||
let i = 1;
|
||
const existingIds = new Set(existingCols.map((c) => c.id));
|
||
while (existingIds.has(candidate)) {
|
||
candidate = `${base}_${i}`;
|
||
i += 1;
|
||
}
|
||
return candidate;
|
||
};
|
||
|
||
const handleImportAirtableCsv = async () => {
|
||
setImportError('');
|
||
const records = parseCsvRecords(importCsvText);
|
||
if (records.length === 0) {
|
||
setImportError('No rows detected. Paste Airtable CSV export data or upload a CSV file.');
|
||
return;
|
||
}
|
||
pushImportDebugEvent('import-start', {
|
||
csv_rows: records.length,
|
||
existing_rows: rows.length,
|
||
existing_columns: columns.length
|
||
});
|
||
|
||
const existingColumns = [...columns];
|
||
const headers = Object.keys(records[0] || {});
|
||
const knownByNormalized = new Map();
|
||
existingColumns.forEach((c) => {
|
||
knownByNormalized.set(normalizeKey(c.label), c);
|
||
knownByNormalized.set(normalizeKey(c.id), c);
|
||
});
|
||
|
||
const findColumnForHeader = (header) => {
|
||
const h = normalizeKey(header);
|
||
if (knownByNormalized.has(h)) return knownByNormalized.get(h);
|
||
if (h.includes('investor name')) return existingColumns.find((c) => c.id === 'investor_name');
|
||
if (h === 'contacts') return existingColumns.find((c) => c.id === 'contacts');
|
||
if (h.includes('notes')) return existingColumns.find((c) => c.id === 'notes');
|
||
if (h.includes('lead source')) return existingColumns.find((c) => c.id === 'lead_source');
|
||
if (h.includes('last communication')) return existingColumns.find((c) => c.id === 'last_communication_date');
|
||
if (h === 'priority') return existingColumns.find((c) => c.id === 'priority');
|
||
if (h.includes('follow up')) return existingColumns.find((c) => c.id === 'follow_up');
|
||
if (h === 'lead') return existingColumns.find((c) => c.id === 'lead');
|
||
if (h === 'graveyard') return existingColumns.find((c) => c.id === 'graveyard');
|
||
if (h.includes('total invested')) return existingColumns.find((c) => c.id === 'total_invested');
|
||
if (h.includes('commit date')) return existingColumns.find((c) => c.id === 'tactical_fund_commit_date');
|
||
return null;
|
||
};
|
||
|
||
const headerToColumn = {};
|
||
const newColumns = [];
|
||
headers.forEach((header) => {
|
||
const mapped = findColumnForHeader(header);
|
||
if (mapped) {
|
||
headerToColumn[header] = mapped;
|
||
return;
|
||
}
|
||
|
||
if (!createMissingColumns) return;
|
||
const baseId = normalizeKey(header).replace(/\s+/g, '_') || `col_${Date.now()}`;
|
||
const uniqueId = ensureUniqueColumnId(baseId, [...existingColumns, ...newColumns]);
|
||
const colType = normalizeKey(header).includes('date') ? 'date' : normalizeKey(header).includes('fund') ? 'currency' : 'text';
|
||
const col = { id: uniqueId, label: header.trim() || uniqueId, type: colType, width: colType === 'currency' ? 140 : 170, isFund: normalizeKey(header).includes('fund') };
|
||
newColumns.push(col);
|
||
headerToColumn[header] = col;
|
||
});
|
||
|
||
const combinedColumns = newColumns.length > 0
|
||
? (() => {
|
||
const next = [...existingColumns];
|
||
const rollupIdx = next.findIndex((c) => c.id === 'total_invested');
|
||
if (rollupIdx >= 0) next.splice(rollupIdx, 0, ...newColumns);
|
||
else next.push(...newColumns);
|
||
return next;
|
||
})()
|
||
: existingColumns;
|
||
|
||
const importedRows = records.map((record, idx) => {
|
||
const row = { id: `inv-import-${Date.now()}-${idx}`, contacts: [] };
|
||
combinedColumns.forEach((c) => {
|
||
if (c.type === 'checkbox') row[c.id] = false;
|
||
else if (c.type === 'currency' || c.type === 'number') row[c.id] = 0;
|
||
else row[c.id] = c.type === 'contacts' ? [] : '';
|
||
});
|
||
|
||
headers.forEach((header) => {
|
||
const col = headerToColumn[header];
|
||
if (!col || col.id === 'total_invested') return;
|
||
const raw = record[header];
|
||
if (col.type === 'checkbox') {
|
||
row[col.id] = parseBoolCell(raw);
|
||
return;
|
||
}
|
||
if (col.type === 'currency' || col.type === 'number') {
|
||
row[col.id] = parseNumericInput(raw);
|
||
return;
|
||
}
|
||
if (col.type === 'contacts') {
|
||
const names = String(raw || '').split(',').map((s) => s.trim()).filter(Boolean);
|
||
row[col.id] = names.map((name) => ({ name, email: '', title: '', city: '', state: '', country: '', location_query: '' }));
|
||
return;
|
||
}
|
||
row[col.id] = raw;
|
||
});
|
||
|
||
const emailHeader = headers.find((h) => normalizeKey(h).includes('contact email') || normalizeKey(h) === 'emails');
|
||
if (emailHeader && Array.isArray(row.contacts) && row.contacts.length > 0) {
|
||
const emails = String(record[emailHeader] || '').split(',').map((s) => s.trim()).filter(Boolean);
|
||
row.contacts = row.contacts.map((c, i) => ({ ...c, email: emails[i] || '' }));
|
||
}
|
||
|
||
return row;
|
||
});
|
||
|
||
const nextRows = [...importedRows, ...rows];
|
||
const nextViews = (() => {
|
||
const next = views.map((v) => {
|
||
if (v.id !== 'view-all') return v;
|
||
return {
|
||
...v,
|
||
filters: { includeGraveyard: true, graveyardOnly: false, followUpOnly: false, lead: '' },
|
||
quickSearch: '',
|
||
hiddenColumns: [],
|
||
columnFilters: [],
|
||
footerAggs: (v.footerAggs && typeof v.footerAggs === 'object') ? v.footerAggs : {},
|
||
rowDensity: v.rowDensity === 'compact' ? 'compact' : 'expanded'
|
||
};
|
||
});
|
||
if (!next.some((v) => v.id === 'view-all')) {
|
||
next.push({
|
||
id: 'view-all',
|
||
name: 'All Investors',
|
||
filters: { includeGraveyard: true, graveyardOnly: false, followUpOnly: false, lead: '' },
|
||
quickSearch: '',
|
||
hiddenColumns: [],
|
||
columnFilters: [],
|
||
footerAggs: {},
|
||
rowDensity: 'expanded',
|
||
columnOrder: [],
|
||
columnWidths: {}
|
||
});
|
||
}
|
||
return next;
|
||
})();
|
||
|
||
if (newColumns.length > 0) setColumns(combinedColumns);
|
||
setRows(nextRows);
|
||
setViews(nextViews);
|
||
lastAppliedViewIdRef.current = null;
|
||
setActiveView('view-all');
|
||
setFilters({ includeGraveyard: true, graveyardOnly: false, followUpOnly: false, lead: '' });
|
||
setQuickSearch('');
|
||
setColumnFilters([]);
|
||
setHiddenColumns([]);
|
||
setFooterAggs((nextViews.find((v) => v.id === 'view-all')?.footerAggs) || {});
|
||
setRowDensity((nextViews.find((v) => v.id === 'view-all')?.rowDensity) === 'compact' ? 'compact' : 'expanded');
|
||
setSortState({ colId: '', dir: 'asc' });
|
||
setImportCsvText('');
|
||
setShowImportModal(false);
|
||
onShowToast(`Imported ${importedRows.length} rows from Airtable CSV`, 'success');
|
||
pushImportDebugEvent('import-done', {
|
||
imported_rows: importedRows.length,
|
||
total_rows_after_local_set: nextRows.length,
|
||
active_view: 'view-all'
|
||
});
|
||
try {
|
||
let expectedVersion = Number(remoteVersion);
|
||
if (!Number.isFinite(expectedVersion) || expectedVersion <= 0) {
|
||
const latest = await api('/api/fundraising/state', {}, token);
|
||
expectedVersion = Number(latest?.data?.version);
|
||
}
|
||
const persistRows = stripComputedRows(nextRows);
|
||
const saveResponse = await api('/api/fundraising/state', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
grid: { columns: combinedColumns, rows: persistRows },
|
||
views: nextViews,
|
||
expected_version: Number.isFinite(expectedVersion) && expectedVersion > 0 ? expectedVersion : 1
|
||
})
|
||
}, token);
|
||
const persistedVersion = Number(saveResponse?.data?.version);
|
||
if (Number.isFinite(persistedVersion) && persistedVersion > 0) {
|
||
setRemoteVersion(persistedVersion);
|
||
}
|
||
lastSyncedSnapshotRef.current = JSON.stringify({ columns: combinedColumns, rows: persistRows, views: nextViews });
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns: combinedColumns, rows: persistRows }));
|
||
localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(nextViews));
|
||
pushImportDebugEvent('import-persist-success', {
|
||
persisted_version: Number.isFinite(persistedVersion) ? persistedVersion : null,
|
||
rows: nextRows.length
|
||
});
|
||
} catch (persistErr) {
|
||
pushImportDebugEvent('import-persist-error', { message: getErrorMessage(persistErr, 'persist failed') });
|
||
onShowToast(`Imported locally but failed to persist: ${getErrorMessage(persistErr, 'save failed')}`, 'error');
|
||
}
|
||
setTimeout(() => {
|
||
loadImportDebugSnapshot();
|
||
}, 1200);
|
||
};
|
||
|
||
const handleCsvFileUpload = async (event) => {
|
||
const file = event.target.files && event.target.files[0];
|
||
if (!file) return;
|
||
try {
|
||
const text = await file.text();
|
||
setImportCsvText(text);
|
||
} catch (_) {
|
||
setImportError('Could not read selected file');
|
||
} finally {
|
||
event.target.value = '';
|
||
}
|
||
};
|
||
|
||
const saveCurrentFiltersAsView = () => {
|
||
if (!newViewName.trim()) return;
|
||
const id = `view-${Date.now()}`;
|
||
setViews((prev) => [...prev, {
|
||
id,
|
||
name: newViewName.trim(),
|
||
filters: { ...filters },
|
||
quickSearch,
|
||
hiddenColumns: hiddenColumns.filter((id) => id !== 'investor_name'),
|
||
columnFilters: [...columnFilters],
|
||
footerAggs: { ...footerAggs },
|
||
rowDensity,
|
||
columnOrder: columnsForActiveView.map((c) => c.id),
|
||
columnWidths: Object.fromEntries(columnsForActiveView.map((c) => [c.id, Math.max(90, Number(c.width) || 140)]))
|
||
}]);
|
||
setActiveView(id);
|
||
setNewViewName('');
|
||
setShowViewModal(false);
|
||
onShowToast('View saved', 'success');
|
||
};
|
||
|
||
const cellTabIndex = (rowIndex, colIndex) => rowIndex * visibleColumns.length + colIndex + 1;
|
||
|
||
const moveFocus = (rowIndex, colIndex) => {
|
||
const target = document.querySelector(`[data-cell-key="r${rowIndex}-c${colIndex}"]`);
|
||
if (target) target.focus();
|
||
};
|
||
|
||
const moveByKey = (rowIndex, colIndex, key, shiftKey = false) => {
|
||
if (visibleColumns.length === 0 || displayedRows.length === 0) return;
|
||
let nextRow = rowIndex;
|
||
let nextCol = colIndex;
|
||
if (key === 'Tab') {
|
||
nextCol = shiftKey ? colIndex - 1 : colIndex + 1;
|
||
if (nextCol >= visibleColumns.length) {
|
||
nextCol = 0;
|
||
nextRow = Math.min(displayedRows.length - 1, rowIndex + 1);
|
||
}
|
||
if (nextCol < 0) {
|
||
nextCol = visibleColumns.length - 1;
|
||
nextRow = Math.max(0, rowIndex - 1);
|
||
}
|
||
} else if (key === 'Enter') {
|
||
nextRow = Math.min(displayedRows.length - 1, rowIndex + 1);
|
||
}
|
||
moveFocus(nextRow, nextCol);
|
||
};
|
||
|
||
const tryBeginCellEdit = async (row, col) => {
|
||
if (!row || !col) return false;
|
||
if (col.type === 'action' || col.type === 'checkbox' || col.readOnly || col.id === 'total_invested') return false;
|
||
const existing = getCellLockByOther(row.id, col.id);
|
||
if (existing) {
|
||
onShowToast(`Locked by ${existing.locked_by_full_name || existing.locked_by_username || 'another user'}`, 'error');
|
||
return false;
|
||
}
|
||
const heartbeat = await sendCollabHeartbeat({
|
||
selectedOverride: { row_id: row.id, col_id: col.id },
|
||
editingOverride: { rowId: row.id, colId: col.id },
|
||
quiet: true
|
||
});
|
||
const conflict = heartbeat?.lock_conflict;
|
||
if (conflict && conflict.locked_by_user_id !== user?.id) {
|
||
onShowToast(`Locked by ${conflict.locked_by_full_name || conflict.locked_by_username || 'another user'}`, 'error');
|
||
return false;
|
||
}
|
||
setSelectedCell({ rowId: row.id, colId: col.id });
|
||
setEditing({ rowId: row.id, colId: col.id });
|
||
return true;
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!resizing) return undefined;
|
||
|
||
const onMouseMove = (event) => {
|
||
const deltaX = event.clientX - resizing.startX;
|
||
const nextWidth = Math.max(90, resizing.startWidth + deltaX);
|
||
setViews((prev) => prev.map((v) => {
|
||
if (v.id !== activeView) return v;
|
||
return {
|
||
...v,
|
||
columnWidths: {
|
||
...(v.columnWidths || {}),
|
||
[resizing.colId]: nextWidth
|
||
}
|
||
};
|
||
}));
|
||
};
|
||
|
||
const onMouseUp = () => {
|
||
suppressNextHeaderSortRef.current = true;
|
||
setResizing(null);
|
||
window.setTimeout(() => {
|
||
suppressNextHeaderSortRef.current = false;
|
||
}, 200);
|
||
};
|
||
|
||
window.addEventListener('mousemove', onMouseMove);
|
||
window.addEventListener('mouseup', onMouseUp);
|
||
return () => {
|
||
window.removeEventListener('mousemove', onMouseMove);
|
||
window.removeEventListener('mouseup', onMouseUp);
|
||
};
|
||
}, [resizing, activeView, setViews]);
|
||
|
||
useEffect(() => {
|
||
if (!contextMenu) return undefined;
|
||
const close = () => setContextMenu(null);
|
||
const onKeyDown = (event) => {
|
||
if (event.key === 'Escape') close();
|
||
};
|
||
window.addEventListener('click', close);
|
||
window.addEventListener('contextmenu', close);
|
||
window.addEventListener('keydown', onKeyDown);
|
||
return () => {
|
||
window.removeEventListener('click', close);
|
||
window.removeEventListener('contextmenu', close);
|
||
window.removeEventListener('keydown', onKeyDown);
|
||
};
|
||
}, [contextMenu]);
|
||
|
||
const startColumnResize = (event, col) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
suppressNextHeaderSortRef.current = true;
|
||
setResizing({
|
||
colId: col.id,
|
||
startX: event.clientX,
|
||
startWidth: col.width || 140
|
||
});
|
||
};
|
||
|
||
const moveColumnBefore = (fromColId, targetColId) => {
|
||
if (!fromColId || !targetColId || fromColId === targetColId) return;
|
||
if (fromColId === 'investor_name' || targetColId === 'investor_name') return;
|
||
const currentOrder = columnsForActiveView.map((c) => c.id);
|
||
const fromIndex = currentOrder.indexOf(fromColId);
|
||
const targetIndex = currentOrder.indexOf(targetColId);
|
||
if (fromIndex < 0 || targetIndex < 0 || fromIndex === targetIndex) return;
|
||
const nextOrder = [...currentOrder];
|
||
const [moved] = nextOrder.splice(fromIndex, 1);
|
||
const insertIndex = fromIndex < targetIndex ? targetIndex : targetIndex;
|
||
nextOrder.splice(insertIndex, 0, moved);
|
||
setViews((prev) => prev.map((v) => (
|
||
v.id === activeView ? { ...v, columnOrder: nextOrder } : v
|
||
)));
|
||
};
|
||
|
||
const openGridContextMenu = (event, payload = {}) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setContextMenu({
|
||
x: event.clientX,
|
||
y: event.clientY,
|
||
...payload
|
||
});
|
||
};
|
||
|
||
const handleContextAction = (action) => {
|
||
if (!contextMenu) return;
|
||
if (action === 'add-investor-top') {
|
||
addRow();
|
||
} else if (action === 'add-investor-below' && contextMenu.rowId) {
|
||
addRowBelow(contextMenu.rowId);
|
||
} else if (action === 'insert-col-left') {
|
||
const realIndex = columns.findIndex((c) => c.id === contextMenu.colId);
|
||
openAddColumnModal(Math.max(0, realIndex >= 0 ? realIndex : 0));
|
||
} else if (action === 'insert-col-right') {
|
||
const realIndex = columns.findIndex((c) => c.id === contextMenu.colId);
|
||
openAddColumnModal(Math.max(0, (realIndex >= 0 ? realIndex : 0) + 1));
|
||
} else if (action === 'add-column-end') {
|
||
openAddColumnModal(null);
|
||
} else if (action === 'edit-column' && contextMenu.colId) {
|
||
openEditColumnModal(contextMenu.colId);
|
||
} else if (action === 'manage-columns') {
|
||
setShowColumnVisibilityModal(true);
|
||
} else if (action === 'add-filter') {
|
||
setShowFilterModal(true);
|
||
} else if (action === 'save-view') {
|
||
setShowViewModal(true);
|
||
} else if (action === 'log-communication' && contextMenu.rowId) {
|
||
const target = rows.find((r) => r.id === contextMenu.rowId);
|
||
if (target) openLogCommunicationModal(target, null);
|
||
} else if (action === 'create-opportunity' && contextMenu.rowId) {
|
||
const target = rows.find((r) => r.id === contextMenu.rowId);
|
||
if (target) openCreateOpportunityModal(target);
|
||
} else if (action === 'delete-row' && contextMenu.rowId) {
|
||
const target = rows.find((r) => r.id === contextMenu.rowId);
|
||
const label = String(target?.investor_name || 'this investor').trim() || 'this investor';
|
||
const confirmed = window.confirm(`Delete row for "${label}"? This removes the investor from the fundraising grid.`);
|
||
if (confirmed) {
|
||
setRows((prev) => prev.filter((r) => r.id !== contextMenu.rowId));
|
||
onShowToast(`Deleted investor row: ${label}`, 'success');
|
||
}
|
||
}
|
||
setContextMenu(null);
|
||
};
|
||
|
||
const getRuleOpsForColumn = (col) => {
|
||
if (!col) return [{ value: 'contains', label: 'contains' }, { value: 'equals', label: 'equals' }];
|
||
if (col.type === 'checkbox') return [{ value: 'is_true', label: 'is checked' }, { value: 'is_false', label: 'is unchecked' }];
|
||
if (col.type === 'currency' || col.type === 'number' || col.id === 'total_invested') {
|
||
return [{ value: 'gt', label: '>' }, { value: 'lt', label: '<' }, { value: 'equals', label: '=' }];
|
||
}
|
||
if (col.type === 'date') {
|
||
return [{ value: 'on_or_after', label: 'on or after' }, { value: 'on_or_before', label: 'on or before' }, { value: 'equals', label: 'on date' }];
|
||
}
|
||
return [
|
||
{ value: 'contains', label: 'contains' },
|
||
{ value: 'equals', label: 'equals' },
|
||
{ value: 'not_equals', label: 'not equals' }
|
||
];
|
||
};
|
||
|
||
const addFilterRule = () => {
|
||
if (!newRule.colId) return;
|
||
const col = getColumnById(newRule.colId);
|
||
const ops = getRuleOpsForColumn(col).map((o) => o.value);
|
||
const op = ops.includes(newRule.op) ? newRule.op : ops[0];
|
||
const value = (op === 'is_true' || op === 'is_false') ? '' : newRule.value;
|
||
setColumnFilters((prev) => [...prev, { id: `rule-${Date.now()}`, colId: newRule.colId, op, value }]);
|
||
setNewRule({ colId: '', op: 'contains', value: '' });
|
||
setShowFilterModal(false);
|
||
};
|
||
|
||
const toggleRowDensity = () => {
|
||
const next = rowDensity === 'compact' ? 'expanded' : 'compact';
|
||
setRowDensity(next);
|
||
setViews((prev) => prev.map((v) => (
|
||
v.id === activeView
|
||
? { ...v, rowDensity: next }
|
||
: v
|
||
)));
|
||
};
|
||
|
||
const renderCellValue = (row, col) => {
|
||
const value = row[col.id];
|
||
const compactPreview = (text) => (
|
||
<span className="compact-cell-preview" title={String(text || '')}>{String(text || '')}</span>
|
||
);
|
||
if (col.id === 'total_invested') {
|
||
if (col.formula) {
|
||
const computed = evaluateFormulaCell(row, col);
|
||
return formatCurrencyLong(parseNumericInput(computed));
|
||
}
|
||
return formatCurrencyLong(computeRollup(row));
|
||
}
|
||
if (col.type === 'formula') {
|
||
const computed = evaluateFormulaCell(row, col);
|
||
if (typeof computed === 'number' && Number.isFinite(computed)) return computed.toLocaleString();
|
||
return String(computed ?? '');
|
||
}
|
||
if (col.id === 'pipeline') {
|
||
if (row.pipeline) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className="button-secondary"
|
||
style={{ padding: '5px 10px', fontSize: '12px', borderColor: '#2f6f4f', color: '#7fd3a3' }}
|
||
title="In the deal pipeline — click to remove (archives the deal; grid row stays)"
|
||
onClick={(e) => { e.stopPropagation(); removeFromPipeline(row); }}
|
||
>
|
||
✓ In pipeline
|
||
</button>
|
||
);
|
||
}
|
||
return (
|
||
<button
|
||
type="button"
|
||
className="button-secondary"
|
||
style={{ padding: '5px 10px', fontSize: '12px' }}
|
||
title="Add this investor to the deal pipeline"
|
||
onClick={(e) => { e.stopPropagation(); openCreateOpportunityModal(row); }}
|
||
>
|
||
+ Pipeline
|
||
</button>
|
||
);
|
||
}
|
||
if (col.id === 'pipeline_stage') {
|
||
const stage = String(row.pipeline_stage || '');
|
||
if (!stage) return <span style={{ color: '#70859b' }}>—</span>;
|
||
return <span style={{ textTransform: 'capitalize' }}>{stage.replace(/_/g, ' ')}</span>;
|
||
}
|
||
if (col.type === 'action' || col.id === 'log_action') {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className="button-secondary"
|
||
style={{ padding: '5px 10px', fontSize: '12px' }}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
openLogCommunicationModal(row, null);
|
||
}}
|
||
>
|
||
Log
|
||
</button>
|
||
);
|
||
}
|
||
if (col.type === 'checkbox') return value ? '✓' : '';
|
||
if (col.type === 'currency') return value ? formatCurrencyLong(value) : '$0';
|
||
if (col.type === 'contacts') {
|
||
const contacts = Array.isArray(value) ? value : [];
|
||
if (contacts.length === 0) return <span style={{ color: '#70859b' }}>+ Add contacts</span>;
|
||
if (rowDensity === 'compact') {
|
||
return (
|
||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'nowrap', overflow: 'hidden', maxWidth: '100%' }}>
|
||
{contacts.map((c, i) => (
|
||
<button
|
||
key={i}
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
openContactCardModal(row, c);
|
||
}}
|
||
style={{
|
||
background: '#263548',
|
||
border: '1px solid #32485f',
|
||
color: '#c7d3e0',
|
||
borderRadius: '10px',
|
||
padding: '2px 8px',
|
||
fontSize: '12px',
|
||
cursor: 'pointer',
|
||
flex: '0 0 auto',
|
||
maxWidth: '180px',
|
||
whiteSpace: 'nowrap',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis'
|
||
}}
|
||
title={c.name || 'Unnamed'}
|
||
>
|
||
{c.name || 'Unnamed'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||
{contacts.map((c, i) => (
|
||
<button
|
||
key={i}
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
openContactCardModal(row, c);
|
||
}}
|
||
style={{ background: '#263548', border: '1px solid #32485f', color: '#c7d3e0', borderRadius: '10px', padding: '2px 8px', fontSize: '12px', cursor: 'pointer' }}
|
||
title="View contact details"
|
||
>
|
||
{c.name || 'Unnamed'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
if (rowDensity === 'compact' && (col.type === 'longtext' || col.type === 'text' || col.type === 'date' || col.type === 'select')) {
|
||
return compactPreview(value || '');
|
||
}
|
||
return value || '';
|
||
};
|
||
|
||
const renderEditor = (row, col, rowIndex, colIndex) => {
|
||
const value = row[col.id];
|
||
const placeCaretAtEnd = (el) => {
|
||
if (!el) return;
|
||
window.requestAnimationFrame(() => {
|
||
try {
|
||
const text = String(el.value ?? '');
|
||
const end = text.length;
|
||
el.setSelectionRange(end, end);
|
||
} catch (_) {
|
||
// Ignore unsupported input types (e.g. date/select).
|
||
}
|
||
});
|
||
};
|
||
if (col.id === 'total_invested' || col.readOnly) return null;
|
||
if (col.type === 'action') return null;
|
||
|
||
if (col.type === 'checkbox') {
|
||
return (
|
||
<input
|
||
autoFocus
|
||
type="checkbox"
|
||
checked={!!value}
|
||
onChange={(e) => updateCell(row.id, col.id, e.target.checked)}
|
||
onBlur={() => setEditing(null)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Tab' || e.key === 'Enter') {
|
||
e.preventDefault();
|
||
setEditing(null);
|
||
moveByKey(rowIndex, colIndex, e.key, e.shiftKey);
|
||
}
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (col.type === 'contacts') {
|
||
const contacts = Array.isArray(value) ? [...value] : [];
|
||
const updateContacts = (next) => updateCell(row.id, col.id, next);
|
||
return (
|
||
<div style={{ minWidth: '480px', background: '#0b1118', border: '1px solid #263548', borderRadius: '8px', padding: '8px' }}>
|
||
{contacts.map((c, idx) => (
|
||
<div key={idx} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr auto', gap: '6px', marginBottom: '6px' }}>
|
||
<input className="text-input" placeholder="Name" value={c.name || ''} onChange={(e) => {
|
||
const next = [...contacts];
|
||
next[idx] = { ...next[idx], name: e.target.value };
|
||
updateContacts(next);
|
||
}} onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
setEditing(null);
|
||
moveByKey(rowIndex, colIndex, 'Enter');
|
||
}
|
||
}} />
|
||
<input className="text-input" placeholder="Email" value={c.email || ''} onChange={(e) => {
|
||
const next = [...contacts];
|
||
next[idx] = { ...next[idx], email: e.target.value };
|
||
updateContacts(next);
|
||
}} onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
setEditing(null);
|
||
moveByKey(rowIndex, colIndex, 'Enter');
|
||
}
|
||
}} />
|
||
<input className="text-input" placeholder="Title (optional)" value={c.title || ''} onChange={(e) => {
|
||
const next = [...contacts];
|
||
next[idx] = { ...next[idx], title: e.target.value };
|
||
updateContacts(next);
|
||
}} onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
setEditing(null);
|
||
moveByKey(rowIndex, colIndex, 'Enter');
|
||
}
|
||
}} />
|
||
<input className="text-input" placeholder="LinkedIn URL" value={c.linkedin_url || ''} onChange={(e) => {
|
||
const next = [...contacts];
|
||
next[idx] = { ...next[idx], linkedin_url: e.target.value };
|
||
updateContacts(next);
|
||
}} onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
setEditing(null);
|
||
moveByKey(rowIndex, colIndex, 'Enter');
|
||
}
|
||
}} />
|
||
<input
|
||
className="text-input"
|
||
list="location-suggestions"
|
||
placeholder="City / Location"
|
||
value={c.location_query || ''}
|
||
onChange={(e) => {
|
||
const query = e.target.value;
|
||
const matched = applyLocationAutoFill(query);
|
||
const next = [...contacts];
|
||
next[idx] = {
|
||
...next[idx],
|
||
location_query: query,
|
||
city: matched ? matched.city : next[idx].city || '',
|
||
state: matched ? matched.state : next[idx].state || '',
|
||
country: matched ? matched.country : next[idx].country || ''
|
||
};
|
||
updateContacts(next);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
setEditing(null);
|
||
moveByKey(rowIndex, colIndex, 'Enter');
|
||
}
|
||
}}
|
||
/>
|
||
<button className="button-danger" type="button" onClick={() => {
|
||
const next = contacts.filter((_, i) => i !== idx);
|
||
updateContacts(next);
|
||
}}>×</button>
|
||
<div style={{ gridColumn: '1 / span 6', fontSize: '11px', color: '#8ea2b7' }}>
|
||
{c.city || c.state || c.country ? `${c.city || '-'}, ${c.state || '-'}, ${c.country || '-'}` : 'No location set'}
|
||
</div>
|
||
</div>
|
||
))}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<button type="button" className="button-secondary" onClick={() => updateContacts([...contacts, { name: '', email: '', title: '', linkedin_url: '', city: '', state: '', country: '', location_query: '' }])}>+ Contact</button>
|
||
<button type="button" onClick={() => { setEditing(null); moveFocus(rowIndex + 1, colIndex); }}>Done</button>
|
||
</div>
|
||
<datalist id="location-suggestions">
|
||
{locationCatalog.map((loc) => <option key={loc.label} value={loc.label} />)}
|
||
</datalist>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (col.type === 'select') {
|
||
const options = col.options || [];
|
||
return (
|
||
<select
|
||
autoFocus
|
||
className="select-input"
|
||
value={value || ''}
|
||
onChange={(e) => updateCell(row.id, col.id, e.target.value)}
|
||
onBlur={() => setEditing(null)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||
e.preventDefault();
|
||
setEditing(null);
|
||
moveByKey(rowIndex, colIndex, e.key, e.shiftKey);
|
||
}
|
||
}}
|
||
>
|
||
<option value="">--</option>
|
||
{options.map((o) => <option key={o} value={o}>{o}</option>)}
|
||
</select>
|
||
);
|
||
}
|
||
|
||
if (col.type === 'longtext') {
|
||
return (
|
||
<textarea
|
||
autoFocus
|
||
className="text-input"
|
||
rows="3"
|
||
value={value || ''}
|
||
onFocus={(e) => placeCaretAtEnd(e.target)}
|
||
onChange={(e) => updateCell(row.id, col.id, e.target.value)}
|
||
onBlur={() => setEditing(null)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
setEditing(null);
|
||
moveByKey(rowIndex, colIndex, 'Enter');
|
||
} else if (e.key === 'Tab') {
|
||
e.preventDefault();
|
||
setEditing(null);
|
||
moveByKey(rowIndex, colIndex, 'Tab', e.shiftKey);
|
||
}
|
||
}}
|
||
style={{ width: '100%', minWidth: 0, display: 'block', resize: 'vertical' }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const inputType = col.type === 'date' ? 'date' : 'text';
|
||
const inputMode = col.type === 'currency' || col.type === 'number' ? 'decimal' : 'text';
|
||
const displayValue = col.type === 'currency' || col.type === 'number' ? String(value ?? '') : (value ?? '');
|
||
return (
|
||
<input
|
||
autoFocus
|
||
type={inputType}
|
||
inputMode={inputMode}
|
||
className="text-input"
|
||
value={displayValue}
|
||
onFocus={(e) => {
|
||
if (inputType === 'text') placeCaretAtEnd(e.target);
|
||
}}
|
||
onChange={(e) => {
|
||
const raw = e.target.value;
|
||
if (col.type === 'currency' || col.type === 'number') updateCell(row.id, col.id, raw);
|
||
else updateCell(row.id, col.id, raw);
|
||
}}
|
||
onBlur={() => {
|
||
if (col.type === 'currency' || col.type === 'number') updateCell(row.id, col.id, parseNumericInput(row[col.id]));
|
||
setEditing(null);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||
e.preventDefault();
|
||
if (col.type === 'currency' || col.type === 'number') updateCell(row.id, col.id, parseNumericInput(row[col.id]));
|
||
setEditing(null);
|
||
moveByKey(rowIndex, colIndex, e.key, e.shiftKey);
|
||
}
|
||
}}
|
||
style={{ MozAppearance: 'textfield', width: '100%', minWidth: 0, display: 'block' }}
|
||
/>
|
||
);
|
||
};
|
||
|
||
const activeCollaborators = useMemo(() => {
|
||
const all = Array.isArray(collabPresence) ? collabPresence : [];
|
||
return all.filter((p) => p && p.user_id && p.user_id !== user?.id);
|
||
}, [collabPresence, user?.id]);
|
||
|
||
return (
|
||
<div className="page-container" style={{ maxWidth: '100%' }}>
|
||
<div className="section" style={{ overflow: 'hidden' }}>
|
||
<div className="controls">
|
||
<input className="search-input" placeholder="Filter investors, contacts, notes..." value={quickSearch} onChange={(e) => setQuickSearch(e.target.value)} />
|
||
<select className="select-input" value={filters.lead} onChange={(e) => setFilters((f) => ({ ...f, lead: e.target.value }))}>
|
||
<option value="">All Leads</option>
|
||
{teamMembers.map((m) => <option key={m} value={m}>{m}</option>)}
|
||
</select>
|
||
<button onClick={addRow}>+ Investor</button>
|
||
<button className="button-secondary" onClick={() => setShowViewModal(true)}>+ Save View</button>
|
||
<button className="button-secondary" onClick={toggleRowDensity}>{rowDensity === 'compact' ? 'Expand Rows' : 'Compact Rows'}</button>
|
||
<button className="button-secondary" onClick={() => setShowColumnVisibilityModal(true)}>Columns</button>
|
||
<button className="button-secondary" onClick={() => setShowFilterModal(true)}>Filters</button>
|
||
<button className="button-secondary" onClick={() => openAddColumnModal(null)}>+ Column</button>
|
||
<button className="button-secondary" onClick={() => setImportDebugOpen((v) => !v)}>{importDebugOpen ? 'Hide Debug' : 'Show Debug'}</button>
|
||
</div>
|
||
{importDebugOpen && (
|
||
<div style={{ margin: '6px 0 10px', border: '1px solid #34506a', borderRadius: '8px', padding: '10px', background: '#0e1725' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||
<strong style={{ fontSize: '12px' }}>Import Debug</strong>
|
||
<div style={{ display: 'flex', gap: '8px' }}>
|
||
<button type="button" className="button-secondary" onClick={() => loadImportDebugSnapshot()}>Refresh Server Snapshot</button>
|
||
<button type="button" className="button-secondary" onClick={() => setImportDebugEvents([])}>Clear Log</button>
|
||
</div>
|
||
</div>
|
||
<div style={{ fontSize: '12px', color: '#c7d3e0', marginBottom: '8px' }}>
|
||
Local rows: {rows.length} · Displayed rows: {displayedRows.length} · Active view: {activeView} · Remote version: {Number.isFinite(remoteVersion) ? remoteVersion : 'n/a'} · Hydrated: {stateHydrated ? 'yes' : 'no'}
|
||
</div>
|
||
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '8px' }}>
|
||
Filters: includeGraveyard={String(!!filters.includeGraveyard)} graveyardOnly={String(!!filters.graveyardOnly)} followUpOnly={String(!!filters.followUpOnly)} lead={filters.lead || '-'} quickSearch="{quickSearch}"
|
||
</div>
|
||
{importDebugServerSnapshot && (
|
||
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '8px' }}>
|
||
Server snapshot ({importDebugServerSnapshot.fetched_at}): grid rows={importDebugServerSnapshot.server_rows_count}, relational investors={importDebugServerSnapshot.relational_investors_count}, version={importDebugServerSnapshot.server_version}
|
||
</div>
|
||
)}
|
||
<div style={{ maxHeight: '170px', overflowY: 'auto', borderTop: '1px solid #263548', paddingTop: '8px', fontSize: '11px', color: '#b7c6d8' }}>
|
||
{importDebugEvents.length === 0 && <div>No events yet.</div>}
|
||
{importDebugEvents.map((evt, idx) => (
|
||
<div key={`${evt.ts}-${idx}`} style={{ marginBottom: '4px', whiteSpace: 'pre-wrap' }}>
|
||
[{evt.ts}] {evt.kind}: {JSON.stringify(evt.payload)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{activeCollaborators.length > 0 && (
|
||
<div style={{ margin: '4px 0 10px', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||
{activeCollaborators.map((p) => {
|
||
const rowLabel = rows.find((r) => r.id === p.row_id)?.investor_name || '';
|
||
const colLabel = columns.find((c) => c.id === p.col_id)?.label || '';
|
||
const name = p.full_name || p.username || 'User';
|
||
const detail = p.is_editing ? `editing ${rowLabel || 'row'} ${colLabel ? `• ${colLabel}` : ''}` : 'viewing';
|
||
return (
|
||
<span key={`${p.user_id}-${p.last_seen_at}`} className="badge badge-other" title={detail}>
|
||
{name}: {detail}
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid-table-shell" onContextMenu={(e) => openGridContextMenu(e, { area: 'table' })}>
|
||
<table ref={gridTableRef} className={`table fundraising-table ${rowDensity === 'compact' ? 'compact-rows' : ''}`} style={{ minWidth: '1600px' }}>
|
||
<thead>
|
||
<tr>
|
||
{visibleColumns.map((col, colIndex) => (
|
||
<th
|
||
key={col.id}
|
||
className={`${draggingColId === col.id ? 'column-dragging' : ''} ${dragOverColId === col.id ? 'column-drag-over' : ''} ${col.id === 'investor_name' ? 'sticky-col' : ''}`.trim()}
|
||
draggable={resizing === null && col.id !== 'investor_name'}
|
||
onDragStart={(e) => {
|
||
if (resizing !== null) return;
|
||
if (col.id === 'investor_name') return;
|
||
setDraggingColId(col.id);
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
e.dataTransfer.setData('text/plain', col.id);
|
||
}}
|
||
onDragOver={(e) => {
|
||
e.preventDefault();
|
||
if (col.id === 'investor_name') return;
|
||
if (!draggingColId || draggingColId === col.id) return;
|
||
setDragOverColId(col.id);
|
||
e.dataTransfer.dropEffect = 'move';
|
||
}}
|
||
onDrop={(e) => {
|
||
e.preventDefault();
|
||
if (col.id === 'investor_name') return;
|
||
const droppedColId = e.dataTransfer.getData('text/plain') || draggingColId;
|
||
moveColumnBefore(droppedColId, col.id);
|
||
setDragOverColId(null);
|
||
setDraggingColId(null);
|
||
}}
|
||
onDragEnd={() => {
|
||
setDragOverColId(null);
|
||
setDraggingColId(null);
|
||
}}
|
||
onContextMenu={(e) => openGridContextMenu(e, { area: 'header', colId: col.id, colIndex })}
|
||
onClick={() => {
|
||
if (suppressNextHeaderSortRef.current) {
|
||
return;
|
||
}
|
||
if (resizing !== null) return;
|
||
setSortState((prev) => {
|
||
if (prev.colId !== col.id) return { colId: col.id, dir: 'asc' };
|
||
return { colId: col.id, dir: prev.dir === 'asc' ? 'desc' : 'asc' };
|
||
});
|
||
}}
|
||
style={{ minWidth: `${col.width || 140}px`, width: `${col.width || 140}px` }}
|
||
>
|
||
<div className="column-header-inner">
|
||
<span className="column-drag-indicator" title="Drag to reorder">⋮⋮</span>
|
||
{col.label}
|
||
{sortState.colId === col.id && (
|
||
<span style={{ marginLeft: '6px', fontSize: '11px', color: '#8ea2b7' }}>
|
||
{sortState.dir === 'asc' ? '▲' : '▼'}
|
||
</span>
|
||
)}
|
||
<span className="column-resize-handle" onMouseDown={(e) => startColumnResize(e, col)} />
|
||
</div>
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{displayedRows.map((row, rowIndex) => (
|
||
<tr key={row.id}>
|
||
{visibleColumns.map((col, colIndex) => {
|
||
const isEditing = editing && editing.rowId === row.id && editing.colId === col.id;
|
||
const isSelected = selectedCell && selectedCell.rowId === row.id && selectedCell.colId === col.id;
|
||
const activeLock = getCellLockByOther(row.id, col.id);
|
||
const isLockedByOther = !!activeLock;
|
||
const lockTitle = isLockedByOther
|
||
? `Locked by ${activeLock.locked_by_full_name || activeLock.locked_by_username || 'another user'}`
|
||
: '';
|
||
return (
|
||
<td
|
||
key={col.id}
|
||
tabIndex={cellTabIndex(rowIndex, colIndex)}
|
||
data-cell-key={`r${rowIndex}-c${colIndex}`}
|
||
className={`${isSelected ? 'grid-cell-selected' : ''} ${col.id === 'investor_name' ? 'sticky-col' : ''}`.trim()}
|
||
title={lockTitle}
|
||
onContextMenu={(e) => openGridContextMenu(e, { area: 'cell', rowId: row.id, colId: col.id, colIndex })}
|
||
onClick={() => {
|
||
setSelectedCell({ rowId: row.id, colId: col.id });
|
||
if (col.type === 'checkbox' && !col.readOnly) {
|
||
if (isLockedByOther) {
|
||
onShowToast(lockTitle, 'error');
|
||
return;
|
||
}
|
||
updateCell(row.id, col.id, !Boolean(row[col.id]));
|
||
}
|
||
}}
|
||
onDoubleClick={async () => {
|
||
if (isLockedByOther) {
|
||
onShowToast(lockTitle, 'error');
|
||
return;
|
||
}
|
||
await tryBeginCellEdit(row, col);
|
||
}}
|
||
onKeyDown={async (e) => {
|
||
if (isEditing) return;
|
||
if (col.type === 'action' && (e.key === 'Enter' || e.key === ' ')) {
|
||
e.preventDefault();
|
||
openLogCommunicationModal(row, null);
|
||
return;
|
||
}
|
||
if (e.key === 'F2' && !col.readOnly) {
|
||
e.preventDefault();
|
||
if (isLockedByOther) {
|
||
onShowToast(lockTitle, 'error');
|
||
return;
|
||
}
|
||
await tryBeginCellEdit(row, col);
|
||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||
e.preventDefault();
|
||
setSelectedCell({ rowId: row.id, colId: col.id });
|
||
moveByKey(rowIndex, colIndex, e.key, e.shiftKey);
|
||
}
|
||
}}
|
||
style={{
|
||
verticalAlign: 'top',
|
||
cursor: col.readOnly ? 'default' : (isLockedByOther ? 'not-allowed' : 'cell'),
|
||
boxShadow: isLockedByOther ? 'inset 0 0 0 1px #8f4b4b' : undefined,
|
||
background: isLockedByOther ? 'rgba(143, 75, 75, 0.12)' : undefined,
|
||
minWidth: `${col.width || 140}px`,
|
||
width: `${col.width || 140}px`
|
||
}}
|
||
>
|
||
{isEditing ? renderEditor(row, col, rowIndex, colIndex) : (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '6px' }}>
|
||
<span style={{ flex: 1, minWidth: 0 }}>{renderCellValue(row, col)}</span>
|
||
{isLockedByOther && <span style={{ fontSize: '10px', color: '#d38f8f' }}>LOCKED</span>}
|
||
</div>
|
||
)}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr>
|
||
{visibleColumns.map((col, colIndex) => {
|
||
const numeric = isNumericColumn(col);
|
||
const agg = numeric ? getAggregateForColumn(col) : null;
|
||
const footerValue = numeric ? formatAggregateValue(col, agg.value) : '';
|
||
return (
|
||
<td
|
||
key={`footer-${col.id}`}
|
||
className={col.id === 'investor_name' ? 'sticky-col' : ''}
|
||
style={{ verticalAlign: 'top', minWidth: `${col.width || 140}px`, width: `${col.width || 140}px` }}
|
||
>
|
||
{colIndex === 0 ? (
|
||
<span style={{ color: '#8ea2b7', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Summary</span>
|
||
) : null}
|
||
{numeric && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||
<div style={{ fontSize: '12px', color: '#c7d3e0', minHeight: '16px' }}>
|
||
{agg.op === 'none' ? '' : `${agg.label} ${footerValue}`}
|
||
</div>
|
||
<select
|
||
className="select-input"
|
||
style={{ fontSize: '11px', minWidth: '110px', padding: '4px 6px', height: '28px' }}
|
||
value={agg.op}
|
||
onChange={(e) => {
|
||
const op = e.target.value;
|
||
setFooterAggs((prev) => ({ ...prev, [col.id]: op }));
|
||
setViews((prev) => prev.map((v) => (v.id === activeView ? { ...v, footerAggs: { ...(v.footerAggs || {}), [col.id]: op } } : v)));
|
||
}}
|
||
>
|
||
{FOOTER_AGGREGATE_OPTIONS.map((opt) => (
|
||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
{displayedRows.length === 0 && rows.length > 0 && (
|
||
<div style={{ marginTop: '10px', fontSize: '12px', color: '#8ea2b7', display: 'flex', gap: '10px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<span>{rows.length} row(s) exist but are hidden by current view/search/filter settings.</span>
|
||
<button
|
||
type="button"
|
||
className="button-secondary"
|
||
onClick={() => {
|
||
setActiveView('view-all');
|
||
setFilters({ includeGraveyard: true, graveyardOnly: false, followUpOnly: false, lead: '' });
|
||
setQuickSearch('');
|
||
setColumnFilters([]);
|
||
setHiddenColumns([]);
|
||
}}
|
||
>
|
||
Show All Rows
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{contextMenu && (
|
||
<div className="context-menu" style={{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }} onClick={(e) => e.stopPropagation()}>
|
||
<button className="context-menu-item" onClick={() => handleContextAction('add-investor-top')}>Add Investor Row</button>
|
||
{contextMenu.rowId && (
|
||
<button className="context-menu-item" onClick={() => handleContextAction('add-investor-below')}>Add Investor Below</button>
|
||
)}
|
||
{contextMenu.rowId && (
|
||
<button className="context-menu-item" onClick={() => handleContextAction('log-communication')}>Log Communication</button>
|
||
)}
|
||
{contextMenu.rowId && (
|
||
<button className="context-menu-item" onClick={() => handleContextAction('create-opportunity')}>Create Pipeline Opportunity</button>
|
||
)}
|
||
{contextMenu.rowId && (
|
||
<button className="context-menu-item context-menu-danger" onClick={() => handleContextAction('delete-row')}>Delete Investor Row</button>
|
||
)}
|
||
<div className="context-menu-sep" />
|
||
{typeof contextMenu.colIndex === 'number' && (
|
||
<>
|
||
<button className="context-menu-item" onClick={() => handleContextAction('insert-col-left')}>Insert Column Left</button>
|
||
<button className="context-menu-item" onClick={() => handleContextAction('insert-col-right')}>Insert Column Right</button>
|
||
<button className="context-menu-item" onClick={() => handleContextAction('edit-column')}>Column Settings</button>
|
||
</>
|
||
)}
|
||
<button className="context-menu-item" onClick={() => handleContextAction('add-column-end')}>Add Column</button>
|
||
<button className="context-menu-item" onClick={() => handleContextAction('manage-columns')}>Show / Hide Columns</button>
|
||
<button className="context-menu-item" onClick={() => handleContextAction('add-filter')}>Add Filter Rule</button>
|
||
<div className="context-menu-sep" />
|
||
<button className="context-menu-item" onClick={() => handleContextAction('save-view')}>Save Current View</button>
|
||
</div>
|
||
)}
|
||
|
||
{(columnFilters.length > 0 || hiddenColumns.length > 0) && (
|
||
<div className="section" style={{ marginTop: '12px' }}>
|
||
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
||
{hiddenColumns.length > 0 && (
|
||
<span className="badge badge-other">Hidden columns: {hiddenColumns.length}</span>
|
||
)}
|
||
{columnFilters.map((rule) => {
|
||
const col = getColumnById(rule.colId);
|
||
if (!col) return null;
|
||
return (
|
||
<span key={rule.id} className="badge badge-prospect" style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
|
||
{col.label} {rule.op} {rule.value || ''}
|
||
<button
|
||
type="button"
|
||
style={{ border: 'none', background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
|
||
onClick={() => setColumnFilters((prev) => prev.filter((r) => r.id !== rule.id))}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{showColumnVisibilityModal && (
|
||
<div className="modal-overlay">
|
||
<div className="modal">
|
||
<div className="modal-header">Show / Hide Columns</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', maxHeight: '55vh', overflowY: 'auto' }}>
|
||
{columns.map((col) => (
|
||
<label key={col.id} className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px', justifyContent: 'space-between' }}>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={!hiddenColumns.includes(col.id)}
|
||
onChange={(e) => {
|
||
const checked = e.target.checked;
|
||
setHiddenColumns((prev) => {
|
||
if (checked) return prev.filter((id) => id !== col.id);
|
||
if (col.id === 'investor_name') return prev;
|
||
return [...prev, col.id];
|
||
});
|
||
}}
|
||
/>
|
||
{col.label}
|
||
{col.id === 'investor_name' && <span style={{ color: '#70859b', fontSize: '12px' }}>(always visible)</span>}
|
||
</span>
|
||
<button type="button" className="button-secondary" style={{ padding: '4px 8px', fontSize: '11px' }} onClick={() => openEditColumnModal(col.id)}>Edit</button>
|
||
</label>
|
||
))}
|
||
</div>
|
||
<div className="form-actions">
|
||
<button type="button" className="button-secondary" onClick={() => setHiddenColumns([])}>Show all</button>
|
||
<button type="button" onClick={() => setShowColumnVisibilityModal(false)}>Done</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{showFilterModal && (
|
||
<div className="modal-overlay">
|
||
<div className="modal">
|
||
<div className="modal-header">Add Filter Rule</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Column</label>
|
||
<select
|
||
className="select-input"
|
||
value={newRule.colId}
|
||
onChange={(e) => {
|
||
const colId = e.target.value;
|
||
const ops = getRuleOpsForColumn(getColumnById(colId));
|
||
setNewRule({ colId, op: ops[0]?.value || 'contains', value: '' });
|
||
}}
|
||
>
|
||
<option value="">Select column</option>
|
||
{columns.map((col) => <option key={col.id} value={col.id}>{col.label}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Condition</label>
|
||
<select
|
||
className="select-input"
|
||
value={newRule.op}
|
||
onChange={(e) => setNewRule((prev) => ({ ...prev, op: e.target.value }))}
|
||
>
|
||
{getRuleOpsForColumn(getColumnById(newRule.colId)).map((op) => (
|
||
<option key={op.value} value={op.value}>{op.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
{!['is_true', 'is_false'].includes(newRule.op) && (
|
||
<div className="form-group">
|
||
<label className="form-label">Value</label>
|
||
<input
|
||
className="text-input"
|
||
type={getColumnById(newRule.colId)?.type === 'date' ? 'date' : 'text'}
|
||
value={newRule.value}
|
||
onChange={(e) => setNewRule((prev) => ({ ...prev, value: e.target.value }))}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="form-actions">
|
||
<button type="button" className="button-secondary" onClick={() => setShowFilterModal(false)}>Cancel</button>
|
||
<button type="button" onClick={addFilterRule}>Add Rule</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{showColumnModal && (
|
||
<div className="modal-overlay">
|
||
<div className="modal">
|
||
<div className="modal-header">{columnModalMode === 'edit' ? 'Column Settings' : 'Add New Column'}</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Column name</label>
|
||
<input className="text-input" value={newColumn.label} onChange={(e) => setNewColumn((c) => ({ ...c, label: e.target.value }))} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Data type</label>
|
||
<select
|
||
className="select-input"
|
||
value={newColumn.type}
|
||
disabled={editingColumnId === 'total_invested'}
|
||
onChange={(e) => setNewColumn((c) => ({ ...c, type: e.target.value }))}
|
||
>
|
||
<option value="text">Single line text</option>
|
||
<option value="longtext">Long text</option>
|
||
<option value="number">Number</option>
|
||
<option value="currency">Currency</option>
|
||
<option value="rollup">Rollup (computed)</option>
|
||
<option value="date">Date</option>
|
||
<option value="checkbox">Checkbox</option>
|
||
<option value="select">Single select</option>
|
||
<option value="formula">Formula</option>
|
||
</select>
|
||
<div style={{ fontSize: '12px', color: '#8ea2b7', marginTop: '6px' }}>
|
||
{newColumn.type === 'text' && 'Short text values (names, sources, labels).'}
|
||
{newColumn.type === 'longtext' && 'Multi-line notes or communication history.'}
|
||
{newColumn.type === 'number' && 'Numeric values (no currency formatting).'}
|
||
{newColumn.type === 'currency' && 'Currency values; optional inclusion in Total invested rollup.'}
|
||
{(newColumn.type === 'rollup' || editingColumnId === 'total_invested') && 'Rollup column computed from other numeric columns.'}
|
||
{newColumn.type === 'date' && 'Calendar date values.'}
|
||
{newColumn.type === 'checkbox' && 'True/false flag for quick filtering and views.'}
|
||
{newColumn.type === 'select' && 'One option from a custom list.'}
|
||
{newColumn.type === 'formula' && 'Read-only computed values. Use {Column Name} or {column_id} with IF, AND, OR, NOT, MIN, MAX, ABS, ROUND, SUM, COALESCE, CONCAT.'}
|
||
</div>
|
||
</div>
|
||
{newColumn.type === 'select' && (
|
||
<div className="form-group">
|
||
<label className="form-label">Options (comma separated)</label>
|
||
<input className="text-input" value={newColumn.options} onChange={(e) => setNewColumn((c) => ({ ...c, options: e.target.value }))} />
|
||
</div>
|
||
)}
|
||
{(newColumn.type === 'formula' || editingColumnId === 'total_invested') && (
|
||
<div className="form-group">
|
||
<label className="form-label">Formula</label>
|
||
<textarea
|
||
className="text-input"
|
||
rows="4"
|
||
value={newColumn.formula}
|
||
onChange={(e) => setNewColumn((c) => ({ ...c, formula: e.target.value }))}
|
||
placeholder={'Examples:\n{Fund I} + {Fund II}\nIF({Priority}, "Hot", "Normal")\nIF(AND({Follow up}, !{Graveyard}), "Act", "Wait")'}
|
||
/>
|
||
<div style={{ fontSize: '12px', color: '#8ea2b7' }}>
|
||
Supported: `{`field`}`, `IF(condition,a,b)`, `AND(...)`, `OR(...)`, `+ - * /`, `&` for text concat.
|
||
</div>
|
||
</div>
|
||
)}
|
||
{newColumn.type === 'currency' && editingColumnId !== 'total_invested' && (
|
||
<div className="form-group">
|
||
<label className="form-label"><input type="checkbox" checked={newColumn.isFund} onChange={(e) => setNewColumn((c) => ({ ...c, isFund: e.target.checked }))} /> Include in Total invested rollup</label>
|
||
</div>
|
||
)}
|
||
<div className="form-actions">
|
||
<button type="button" className="button-secondary" onClick={() => { setShowColumnModal(false); setColumnModalMode('add'); setEditingColumnId(null); }}>Cancel</button>
|
||
<button type="button" onClick={saveColumnModal}>{columnModalMode === 'edit' ? 'Save Changes' : 'Add Column'}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{showViewModal && (
|
||
<div className="modal-overlay">
|
||
<div className="modal">
|
||
<div className="modal-header">Save Current Filters as View</div>
|
||
<div className="form-group">
|
||
<label className="form-label">View name</label>
|
||
<input className="text-input" value={newViewName} onChange={(e) => setNewViewName(e.target.value)} />
|
||
</div>
|
||
<div className="form-actions">
|
||
<button type="button" className="button-secondary" onClick={() => setShowViewModal(false)}>Cancel</button>
|
||
<button type="button" onClick={saveCurrentFiltersAsView}>Save View</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{showLogCommModal && (
|
||
<div className="modal-overlay">
|
||
<div className="modal">
|
||
<div className="modal-header">Log Communication</div>
|
||
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '12px' }}>
|
||
{logCommContext?.investorName || 'Investor'} {logCommContext?.contact?.name ? `· ${logCommContext.contact.name}` : ''}
|
||
</div>
|
||
<div className="form-help" style={{ marginBottom: '14px', padding: '10px', border: '1px solid #263548', borderRadius: '8px', background: '#0d1622' }}>
|
||
This saves a communication record to the shared timeline and updates this investor row. If enabled below, it also appends a one-line summary into <strong>Notes / Communication / Outreach</strong>.
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Type</label>
|
||
<select className="select-input" value={logCommForm.type} onChange={(e) => setLogCommForm((f) => ({ ...f, type: e.target.value }))}>
|
||
<option value="email">Email</option>
|
||
<option value="call">Call</option>
|
||
<option value="meeting">Meeting</option>
|
||
<option value="note">Note</option>
|
||
<option value="text">Text</option>
|
||
</select>
|
||
<div className="form-help">Category used in Communications timeline and filters.</div>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Summary</label>
|
||
<input className="text-input" placeholder="Short summary (headline)" value={logCommForm.subject} onChange={(e) => setLogCommForm((f) => ({ ...f, subject: e.target.value }))} />
|
||
<div className="form-help">Headline summary shown in Communications. This is not an email subject line.</div>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Details</label>
|
||
<textarea className="text-input" rows="4" placeholder="Full details and context" value={logCommForm.body} onChange={(e) => setLogCommForm((f) => ({ ...f, body: e.target.value }))} />
|
||
<div className="form-help">Long-form details kept in communications history.</div>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Outcome</label>
|
||
<input className="text-input" value={logCommForm.outcome} onChange={(e) => setLogCommForm((f) => ({ ...f, outcome: e.target.value }))} />
|
||
<div className="form-help">Result or status after this interaction.</div>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Next Action</label>
|
||
<input className="text-input" value={logCommForm.next_action} onChange={(e) => setLogCommForm((f) => ({ ...f, next_action: e.target.value }))} />
|
||
<div className="form-help">Specific follow-up task.</div>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Next Action Date</label>
|
||
<input type="date" className="text-input" value={logCommForm.next_action_date} onChange={(e) => setLogCommForm((f) => ({ ...f, next_action_date: e.target.value }))} />
|
||
<div className="form-help">Target due date for the next step.</div>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">
|
||
<input type="checkbox" checked={!!logCommForm.append_note} onChange={(e) => setLogCommForm((f) => ({ ...f, append_note: e.target.checked }))} />
|
||
{' '}Append summary to Fundraising Grid notes
|
||
</label>
|
||
<div className="form-help">Adds one line to <strong>Notes / Communication / Outreach</strong>: date + type + contact + summary.</div>
|
||
</div>
|
||
<div className="form-actions">
|
||
<button type="button" className="button-secondary" onClick={() => setShowLogCommModal(false)}>Cancel</button>
|
||
<button type="button" onClick={submitLogCommunication} disabled={logCommSubmitting}>
|
||
{logCommSubmitting ? <Spinner /> : 'Log'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{showContactCardModal && (
|
||
<div className="modal-overlay">
|
||
<div className="modal">
|
||
<div className="modal-header">Contact Details</div>
|
||
{contactCardLoading ? (
|
||
<div style={{ padding: '4px 0 12px' }}><Spinner /></div>
|
||
) : (
|
||
<>
|
||
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '12px' }}>
|
||
{contactCardContext?.investorName || 'Investor'}
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Name</span>
|
||
<span className="detail-value">{contactCardContext?.contact?.name || '-'}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Email</span>
|
||
<span className="detail-value">{contactCardContext?.contact?.email || '-'}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Title</span>
|
||
<span className="detail-value">{contactCardContext?.contact?.title || '-'}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">City</span>
|
||
<span className="detail-value">{contactCardContext?.contact?.city || '-'}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">State</span>
|
||
<span className="detail-value">{contactCardContext?.contact?.state || '-'}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Country</span>
|
||
<span className="detail-value">{contactCardContext?.contact?.country || '-'}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Lead Source</span>
|
||
<span className="detail-value">{contactCardContext?.leadSource || '-'}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">LinkedIn</span>
|
||
<span className="detail-value">{contactCardContext?.contact?.linkedin_url || '-'}</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
<div className="form-actions">
|
||
<button type="button" className="button-secondary" onClick={() => setShowContactCardModal(false)}>Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{showCreateOppModal && (
|
||
<div className="modal-overlay">
|
||
<div className="modal">
|
||
<div className="modal-header">Add to Pipeline</div>
|
||
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '12px' }}>
|
||
{createOppContext?.investorName || 'Investor'}
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Opportunity Name</label>
|
||
<input className="text-input" value={createOppForm.name} onChange={(e) => setCreateOppForm((f) => ({ ...f, name: e.target.value }))} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Contact</label>
|
||
<select
|
||
className="select-input"
|
||
value={String(createOppForm.contactIndex)}
|
||
onChange={(e) => setCreateOppForm((f) => ({ ...f, contactIndex: Number(e.target.value) || 0 }))}
|
||
>
|
||
{(createOppContext?.contacts || []).map((c, i) => (
|
||
<option key={`${c?.name || 'contact'}-${i}`} value={String(i)}>
|
||
{c?.name || 'Unnamed'}{c?.email ? ` (${c.email})` : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Stage</label>
|
||
<select className="select-input" value={createOppForm.stage} onChange={(e) => setCreateOppForm((f) => ({ ...f, stage: e.target.value }))}>
|
||
<option value="lead">Lead</option>
|
||
<option value="outreach">Outreach</option>
|
||
<option value="meeting">Meeting</option>
|
||
<option value="due_diligence">Due Diligence</option>
|
||
<option value="committed">Committed</option>
|
||
<option value="funded">Funded</option>
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Expected Amount</label>
|
||
<input className="text-input" inputMode="decimal" value={createOppForm.expected_amount} onChange={(e) => setCreateOppForm((f) => ({ ...f, expected_amount: e.target.value }))} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Probability (%)</label>
|
||
<input type="number" min="0" max="100" className="text-input" value={createOppForm.probability} onChange={(e) => setCreateOppForm((f) => ({ ...f, probability: e.target.value }))} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Fund (optional)</label>
|
||
<select className="select-input" value={createOppForm.fund_name} onChange={(e) => setCreateOppForm((f) => ({ ...f, fund_name: e.target.value }))}>
|
||
<option value="">— None —</option>
|
||
{columns.filter((c) => c.isFund).map((c) => (
|
||
<option key={c.id} value={c.label}>{c.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="form-actions">
|
||
<button type="button" className="button-secondary" onClick={() => setShowCreateOppModal(false)}>Cancel</button>
|
||
<button type="button" onClick={submitCreateOpportunity} disabled={createOppSubmitting}>
|
||
{createOppSubmitting ? <Spinner /> : 'Add to Pipeline'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{showImportModal && (
|
||
<div className="modal-overlay">
|
||
<div className="modal" style={{ maxWidth: '760px' }}>
|
||
<div className="modal-header">Import Airtable CSV</div>
|
||
{importError && <div className="toast error" style={{ position: 'static', marginBottom: '12px' }}>{importError}</div>}
|
||
<div className="form-group">
|
||
<label className="form-label">Upload CSV export file</label>
|
||
<input type="file" accept=".csv,text/csv" onChange={handleCsvFileUpload} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Or paste CSV data</label>
|
||
<textarea
|
||
className="text-input"
|
||
rows="10"
|
||
value={importCsvText}
|
||
onChange={(e) => setImportCsvText(e.target.value)}
|
||
placeholder={'Investor Name,Contacts,Notes / Communication / Outreach,Priority,Follow up,Lead,Graveyard,Fund I,Fund II\nExample Investor,"Jane Doe, John Doe","Met on 3/1",true,false,JK,false,1000000,0'}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">
|
||
<input
|
||
type="checkbox"
|
||
checked={createMissingColumns}
|
||
onChange={(e) => setCreateMissingColumns(e.target.checked)}
|
||
/>
|
||
{' '}Create missing columns automatically from CSV headers
|
||
</label>
|
||
</div>
|
||
<div className="form-actions">
|
||
<button type="button" className="button-secondary" onClick={() => { setShowImportModal(false); setImportError(''); }}>Cancel</button>
|
||
<button type="button" onClick={handleImportAirtableCsv}>Import</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const InstructionsPage = () => {
|
||
return (
|
||
<div className="page-container">
|
||
<h2 className="section-title">Instructions</h2>
|
||
|
||
<div className="section">
|
||
<div className="section-title">Purpose</div>
|
||
<div style={{ color: '#c7d3e0', fontSize: '14px', lineHeight: 1.6 }}>
|
||
Use Fundraising Grid as the master list of investor relationships, then use Contacts, Communications, and Pipeline as deeper operating layers when a relationship becomes active.
|
||
</div>
|
||
</div>
|
||
|
||
<div className="section">
|
||
<div className="section-title">Daily Workflow</div>
|
||
<ol style={{ marginLeft: '20px', color: '#c7d3e0', lineHeight: 1.8 }}>
|
||
<li>Capture new leads in Fundraising Grid first.</li>
|
||
<li>Add or verify contacts on that row (name, email, title, location).</li>
|
||
<li>Set Lead owner and relevant flags (Priority, Follow up).</li>
|
||
<li>Log communications after each meaningful touchpoint.</li>
|
||
<li>Use Next Action and Next Action Date for commitments and reminders.</li>
|
||
</ol>
|
||
</div>
|
||
|
||
<div className="section">
|
||
<div className="section-title">How To Add New Leads</div>
|
||
<ol style={{ marginLeft: '20px', color: '#c7d3e0', lineHeight: 1.8 }}>
|
||
<li>Create a new row in Fundraising Grid.</li>
|
||
<li>Fill Investor Name and at least one contact.</li>
|
||
<li>Add context in Notes / Communication / Outreach.</li>
|
||
<li>Assign Lead and mark Priority only if truly high-attention.</li>
|
||
<li>Use Follow up for active near-term tracking views.</li>
|
||
</ol>
|
||
</div>
|
||
|
||
<div className="section">
|
||
<div className="section-title">Communication Logging Best Practices</div>
|
||
<ol style={{ marginLeft: '20px', color: '#c7d3e0', lineHeight: 1.8 }}>
|
||
<li>Log communication from Fundraising Grid via contact chip or row right-click.</li>
|
||
<li>Always set type and a concise subject/body.</li>
|
||
<li>Use Outcome for what happened; use Next Action for what will happen.</li>
|
||
<li>If you want timeline text in grid notes, keep “Append note” checked.</li>
|
||
<li>Use Communications page to audit and manage all logged interactions.</li>
|
||
</ol>
|
||
</div>
|
||
|
||
<div className="section">
|
||
<div className="section-title">Priority vs Pipeline</div>
|
||
<div style={{ color: '#c7d3e0', fontSize: '14px', lineHeight: 1.7 }}>
|
||
Priority is a relationship-level attention flag in the Fundraising Grid. Pipeline is for specific active opportunities with stage/probability/amount tracking. Keep Priority broad and Pipeline selective.
|
||
</div>
|
||
</div>
|
||
|
||
<div className="section">
|
||
<div className="section-title">When An Opportunity Is Concrete</div>
|
||
<ol style={{ marginLeft: '20px', color: '#c7d3e0', lineHeight: 1.8 }}>
|
||
<li>Right-click the investor row in Fundraising Grid.</li>
|
||
<li>Select <strong>Create Pipeline Opportunity</strong>.</li>
|
||
<li>Pick contact, stage, expected amount, and probability.</li>
|
||
<li>Track progress in Pipeline while keeping relationship notes in Fundraising Grid.</li>
|
||
<li>Continue logging communications so follow-ups and timelines stay current.</li>
|
||
</ol>
|
||
</div>
|
||
|
||
<div className="section">
|
||
<div className="section-title">Data Flow</div>
|
||
<ol style={{ marginLeft: '20px', color: '#c7d3e0', lineHeight: 1.8 }}>
|
||
<li>Fundraising Grid saves to a master fundraising state and relational fundraising tables.</li>
|
||
<li>Contacts in Fundraising Grid sync bi-directionally with the Contacts database.</li>
|
||
<li>Logging communication creates a communications record and updates fundraising row dates.</li>
|
||
<li>Notes Last Modified and Last Communication Date update automatically from activity.</li>
|
||
<li>Saved Views filter the same shared master dataset, they do not duplicate records.</li>
|
||
</ol>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const SettingsPage = ({ token, onShowToast, user, onOpenAirtableImport }) => {
|
||
const [loading, setLoading] = useState(true);
|
||
const [inviteForm, setInviteForm] = useState({
|
||
username: '',
|
||
full_name: '',
|
||
email: '',
|
||
password: '',
|
||
role: 'member'
|
||
});
|
||
const [inviteLoading, setInviteLoading] = useState(false);
|
||
const [backupLoading, setBackupLoading] = useState(false);
|
||
const [exportLoading, setExportLoading] = useState(false);
|
||
const [restoreLoading, setRestoreLoading] = useState(false);
|
||
const [previewLoading, setPreviewLoading] = useState(false);
|
||
const [lastBackupInfo, setLastBackupInfo] = useState(null);
|
||
const [restorePayload, setRestorePayload] = useState(null);
|
||
const [restoreFileName, setRestoreFileName] = useState('');
|
||
const [restorePreview, setRestorePreview] = useState(null);
|
||
const [backupHistory, setBackupHistory] = useState([]);
|
||
const [backupHistoryLoading, setBackupHistoryLoading] = useState(false);
|
||
const [users, setUsers] = useState([]);
|
||
const [usersLoading, setUsersLoading] = useState(false);
|
||
const [userActionLoadingId, setUserActionLoadingId] = useState(null);
|
||
const [testEmailLoading, setTestEmailLoading] = useState(false);
|
||
const [sendDigestLoading, setSendDigestLoading] = useState(false);
|
||
const [digestPolicy, setDigestPolicy] = useState({ enabled: false, send_hour: 18 });
|
||
const [digestPolicyLoading, setDigestPolicyLoading] = useState(false);
|
||
const [digestPolicySaving, setDigestPolicySaving] = useState(false);
|
||
// Manual run: window selector + in-panel preview before pushing to email.
|
||
const [digestWindowMode, setDigestWindowMode] = useState('24h'); // '24h' | 'since'
|
||
const [digestSince, setDigestSince] = useState(() =>
|
||
new Date(Date.now() - 30 * 864e5).toISOString().slice(0, 10));
|
||
const [digestPreview, setDigestPreview] = useState(null);
|
||
const [digestPreviewLoading, setDigestPreviewLoading] = useState(false);
|
||
const [auditLogs, setAuditLogs] = useState([]);
|
||
const [auditLoading, setAuditLoading] = useState(false);
|
||
const [automationRules, setAutomationRules] = useState([]);
|
||
const [automationRuns, setAutomationRuns] = useState([]);
|
||
const [automationsLoading, setAutomationsLoading] = useState(false);
|
||
const [activityFeed, setActivityFeed] = useState([]);
|
||
const [activityLoading, setActivityLoading] = useState(false);
|
||
const [backupVerifyLoading, setBackupVerifyLoading] = useState(false);
|
||
const [backupVerifyResult, setBackupVerifyResult] = useState(null);
|
||
const [securityStatus, setSecurityStatus] = useState(null);
|
||
const [securityLoading, setSecurityLoading] = useState(false);
|
||
const [auditFilters, setAuditFilters] = useState({
|
||
entity_type: '',
|
||
action: '',
|
||
user_id: '',
|
||
date_from: '',
|
||
date_to: '',
|
||
search: '',
|
||
limit: 100
|
||
});
|
||
const [backupPolicy, setBackupPolicy] = useState(null);
|
||
const [backupPolicyDraft, setBackupPolicyDraft] = useState({
|
||
enabled: true,
|
||
interval_hours: 24,
|
||
retention_days: 30,
|
||
max_backups: 60
|
||
});
|
||
const [backupPolicyLoading, setBackupPolicyLoading] = useState(false);
|
||
const [contactsCsvText, setContactsCsvText] = useState('');
|
||
const [contactsImportLoading, setContactsImportLoading] = useState(false);
|
||
const [contactsImportError, setContactsImportError] = useState('');
|
||
const [contactsUpdateExisting, setContactsUpdateExisting] = useState(true);
|
||
const [contactsImportPreview, setContactsImportPreview] = useState(null);
|
||
const [contactsImportOverrides, setContactsImportOverrides] = useState({});
|
||
const [resetAllDataConfirm, setResetAllDataConfirm] = useState('');
|
||
const [resetAllDataLoading, setResetAllDataLoading] = useState(false);
|
||
|
||
useEffect(() => {
|
||
setLoading(false);
|
||
}, [token, onShowToast]);
|
||
|
||
useEffect(() => {
|
||
if (user?.role !== 'admin') return;
|
||
const fetchUsers = async () => {
|
||
setUsersLoading(true);
|
||
try {
|
||
const result = await api('/api/users', {}, token);
|
||
setUsers(Array.isArray(result?.data) ? result.data : []);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to load users'), 'error');
|
||
} finally {
|
||
setUsersLoading(false);
|
||
}
|
||
};
|
||
fetchUsers();
|
||
}, [token, user?.role, onShowToast]);
|
||
|
||
const fetchBackupHistory = useCallback(async () => {
|
||
if (user?.role !== 'admin') return;
|
||
setBackupHistoryLoading(true);
|
||
try {
|
||
const result = await api('/api/fundraising/backups', {}, token);
|
||
setBackupHistory(Array.isArray(result?.data) ? result.data : []);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to load backup history'), 'error');
|
||
} finally {
|
||
setBackupHistoryLoading(false);
|
||
}
|
||
}, [token, user?.role, onShowToast]);
|
||
|
||
const fetchBackupPolicy = useCallback(async () => {
|
||
if (user?.role !== 'admin') return;
|
||
setBackupPolicyLoading(true);
|
||
try {
|
||
const result = await api('/api/fundraising/backup-policy', {}, token);
|
||
const policy = result?.data || null;
|
||
setBackupPolicy(policy);
|
||
if (policy) {
|
||
setBackupPolicyDraft({
|
||
enabled: Boolean(policy.enabled),
|
||
interval_hours: Number(policy.interval_hours) || 24,
|
||
retention_days: Number(policy.retention_days) || 30,
|
||
max_backups: Number(policy.max_backups) || 60
|
||
});
|
||
}
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to load backup policy'), 'error');
|
||
} finally {
|
||
setBackupPolicyLoading(false);
|
||
}
|
||
}, [token, user?.role, onShowToast]);
|
||
|
||
const fetchDigestPolicy = useCallback(async () => {
|
||
if (user?.role !== 'admin') return;
|
||
setDigestPolicyLoading(true);
|
||
try {
|
||
const result = await api('/api/admin/digest/policy', {}, token);
|
||
const p = result?.data;
|
||
if (p) setDigestPolicy({ enabled: Boolean(p.enabled), send_hour: Number(p.send_hour) || 18 });
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to load digest settings'), 'error');
|
||
} finally {
|
||
setDigestPolicyLoading(false);
|
||
}
|
||
}, [token, user?.role, onShowToast]);
|
||
|
||
const handleSaveDigestPolicy = async (patch) => {
|
||
setDigestPolicy((p) => ({ ...p, ...patch })); // optimistic
|
||
setDigestPolicySaving(true);
|
||
try {
|
||
const result = await api('/api/admin/digest/policy', {
|
||
method: 'PATCH',
|
||
body: JSON.stringify(patch)
|
||
}, token);
|
||
const p = result?.data;
|
||
if (p) setDigestPolicy({ enabled: Boolean(p.enabled), send_hour: Number(p.send_hour) || 18 });
|
||
onShowToast('Digest settings saved', 'success');
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to save digest settings'), 'error');
|
||
fetchDigestPolicy(); // revert to server truth
|
||
} finally {
|
||
setDigestPolicySaving(false);
|
||
}
|
||
};
|
||
|
||
const fetchAuditLogs = useCallback(async () => {
|
||
if (user?.role !== 'admin') return;
|
||
setAuditLoading(true);
|
||
try {
|
||
const params = new URLSearchParams();
|
||
Object.entries(auditFilters).forEach(([key, value]) => {
|
||
if (value === '' || value === null || value === undefined) return;
|
||
if (key === 'date_from' && String(value).length === 10) {
|
||
params.set(key, `${value}T00:00:00Z`);
|
||
return;
|
||
}
|
||
if (key === 'date_to' && String(value).length === 10) {
|
||
params.set(key, `${value}T23:59:59Z`);
|
||
return;
|
||
}
|
||
params.set(key, String(value));
|
||
});
|
||
const endpoint = `/api/audit-log?${params.toString()}`;
|
||
const result = await api(endpoint, {}, token);
|
||
setAuditLogs(Array.isArray(result?.data) ? result.data : []);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to load audit log'), 'error');
|
||
} finally {
|
||
setAuditLoading(false);
|
||
}
|
||
}, [token, user?.role, onShowToast, auditFilters]);
|
||
|
||
const fetchAutomations = useCallback(async () => {
|
||
if (user?.role !== 'admin') return;
|
||
setAutomationsLoading(true);
|
||
try {
|
||
const [rules, runs] = await Promise.all([
|
||
api('/api/fundraising/automations', {}, token),
|
||
api('/api/fundraising/automation-runs?limit=60', {}, token)
|
||
]);
|
||
setAutomationRules(Array.isArray(rules?.data) ? rules.data : []);
|
||
setAutomationRuns(Array.isArray(runs?.data) ? runs.data : []);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to load automations'), 'error');
|
||
} finally {
|
||
setAutomationsLoading(false);
|
||
}
|
||
}, [token, user?.role, onShowToast]);
|
||
|
||
const fetchActivityFeed = useCallback(async () => {
|
||
if (user?.role !== 'admin') return;
|
||
setActivityLoading(true);
|
||
try {
|
||
const result = await api('/api/fundraising/activity?limit=80', {}, token);
|
||
setActivityFeed(Array.isArray(result?.data) ? result.data : []);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to load activity feed'), 'error');
|
||
} finally {
|
||
setActivityLoading(false);
|
||
}
|
||
}, [token, user?.role, onShowToast]);
|
||
|
||
const fetchSecurityStatus = useCallback(async () => {
|
||
if (user?.role !== 'admin') return;
|
||
setSecurityLoading(true);
|
||
try {
|
||
const result = await api('/api/security/status', {}, token);
|
||
setSecurityStatus(result?.data || null);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to load security status'), 'error');
|
||
} finally {
|
||
setSecurityLoading(false);
|
||
}
|
||
}, [token, user?.role, onShowToast]);
|
||
|
||
useEffect(() => {
|
||
if (user?.role !== 'admin') return;
|
||
fetchBackupHistory();
|
||
fetchBackupPolicy();
|
||
fetchDigestPolicy();
|
||
fetchAuditLogs();
|
||
fetchAutomations();
|
||
fetchActivityFeed();
|
||
fetchSecurityStatus();
|
||
}, [user?.role, fetchBackupHistory, fetchBackupPolicy, fetchDigestPolicy, fetchAuditLogs, fetchAutomations, fetchActivityFeed, fetchSecurityStatus]);
|
||
|
||
const handleInviteUser = async (e) => {
|
||
e.preventDefault();
|
||
setInviteLoading(true);
|
||
try {
|
||
const result = await api('/api/admin/users', {
|
||
method: 'POST',
|
||
body: JSON.stringify(inviteForm)
|
||
}, token);
|
||
const created = result?.data;
|
||
onShowToast(`User invited: ${created?.username || inviteForm.username}`, 'success');
|
||
setInviteForm({ username: '', full_name: '', email: '', password: '', role: 'member' });
|
||
const usersResult = await api('/api/users', {}, token);
|
||
setUsers(Array.isArray(usersResult?.data) ? usersResult.data : []);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to invite user'), 'error');
|
||
} finally {
|
||
setInviteLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleToggleUserActive = async (target) => {
|
||
setUserActionLoadingId(target.id);
|
||
try {
|
||
const result = await api(`/api/admin/users/${target.id}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ is_active: !Boolean(target.is_active) })
|
||
}, token);
|
||
const updated = result?.data;
|
||
setUsers((prev) => prev.map((u) => (u.id === target.id ? { ...u, ...updated } : u)));
|
||
onShowToast(`${updated?.username || target.username} ${updated?.is_active ? 'activated' : 'deactivated'}`, 'success');
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to update user status'), 'error');
|
||
} finally {
|
||
setUserActionLoadingId(null);
|
||
}
|
||
};
|
||
|
||
const handleUpdateUserRole = async (target, role) => {
|
||
setUserActionLoadingId(`${target.id}:role`);
|
||
try {
|
||
const result = await api(`/api/admin/users/${target.id}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ role })
|
||
}, token);
|
||
const updated = result?.data;
|
||
setUsers((prev) => prev.map((u) => (u.id === target.id ? { ...u, ...updated } : u)));
|
||
onShowToast(`${updated?.username || target.username} role set to ${updated?.role || role}`, 'success');
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to update user role'), 'error');
|
||
} finally {
|
||
setUserActionLoadingId(null);
|
||
}
|
||
};
|
||
|
||
const handleResetUserPassword = async (target) => {
|
||
const nextPassword = window.prompt(`Set a new temporary password for ${target.username} (min 8 chars):`);
|
||
if (nextPassword === null) return;
|
||
const trimmed = String(nextPassword).trim();
|
||
if (trimmed.length < 8) {
|
||
onShowToast('Password must be at least 8 characters', 'error');
|
||
return;
|
||
}
|
||
setUserActionLoadingId(`${target.id}:password`);
|
||
try {
|
||
await api(`/api/admin/users/${target.id}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ password: trimmed })
|
||
}, token);
|
||
onShowToast(`Password reset for ${target.username}`, 'success');
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to reset password'), 'error');
|
||
} finally {
|
||
setUserActionLoadingId(null);
|
||
}
|
||
};
|
||
|
||
const handleBackupFundraising = async () => {
|
||
setBackupLoading(true);
|
||
try {
|
||
const result = await api('/api/fundraising/backup', { method: 'POST' }, token);
|
||
const info = result?.data || null;
|
||
setLastBackupInfo(info);
|
||
onShowToast(`Backup created: ${info?.filename || 'ok'}`, 'success');
|
||
fetchBackupHistory();
|
||
fetchAuditLogs();
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to create backup'), 'error');
|
||
} finally {
|
||
setBackupLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleSaveBackupPolicy = async () => {
|
||
setBackupPolicyLoading(true);
|
||
try {
|
||
const payload = {
|
||
enabled: Boolean(backupPolicyDraft.enabled),
|
||
interval_hours: Math.max(1, Number(backupPolicyDraft.interval_hours) || 24),
|
||
retention_days: Math.max(1, Number(backupPolicyDraft.retention_days) || 30),
|
||
max_backups: Math.max(1, Number(backupPolicyDraft.max_backups) || 60)
|
||
};
|
||
const result = await api('/api/fundraising/backup-policy', {
|
||
method: 'PATCH',
|
||
body: JSON.stringify(payload)
|
||
}, token);
|
||
setBackupPolicy(result?.data || payload);
|
||
onShowToast('Backup policy updated', 'success');
|
||
fetchBackupHistory();
|
||
fetchBackupPolicy();
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to update backup policy'), 'error');
|
||
} finally {
|
||
setBackupPolicyLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleExportFundraising = async () => {
|
||
setExportLoading(true);
|
||
try {
|
||
const result = await api('/api/fundraising/export', {}, token);
|
||
const payload = result?.data || {};
|
||
const filename = `fundraising_state_export_${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
|
||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(url);
|
||
onShowToast('Fundraising export downloaded', 'success');
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to export fundraising state'), 'error');
|
||
} finally {
|
||
setExportLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleRestoreFileSelected = async (e) => {
|
||
const file = e.target.files && e.target.files[0];
|
||
if (!file) return;
|
||
try {
|
||
const text = await file.text();
|
||
const parsed = JSON.parse(text);
|
||
setRestorePayload(parsed);
|
||
setRestoreFileName(file.name);
|
||
setRestorePreview(null);
|
||
onShowToast(`Loaded restore file: ${file.name}`, 'success');
|
||
} catch (_) {
|
||
setRestorePayload(null);
|
||
setRestoreFileName('');
|
||
setRestorePreview(null);
|
||
onShowToast('Invalid JSON file', 'error');
|
||
}
|
||
};
|
||
|
||
const handlePreviewRestore = async () => {
|
||
if (!restorePayload) {
|
||
onShowToast('Select a restore JSON file first', 'error');
|
||
return;
|
||
}
|
||
setPreviewLoading(true);
|
||
try {
|
||
const result = await api('/api/fundraising/restore-preview', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ payload: restorePayload })
|
||
}, token);
|
||
setRestorePreview(result?.data || null);
|
||
onShowToast('Restore preview ready', 'success');
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to preview restore'), 'error');
|
||
} finally {
|
||
setPreviewLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleRestoreFundraising = async () => {
|
||
if (!restorePayload) {
|
||
onShowToast('Select a restore JSON file first', 'error');
|
||
return;
|
||
}
|
||
const confirmed = window.confirm('Restore fundraising state from file? This will overwrite current grid and views.');
|
||
if (!confirmed) return;
|
||
|
||
setRestoreLoading(true);
|
||
try {
|
||
const result = await api('/api/fundraising/restore', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ payload: restorePayload })
|
||
}, token);
|
||
const info = result?.data?.pre_restore_backup;
|
||
onShowToast(`Restore complete${info?.filename ? ` (pre-backup: ${info.filename})` : ''}`, 'success');
|
||
fetchBackupHistory();
|
||
fetchAuditLogs();
|
||
setTimeout(() => window.location.reload(), 500);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to restore fundraising state'), 'error');
|
||
} finally {
|
||
setRestoreLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleRestoreFromBackup = async (filename) => {
|
||
const confirmed = window.confirm(`Restore from backup file ${filename}? This will overwrite current grid and views.`);
|
||
if (!confirmed) return;
|
||
setRestoreLoading(true);
|
||
try {
|
||
const result = await api('/api/fundraising/restore', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ filename })
|
||
}, token);
|
||
const info = result?.data?.pre_restore_backup;
|
||
onShowToast(`Restore complete${info?.filename ? ` (pre-backup: ${info.filename})` : ''}`, 'success');
|
||
fetchBackupHistory();
|
||
fetchAuditLogs();
|
||
setTimeout(() => window.location.reload(), 500);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to restore from backup'), 'error');
|
||
} finally {
|
||
setRestoreLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleToggleAutomationRule = async (rule) => {
|
||
try {
|
||
await api(`/api/fundraising/automations/${rule.id}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ enabled: !Boolean(rule.enabled) })
|
||
}, token);
|
||
onShowToast(`${rule.name} ${rule.enabled ? 'disabled' : 'enabled'}`, 'success');
|
||
fetchAutomations();
|
||
fetchActivityFeed();
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to update automation rule'), 'error');
|
||
}
|
||
};
|
||
|
||
const handleVerifyBackups = async () => {
|
||
setBackupVerifyLoading(true);
|
||
try {
|
||
const result = await api('/api/fundraising/backup-verify', { method: 'POST' }, token);
|
||
setBackupVerifyResult(result?.data || null);
|
||
onShowToast('Backup verification complete', 'success');
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to verify backups'), 'error');
|
||
} finally {
|
||
setBackupVerifyLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleResetAllData = async () => {
|
||
const phrase = 'RESET ALL DATA';
|
||
if (resetAllDataConfirm.trim() !== phrase) {
|
||
onShowToast(`Type exactly "${phrase}" to continue`, 'error');
|
||
return;
|
||
}
|
||
const confirmed = window.confirm('This will permanently clear contacts, organizations, pipeline, communications, feature requests, and reset the fundraising grid. Continue?');
|
||
if (!confirmed) return;
|
||
setResetAllDataLoading(true);
|
||
try {
|
||
const result = await api('/api/admin/reset-all-data', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ confirm_phrase: phrase })
|
||
}, token);
|
||
const pre = result?.data?.pre_backup?.filename;
|
||
onShowToast(`All data reset complete${pre ? ` (backup: ${pre})` : ''}`, 'success');
|
||
setResetAllDataConfirm('');
|
||
setTimeout(() => window.location.reload(), 800);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to reset all data'), 'error');
|
||
} finally {
|
||
setResetAllDataLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleSendTestDigestEmail = async () => {
|
||
setTestEmailLoading(true);
|
||
try {
|
||
const result = await api('/api/admin/digest/test-email', {
|
||
method: 'POST',
|
||
body: JSON.stringify({})
|
||
}, token);
|
||
const to = (result?.data?.sent_to || []).join(', ');
|
||
onShowToast(`Test digest email sent${to ? ` to ${to}` : ''}`, 'success');
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to send test email — is SMTP configured? (Start9: Configure Digest SMTP, then restart)'), 'error');
|
||
} finally {
|
||
setTestEmailLoading(false);
|
||
}
|
||
};
|
||
|
||
// The selected manual-run window: a specific start date, or the last 24h.
|
||
const digestWindowBody = () =>
|
||
digestWindowMode === 'since' && digestSince ? { since: digestSince } : { hours: 24 };
|
||
|
||
const digestSummary = (d) => d.has_activity
|
||
? `${d.user_count} member(s), ${d.email_count} email(s), ${d.investor_count} investor(s)`
|
||
: 'no activity in this window';
|
||
|
||
const handlePreviewDigest = async () => {
|
||
setDigestPreviewLoading(true);
|
||
try {
|
||
const result = await api('/api/admin/digest/preview', {
|
||
method: 'POST',
|
||
body: JSON.stringify(digestWindowBody())
|
||
}, token);
|
||
setDigestPreview(result?.data || null);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to build preview — is Spark Control reachable?'), 'error');
|
||
} finally {
|
||
setDigestPreviewLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleSendDigestNow = async () => {
|
||
setSendDigestLoading(true);
|
||
try {
|
||
const result = await api('/api/admin/digest/send-now', {
|
||
method: 'POST',
|
||
body: JSON.stringify(digestWindowBody())
|
||
}, token);
|
||
const d = result?.data || {};
|
||
const to = (d.recipients || []).join(', ');
|
||
onShowToast(`Digest sent (${digestSummary(d)})${to ? ` to ${to}` : ''}`, 'success');
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to send digest — is a transport (Gmail/DWD or SMTP) configured?'), 'error');
|
||
} finally {
|
||
setSendDigestLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleContactsCsvFileUpload = async (event) => {
|
||
const file = event.target.files && event.target.files[0];
|
||
if (!file) return;
|
||
try {
|
||
const text = await file.text();
|
||
setContactsCsvText(text);
|
||
setContactsImportError('');
|
||
setContactsImportPreview(null);
|
||
setContactsImportOverrides({});
|
||
} catch (_) {
|
||
setContactsImportError('Could not read selected CSV file');
|
||
} finally {
|
||
event.target.value = '';
|
||
}
|
||
};
|
||
|
||
const buildContactsImportMapping = (headers) => {
|
||
const mapping = {};
|
||
headers.forEach((header) => {
|
||
const h = normalizeKey(header);
|
||
if (!h) return;
|
||
if (h === 'first name' || h.includes('first name')) mapping[header] = 'first_name';
|
||
else if (h === 'last name' || h.includes('last name')) mapping[header] = 'last_name';
|
||
else if (h === 'name' || h === 'combined' || h.includes('combined')) mapping[header] = 'name';
|
||
else if (h.includes('email')) mapping[header] = 'email';
|
||
else if (h.includes('title')) mapping[header] = 'title';
|
||
else if (h.includes('investor entity name') || h.includes('investor name') || h === 'organization' || h.includes('employer') || h.includes('company')) mapping[header] = 'organization';
|
||
else if (h.includes('linkedin')) mapping[header] = 'linkedin_url';
|
||
else if (h === 'city' || h === 'city location' || h === 'city/location' || h.includes('location')) mapping[header] = 'location';
|
||
else if (h === 'state') mapping[header] = 'state';
|
||
else if (h === 'country') mapping[header] = 'country';
|
||
else if (h.includes('lead source') || h === 'source') mapping[header] = 'source';
|
||
});
|
||
return mapping;
|
||
};
|
||
|
||
const runContactsImport = async ({ dryRun }) => {
|
||
setContactsImportError('');
|
||
const records = parseCsvRecords(contactsCsvText);
|
||
if (records.length === 0) {
|
||
setContactsImportError('No rows detected. Upload or paste a contacts CSV first.');
|
||
return null;
|
||
}
|
||
const headers = Object.keys(records[0] || {});
|
||
const mapping = buildContactsImportMapping(headers);
|
||
setContactsImportLoading(true);
|
||
try {
|
||
const result = await api('/api/import/csv', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
entity_type: 'contacts',
|
||
data: records,
|
||
mapping,
|
||
dry_run: !!dryRun,
|
||
update_existing: !!contactsUpdateExisting,
|
||
action_overrides: contactsImportOverrides
|
||
})
|
||
}, token);
|
||
const stats = result?.data || {};
|
||
if (dryRun) {
|
||
setContactsImportPreview(stats);
|
||
const nextOverrides = {};
|
||
if (Array.isArray(stats.matches)) {
|
||
stats.matches.forEach((match) => {
|
||
const key = String(match.row || '');
|
||
if (!key) return;
|
||
const existingOverride = contactsImportOverrides[key];
|
||
if (existingOverride === 'update' || existingOverride === 'skip' || existingOverride === 'create_duplicate') {
|
||
nextOverrides[key] = existingOverride;
|
||
} else if (match.action === 'update' || match.action === 'skip' || match.action === 'create_duplicate') {
|
||
nextOverrides[key] = match.action;
|
||
} else if (match.default_action === 'update' || match.default_action === 'skip' || match.default_action === 'create_duplicate') {
|
||
nextOverrides[key] = match.default_action;
|
||
} else {
|
||
nextOverrides[key] = contactsUpdateExisting ? 'update' : 'skip';
|
||
}
|
||
});
|
||
}
|
||
setContactsImportOverrides(nextOverrides);
|
||
} else {
|
||
setContactsImportPreview(null);
|
||
setContactsImportOverrides({});
|
||
}
|
||
onShowToast(`${dryRun ? 'Dry run' : 'Contacts import'}: ${stats.created || 0} created, ${stats.updated || 0} updated, ${stats.skipped || 0} skipped`, 'success');
|
||
if (!dryRun) {
|
||
setContactsCsvText('');
|
||
}
|
||
return stats;
|
||
} catch (err) {
|
||
setContactsImportError(getErrorMessage(err, 'Contacts import failed'));
|
||
return null;
|
||
} finally {
|
||
setContactsImportLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleImportContactsCsv = async (dryRun = false) => {
|
||
return runContactsImport({ dryRun: !!dryRun });
|
||
};
|
||
|
||
const handlePreviewThenConfirmContactsImport = async () => {
|
||
const stats = await runContactsImport({ dryRun: true });
|
||
if (!stats) return;
|
||
};
|
||
|
||
return (
|
||
<div className="page-container">
|
||
<h2 className="section-title">Settings</h2>
|
||
|
||
<div className="section">
|
||
<div className="section-title">Your Profile</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Username</span>
|
||
<span className="detail-value">{user?.username || '-'}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Email</span>
|
||
<span className="detail-value">{user?.email || '-'}</span>
|
||
</div>
|
||
<div className="detail-row">
|
||
<span className="detail-label">Name</span>
|
||
<span className="detail-value">{user?.full_name || '-'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{user?.role === 'admin' && (
|
||
<div className="section">
|
||
<div className="section-title">Migration</div>
|
||
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '12px' }}>
|
||
One-time import for existing Airtable fundraising data.
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="button-secondary"
|
||
onClick={() => onOpenAirtableImport && onOpenAirtableImport()}
|
||
>
|
||
Import from Airtable CSV
|
||
</button>
|
||
<div style={{ marginTop: '16px', borderTop: '1px solid #263548', paddingTop: '14px' }}>
|
||
<div style={{ fontWeight: 600, marginBottom: '8px' }}>Import Contacts CSV</div>
|
||
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '10px' }}>
|
||
Supports Airtable contact exports (first/last name, investor entity, email, LinkedIn, and city/location).
|
||
</div>
|
||
{contactsImportError && (
|
||
<div className="toast error" style={{ position: 'static', marginBottom: '10px' }}>{contactsImportError}</div>
|
||
)}
|
||
<div style={{ marginBottom: '10px' }}>
|
||
<input type="file" accept=".csv,text/csv" onChange={handleContactsCsvFileUpload} />
|
||
</div>
|
||
<textarea
|
||
className="text-input"
|
||
rows="6"
|
||
value={contactsCsvText}
|
||
onChange={(e) => {
|
||
setContactsCsvText(e.target.value);
|
||
setContactsImportPreview(null);
|
||
setContactsImportOverrides({});
|
||
}}
|
||
placeholder={'First name,Last name,Investor entity name,Email,LinkedIn URL,City/location\nJane,Doe,Example Capital,jane@example.com,https://linkedin.com/in/jane-doe,"New York City, NY, USA"'}
|
||
/>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '10px', fontSize: '12px', color: '#c7d3e0' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={contactsUpdateExisting}
|
||
onChange={(e) => {
|
||
setContactsUpdateExisting(e.target.checked);
|
||
setContactsImportPreview(null);
|
||
setContactsImportOverrides({});
|
||
}}
|
||
/>
|
||
Update existing contacts when email matches (otherwise skip existing)
|
||
</label>
|
||
<div style={{ marginTop: '10px', display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
||
<button type="button" className="button-secondary" onClick={() => handleImportContactsCsv(true)} disabled={contactsImportLoading}>
|
||
{contactsImportLoading ? <Spinner /> : 'Dry Run Contacts Import'}
|
||
</button>
|
||
<button type="button" className="button-secondary" onClick={handlePreviewThenConfirmContactsImport} disabled={contactsImportLoading}>
|
||
{contactsImportLoading ? <Spinner /> : 'Review Before Import'}
|
||
</button>
|
||
</div>
|
||
{contactsImportPreview && (
|
||
<div style={{ marginTop: '12px', padding: '10px', border: '1px solid #263548', borderRadius: '8px', background: '#0d1622' }}>
|
||
<div style={{ fontWeight: 600, marginBottom: '6px' }}>Import Review</div>
|
||
<div style={{ fontSize: '12px', color: '#c7d3e0', marginBottom: '8px' }}>
|
||
Created: {contactsImportPreview.created || 0} · Updated: {contactsImportPreview.updated || 0} · Skipped: {contactsImportPreview.skipped || 0}
|
||
</div>
|
||
{Array.isArray(contactsImportPreview.errors) && contactsImportPreview.errors.length > 0 && (
|
||
<div style={{ marginBottom: '8px', fontSize: '12px', color: '#d9a15f', maxHeight: '120px', overflowY: 'auto' }}>
|
||
{contactsImportPreview.errors.slice(0, 30).map((e, i) => (
|
||
<div key={`${e}-${i}`}>• {e}</div>
|
||
))}
|
||
{contactsImportPreview.errors.length > 30 && (
|
||
<div>…and {contactsImportPreview.errors.length - 30} more</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{Array.isArray(contactsImportPreview.matches) && contactsImportPreview.matches.length > 0 && (
|
||
<div style={{ marginBottom: '10px' }}>
|
||
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '6px' }}>
|
||
Matched by email. Choose what to do per row before import:
|
||
<span style={{ marginLeft: '6px', color: '#c7d3e0' }}>update = overwrite existing contact, skip = ignore row, create duplicate = insert another contact with same email.</span>
|
||
</div>
|
||
<div style={{ maxHeight: '220px', overflowY: 'auto', border: '1px solid #263548', borderRadius: '6px' }}>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '12px' }}>
|
||
<thead>
|
||
<tr style={{ background: '#101c2b', color: '#8ea2b7' }}>
|
||
<th style={{ textAlign: 'left', padding: '6px', borderBottom: '1px solid #263548' }}>Row</th>
|
||
<th style={{ textAlign: 'left', padding: '6px', borderBottom: '1px solid #263548' }}>Incoming</th>
|
||
<th style={{ textAlign: 'left', padding: '6px', borderBottom: '1px solid #263548' }}>Existing</th>
|
||
<th style={{ textAlign: 'left', padding: '6px', borderBottom: '1px solid #263548' }}>Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{contactsImportPreview.matches.map((match, idx) => {
|
||
const rowKey = String(match.row || idx + 1);
|
||
const currentAction = contactsImportOverrides[rowKey] || match.action || match.default_action || (contactsUpdateExisting ? 'update' : 'skip');
|
||
return (
|
||
<tr key={`${rowKey}-${idx}`}>
|
||
<td style={{ padding: '6px', borderBottom: '1px solid #1a2a3a', color: '#8ea2b7' }}>{rowKey}</td>
|
||
<td style={{ padding: '6px', borderBottom: '1px solid #1a2a3a', color: '#c7d3e0' }}>
|
||
<div>{match.incoming_name || '-'}</div>
|
||
<div style={{ color: '#8ea2b7' }}>{match.incoming_email || '-'}</div>
|
||
<div style={{ color: '#8ea2b7' }}>{match.incoming_organization || '-'}</div>
|
||
</td>
|
||
<td style={{ padding: '6px', borderBottom: '1px solid #1a2a3a', color: '#c7d3e0' }}>
|
||
<div>{match.existing_name || '-'}</div>
|
||
<div style={{ color: '#8ea2b7' }}>{match.existing_email || '-'}</div>
|
||
<div style={{ color: '#8ea2b7' }}>{match.existing_organization || '-'}</div>
|
||
</td>
|
||
<td style={{ padding: '6px', borderBottom: '1px solid #1a2a3a' }}>
|
||
<select
|
||
className="text-input"
|
||
value={currentAction}
|
||
onChange={(e) => {
|
||
const nextAction = e.target.value;
|
||
setContactsImportOverrides((prev) => ({ ...prev, [rowKey]: nextAction }));
|
||
}}
|
||
>
|
||
<option value="update">Update existing</option>
|
||
<option value="skip">Skip row</option>
|
||
<option value="create_duplicate">Create duplicate</option>
|
||
</select>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
||
<button type="button" className="button-secondary" onClick={() => handleImportContactsCsv(false)} disabled={contactsImportLoading}>
|
||
{contactsImportLoading ? <Spinner /> : 'Proceed With Import'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="button-secondary"
|
||
onClick={() => {
|
||
setContactsImportPreview(null);
|
||
setContactsImportOverrides({});
|
||
}}
|
||
disabled={contactsImportLoading}
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{user?.role === 'admin' && (
|
||
<div className="section">
|
||
<div className="section-title">Admin</div>
|
||
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '12px' }}>
|
||
Invite users and manage fundraising state backups.
|
||
</div>
|
||
|
||
<div style={{ marginBottom: '20px', borderBottom: '1px solid #263548', paddingBottom: '16px' }}>
|
||
<div style={{ fontWeight: 600, marginBottom: '8px' }}>Daily Digest Email</div>
|
||
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '10px' }}>
|
||
A daily email to all active admins: each team member's activity per investor, plus a by-investor view (inbound + outbound), summarized locally (Spark), never Claude. Use <b>Preview</b> below to build the digest over a window and read it before sending — a wide window is how you verify the summarizer on a quiet day. <b>Send</b> pushes that same window to the admin inboxes now. Neither touches the daily schedule.
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '14px', flexWrap: 'wrap', marginBottom: '12px' }}>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(digestPolicy.enabled)}
|
||
disabled={digestPolicyLoading || digestPolicySaving}
|
||
onChange={(e) => handleSaveDigestPolicy({ enabled: e.target.checked })}
|
||
/>
|
||
Send automatically every day
|
||
</label>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||
at
|
||
<select
|
||
value={Number(digestPolicy.send_hour)}
|
||
disabled={digestPolicyLoading || digestPolicySaving || !digestPolicy.enabled}
|
||
onChange={(e) => handleSaveDigestPolicy({ send_hour: Number(e.target.value) })}
|
||
>
|
||
{Array.from({ length: 24 }, (_, h) => (
|
||
<option key={h} value={h}>
|
||
{((h % 12) || 12) + ':00 ' + (h < 12 ? 'AM' : 'PM')}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<span style={{ fontSize: '11px', color: '#8ea2b7' }}>(server local time)</span>
|
||
</label>
|
||
</div>
|
||
<div style={{ fontWeight: 600, fontSize: '13px', margin: '4px 0 8px' }}>Manual run & preview</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap', marginBottom: '10px' }}>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
|
||
<input type="radio" name="digestWindow" checked={digestWindowMode === '24h'}
|
||
onChange={() => setDigestWindowMode('24h')} />
|
||
Last 24 hours
|
||
</label>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
|
||
<input type="radio" name="digestWindow" checked={digestWindowMode === 'since'}
|
||
onChange={() => setDigestWindowMode('since')} />
|
||
Since
|
||
<input type="date" value={digestSince} max={new Date().toISOString().slice(0, 10)}
|
||
onChange={(e) => { setDigestSince(e.target.value); setDigestWindowMode('since'); }}
|
||
disabled={digestWindowMode !== 'since'} />
|
||
</label>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||
<button type="button" onClick={handlePreviewDigest} disabled={digestPreviewLoading}>
|
||
{digestPreviewLoading ? <Spinner /> : 'Preview'}
|
||
</button>
|
||
<button type="button" onClick={handleSendDigestNow} disabled={sendDigestLoading}>
|
||
{sendDigestLoading ? <Spinner /> : 'Send to admins now'}
|
||
</button>
|
||
<button type="button" onClick={handleSendTestDigestEmail} disabled={testEmailLoading}
|
||
style={{ background: 'transparent', color: '#8ea2b7', border: '1px solid #2a3a4d', boxShadow: 'none' }}
|
||
title="Send a fixed test message to verify the outbound transport (Gmail-DWD / SMTP)">
|
||
{testEmailLoading ? <Spinner /> : 'Send transport test'}
|
||
</button>
|
||
</div>
|
||
{digestPreview && (
|
||
<div style={{ marginTop: '12px', border: '1px solid #263548', borderRadius: '6px', overflow: 'hidden' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', padding: '8px 10px', background: '#16202e', flexWrap: 'wrap' }}>
|
||
<span style={{ fontSize: '12px', color: '#cdd9e5' }}>
|
||
<b>{digestPreview.subject}</b> — {digestSummary(digestPreview)}
|
||
{Array.isArray(digestPreview.window) && (
|
||
<span style={{ color: '#8ea2b7' }}> · {formatDate(digestPreview.window[0])} → {formatDate(digestPreview.window[1])}</span>
|
||
)}
|
||
</span>
|
||
<button type="button" style={{ fontSize: '11px', padding: '4px 8px', background: 'transparent', color: '#8ea2b7', border: '1px solid #2a3a4d', boxShadow: 'none' }} onClick={() => setDigestPreview(null)}>Clear</button>
|
||
</div>
|
||
<pre style={{ margin: 0, padding: '10px', maxHeight: '380px', overflow: 'auto', fontSize: '12px', lineHeight: 1.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: '#cdd9e5' }}>{digestPreview.body}</pre>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ marginBottom: '20px', borderBottom: '1px solid #263548', paddingBottom: '16px' }}>
|
||
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Invite User</div>
|
||
<form onSubmit={handleInviteUser}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(190px,1fr))', gap: '10px', marginBottom: '10px' }}>
|
||
<input
|
||
type="text"
|
||
className="text-input"
|
||
placeholder="Username"
|
||
value={inviteForm.username}
|
||
onChange={(e) => setInviteForm((f) => ({ ...f, username: e.target.value }))}
|
||
required
|
||
/>
|
||
<input
|
||
type="text"
|
||
className="text-input"
|
||
placeholder="Full name"
|
||
value={inviteForm.full_name}
|
||
onChange={(e) => setInviteForm((f) => ({ ...f, full_name: e.target.value }))}
|
||
required
|
||
/>
|
||
<input
|
||
type="email"
|
||
className="text-input"
|
||
placeholder="Email"
|
||
value={inviteForm.email}
|
||
onChange={(e) => setInviteForm((f) => ({ ...f, email: e.target.value }))}
|
||
required
|
||
/>
|
||
<input
|
||
type="text"
|
||
className="text-input"
|
||
placeholder="Temporary password"
|
||
value={inviteForm.password}
|
||
onChange={(e) => setInviteForm((f) => ({ ...f, password: e.target.value }))}
|
||
required
|
||
/>
|
||
<select
|
||
className="select-input"
|
||
value={inviteForm.role}
|
||
onChange={(e) => setInviteForm((f) => ({ ...f, role: e.target.value }))}
|
||
>
|
||
<option value="member">Member</option>
|
||
<option value="admin">Admin</option>
|
||
</select>
|
||
</div>
|
||
<button type="submit" disabled={inviteLoading}>
|
||
{inviteLoading ? <Spinner /> : 'Invite User'}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: '20px', borderBottom: '1px solid #263548', paddingBottom: '16px' }}>
|
||
<div style={{ fontWeight: 600, marginBottom: '8px', color: '#f3b2b2' }}>Danger Zone: Reset All Data</div>
|
||
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '10px' }}>
|
||
Clears all CRM records (contacts, organizations, opportunities, communications, feature requests) and resets fundraising grid to empty defaults.
|
||
</div>
|
||
<input
|
||
type="text"
|
||
className="text-input"
|
||
placeholder='Type: RESET ALL DATA'
|
||
value={resetAllDataConfirm}
|
||
onChange={(e) => setResetAllDataConfirm(e.target.value)}
|
||
style={{ marginBottom: '10px' }}
|
||
/>
|
||
<button type="button" className="button-danger" onClick={handleResetAllData} disabled={resetAllDataLoading}>
|
||
{resetAllDataLoading ? <Spinner /> : 'Reset All Data'}
|
||
</button>
|
||
</div>
|
||
|
||
<div>
|
||
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Fundraising State Ops</div>
|
||
<div style={{ marginBottom: '12px', padding: '10px', border: '1px solid #263548', borderRadius: '8px' }}>
|
||
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '8px' }}>Scheduled Backups</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(180px,1fr))', gap: '10px', marginBottom: '10px' }}>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px', color: '#c7d3e0' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(backupPolicyDraft.enabled)}
|
||
onChange={(e) => setBackupPolicyDraft((p) => ({ ...p, enabled: e.target.checked }))}
|
||
/>
|
||
Enabled
|
||
</label>
|
||
<label style={{ display: 'grid', gap: '4px', fontSize: '12px', color: '#8ea2b7' }}>
|
||
Interval (hours)
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="168"
|
||
className="text-input"
|
||
value={backupPolicyDraft.interval_hours}
|
||
onChange={(e) => setBackupPolicyDraft((p) => ({ ...p, interval_hours: e.target.value }))}
|
||
/>
|
||
</label>
|
||
<label style={{ display: 'grid', gap: '4px', fontSize: '12px', color: '#8ea2b7' }}>
|
||
Retention (days)
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="365"
|
||
className="text-input"
|
||
value={backupPolicyDraft.retention_days}
|
||
onChange={(e) => setBackupPolicyDraft((p) => ({ ...p, retention_days: e.target.value }))}
|
||
/>
|
||
</label>
|
||
<label style={{ display: 'grid', gap: '4px', fontSize: '12px', color: '#8ea2b7' }}>
|
||
Max backups
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="1000"
|
||
className="text-input"
|
||
value={backupPolicyDraft.max_backups}
|
||
onChange={(e) => setBackupPolicyDraft((p) => ({ ...p, max_backups: e.target.value }))}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<button type="button" className="button-secondary" onClick={handleSaveBackupPolicy} disabled={backupPolicyLoading}>
|
||
{backupPolicyLoading ? <Spinner /> : 'Save Backup Policy'}
|
||
</button>
|
||
{backupPolicy?.next_run_at && (
|
||
<span style={{ fontSize: '12px', color: '#8ea2b7' }}>
|
||
Next run: {formatDateLong(backupPolicy.next_run_at)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
||
<button type="button" className="button-secondary" onClick={handleExportFundraising} disabled={exportLoading}>
|
||
{exportLoading ? <Spinner /> : 'Download Export JSON'}
|
||
</button>
|
||
<button type="button" className="button-secondary" onClick={handleBackupFundraising} disabled={backupLoading}>
|
||
{backupLoading ? <Spinner /> : 'Backup Now'}
|
||
</button>
|
||
</div>
|
||
<div style={{ marginTop: '12px', display: 'flex', gap: '10px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||
<input type="file" accept=".json,application/json" onChange={handleRestoreFileSelected} />
|
||
<button type="button" className="button-secondary" onClick={handlePreviewRestore} disabled={previewLoading || !restorePayload}>
|
||
{previewLoading ? <Spinner /> : 'Preview Restore'}
|
||
</button>
|
||
<button type="button" className="button-secondary" onClick={handleRestoreFundraising} disabled={restoreLoading || !restorePayload}>
|
||
{restoreLoading ? <Spinner /> : 'Restore From JSON'}
|
||
</button>
|
||
</div>
|
||
{restoreFileName && (
|
||
<div style={{ marginTop: '8px', fontSize: '12px', color: '#8ea2b7' }}>
|
||
Loaded restore file: {restoreFileName}
|
||
</div>
|
||
)}
|
||
{restorePreview && (
|
||
<div style={{ marginTop: '8px', fontSize: '12px', color: '#8ea2b7', display: 'grid', gap: '5px' }}>
|
||
<div>Preview: {restorePreview.columns_count} columns, {restorePreview.rows_count} rows, {restorePreview.views_count} views</div>
|
||
{restorePreview.diff && (
|
||
<div style={{ color: '#a6b8ca' }}>
|
||
Diff: +{restorePreview.diff.rows_added_count || 0} rows, -{restorePreview.diff.rows_removed_count || 0} rows, {restorePreview.diff.rows_changed_count || 0} rows changed, {restorePreview.diff.cell_changes_count || 0} cell edits, +{(restorePreview.diff.columns_added || []).length} columns, -{(restorePreview.diff.columns_removed || []).length} columns
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{lastBackupInfo && (
|
||
<div style={{ marginTop: '10px', fontSize: '12px', color: '#8ea2b7' }}>
|
||
Last backup: {lastBackupInfo.filename} ({lastBackupInfo.path})
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ marginTop: '20px', borderTop: '1px solid #263548', paddingTop: '16px' }}>
|
||
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Backup History</div>
|
||
{backupHistoryLoading ? (
|
||
<SkeletonBlock lines={4} />
|
||
) : backupHistory.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '10px 0' }}>No backups yet</div>
|
||
) : (
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th>File</th>
|
||
<th>Type</th>
|
||
<th>Modified</th>
|
||
<th>Size</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{backupHistory.slice(0, 30).map((b) => (
|
||
<tr key={b.filename}>
|
||
<td>{b.filename}</td>
|
||
<td>{b.kind}</td>
|
||
<td>{formatDateLong(b.modified_at)}</td>
|
||
<td>{Math.round((b.size_bytes || 0) / 1024)} KB</td>
|
||
<td>
|
||
<button
|
||
type="button"
|
||
className="button-secondary"
|
||
onClick={() => handleRestoreFromBackup(b.filename)}
|
||
disabled={restoreLoading}
|
||
>
|
||
Restore
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ marginTop: '20px', borderTop: '1px solid #263548', paddingTop: '16px' }}>
|
||
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Users</div>
|
||
{usersLoading ? (
|
||
<SkeletonBlock lines={5} />
|
||
) : users.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '10px 0' }}>No users</div>
|
||
) : (
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th>Name</th>
|
||
<th>Username</th>
|
||
<th>Email</th>
|
||
<th>Role</th>
|
||
<th>Status</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{users.map((u) => (
|
||
<tr key={u.id}>
|
||
<td>{u.full_name || '-'}</td>
|
||
<td>{u.username}</td>
|
||
<td>{u.email || '-'}</td>
|
||
<td>
|
||
<select
|
||
className="select-input"
|
||
style={{ minWidth: '120px' }}
|
||
value={u.role || 'member'}
|
||
onChange={(e) => handleUpdateUserRole(u, e.target.value)}
|
||
disabled={userActionLoadingId === `${u.id}:role` || u.id === user?.id}
|
||
>
|
||
<option value="member">member</option>
|
||
<option value="admin">admin</option>
|
||
{/* bot = dedicated agent service account (e.g. the Matrix bot): authenticated but never admin */}
|
||
<option value="bot">bot</option>
|
||
</select>
|
||
</td>
|
||
<td>{u.is_active ? 'Active' : 'Inactive'}</td>
|
||
<td>
|
||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||
<button
|
||
type="button"
|
||
className="button-secondary"
|
||
onClick={() => handleToggleUserActive(u)}
|
||
disabled={userActionLoadingId === u.id || u.id === user?.id}
|
||
>
|
||
{userActionLoadingId === u.id ? <Spinner /> : (u.is_active ? 'Deactivate' : 'Activate')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="button-secondary"
|
||
onClick={() => handleResetUserPassword(u)}
|
||
disabled={userActionLoadingId === `${u.id}:password`}
|
||
>
|
||
{userActionLoadingId === `${u.id}:password` ? <Spinner /> : 'Reset Password'}
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ marginTop: '20px', borderTop: '1px solid #263548', paddingTop: '16px' }}>
|
||
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Audit Log</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(170px,1fr))', gap: '10px', marginBottom: '10px' }}>
|
||
<select className="select-input" value={auditFilters.entity_type} onChange={(e) => setAuditFilters((f) => ({ ...f, entity_type: e.target.value }))}>
|
||
<option value="">All entities</option>
|
||
<option value="fundraising_state">fundraising_state</option>
|
||
<option value="user">user</option>
|
||
<option value="feature_request">feature_request</option>
|
||
<option value="contact">contact</option>
|
||
<option value="organization">organization</option>
|
||
<option value="opportunity">opportunity</option>
|
||
<option value="communication">communication</option>
|
||
<option value="tag">tag</option>
|
||
</select>
|
||
<input className="text-input" placeholder="Action (create/update/...)" value={auditFilters.action} onChange={(e) => setAuditFilters((f) => ({ ...f, action: e.target.value }))} />
|
||
<select className="select-input" value={auditFilters.user_id} onChange={(e) => setAuditFilters((f) => ({ ...f, user_id: e.target.value }))}>
|
||
<option value="">All users</option>
|
||
{users.map((u) => (
|
||
<option key={u.id} value={u.id}>{u.full_name || u.username}</option>
|
||
))}
|
||
</select>
|
||
<input className="text-input" type="date" value={auditFilters.date_from} onChange={(e) => setAuditFilters((f) => ({ ...f, date_from: e.target.value }))} />
|
||
<input className="text-input" type="date" value={auditFilters.date_to} onChange={(e) => setAuditFilters((f) => ({ ...f, date_to: e.target.value }))} />
|
||
<input className="text-input" placeholder="Search entity or changes" value={auditFilters.search} onChange={(e) => setAuditFilters((f) => ({ ...f, search: e.target.value }))} />
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
|
||
<button type="button" className="button-secondary" onClick={fetchAuditLogs} disabled={auditLoading}>
|
||
{auditLoading ? <Spinner /> : 'Apply Filters'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="button-secondary"
|
||
onClick={() => setAuditFilters({ entity_type: '', action: '', user_id: '', date_from: '', date_to: '', search: '', limit: 100 })}
|
||
>
|
||
Reset
|
||
</button>
|
||
</div>
|
||
{auditLoading ? (
|
||
<SkeletonBlock lines={5} />
|
||
) : auditLogs.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '10px 0' }}>No audit events</div>
|
||
) : (
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th>When</th>
|
||
<th>Entity</th>
|
||
<th>Action</th>
|
||
<th>User</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{auditLogs.slice(0, 40).map((l) => (
|
||
<tr key={l.id}>
|
||
<td>{formatDateLong(l.created_at)}</td>
|
||
<td>{l.entity_type} · {l.entity_id}</td>
|
||
<td>{l.action}</td>
|
||
<td>{l.user_name || l.user_id || '-'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ marginTop: '20px', borderTop: '1px solid #263548', paddingTop: '16px' }}>
|
||
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Automations</div>
|
||
{automationsLoading ? (
|
||
<SkeletonBlock lines={4} />
|
||
) : (
|
||
<>
|
||
{automationRules.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '10px 0' }}>No automation rules</div>
|
||
) : (
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th>Rule</th>
|
||
<th>Trigger</th>
|
||
<th>Status</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{automationRules.map((r) => (
|
||
<tr key={r.id}>
|
||
<td>{r.name}</td>
|
||
<td>{r.trigger_type}</td>
|
||
<td>{r.enabled ? 'Enabled' : 'Disabled'}</td>
|
||
<td>
|
||
<button type="button" className="button-secondary" onClick={() => handleToggleAutomationRule(r)}>
|
||
{r.enabled ? 'Disable' : 'Enable'}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
<div style={{ marginTop: '10px', fontSize: '12px', color: '#8ea2b7' }}>Recent automation runs</div>
|
||
{automationRuns.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '8px 0' }}>No runs yet</div>
|
||
) : (
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th>When</th>
|
||
<th>Investor</th>
|
||
<th>Status</th>
|
||
<th>Result</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{automationRuns.slice(0, 12).map((r) => (
|
||
<tr key={r.id}>
|
||
<td>{formatDateLong(r.created_at)}</td>
|
||
<td>{r.investor_name || r.investor_id || '-'}</td>
|
||
<td>{r.status}</td>
|
||
<td style={{ fontFamily: 'IBM Plex Mono, monospace', fontSize: '11px' }}>{JSON.stringify(r.result_json || {})}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ marginTop: '20px', borderTop: '1px solid #263548', paddingTop: '16px' }}>
|
||
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Reliability Checks</div>
|
||
<button type="button" className="button-secondary" onClick={handleVerifyBackups} disabled={backupVerifyLoading}>
|
||
{backupVerifyLoading ? <Spinner /> : 'Verify Backup Files'}
|
||
</button>
|
||
{backupVerifyResult && (
|
||
<div style={{ marginTop: '10px', fontSize: '12px', color: '#8ea2b7' }}>
|
||
Checked {backupVerifyResult.checked} backups. Valid: {backupVerifyResult.valid}. Invalid: {backupVerifyResult.invalid_count}.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ marginTop: '20px', borderTop: '1px solid #263548', paddingTop: '16px' }}>
|
||
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Activity Feed</div>
|
||
{activityLoading ? (
|
||
<SkeletonBlock lines={4} />
|
||
) : activityFeed.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '10px 0' }}>No activity yet</div>
|
||
) : (
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th>When</th>
|
||
<th>Source</th>
|
||
<th>Entity</th>
|
||
<th>Action</th>
|
||
<th>Actor</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{activityFeed.slice(0, 30).map((a) => (
|
||
<tr key={a.id}>
|
||
<td>{formatDateLong(a.created_at)}</td>
|
||
<td>{a.source}</td>
|
||
<td>{a.entity_type} · {a.entity_id}</td>
|
||
<td>{a.action}</td>
|
||
<td>{a.actor_name || '-'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ marginTop: '20px', borderTop: '1px solid #263548', paddingTop: '16px' }}>
|
||
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Security</div>
|
||
{securityLoading ? (
|
||
<SkeletonBlock lines={2} />
|
||
) : securityStatus ? (
|
||
<div style={{ fontSize: '12px', color: '#8ea2b7', display: 'grid', gap: '4px' }}>
|
||
<div>Environment: {securityStatus.env}</div>
|
||
<div>CORS: {securityStatus.cors_origin}</div>
|
||
<div>Custom secret set: {securityStatus.has_custom_secret ? 'Yes' : 'No'}</div>
|
||
<div>Rate limits: login {securityStatus.rate_limits?.login_per_min || '-'} /min, writes {securityStatus.rate_limits?.write_per_min || '-'} /min</div>
|
||
{Array.isArray(securityStatus.warnings) && securityStatus.warnings.map((w, i) => (
|
||
<div key={i} style={{ color: '#d9a15f' }}>Warning: {w}</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="empty-state" style={{ padding: '10px 0' }}>No security info</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const THESIS_STATUS_BADGE = {
|
||
canonical: 'badge-funded',
|
||
in_review: 'badge-meeting',
|
||
draft: 'badge-other',
|
||
superseded: 'badge-low',
|
||
archived: 'badge-low'
|
||
};
|
||
|
||
const thesisStatusClass = (status) => THESIS_STATUS_BADGE[status] || 'badge-other';
|
||
|
||
const formatThesisStatus = (status) => String(status || '').replace(/_/g, ' ');
|
||
|
||
const ThesisVersionDetail = ({ token, versionId, user, onShowToast, onReviewed }) => {
|
||
const [data, setData] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [reviewForm, setReviewForm] = useState({ decision: 'approve', feedback: '' });
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const isAdmin = user?.role === 'admin';
|
||
|
||
const loadVersion = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
const result = await api(`/api/thesis/versions/${versionId}`, {}, token);
|
||
setData(result);
|
||
setError('');
|
||
} catch (err) {
|
||
setError(getErrorMessage(err, 'Failed to load thesis version'));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [token, versionId]);
|
||
|
||
useEffect(() => {
|
||
loadVersion();
|
||
}, [loadVersion]);
|
||
|
||
const handleSubmitReview = async (e) => {
|
||
e.preventDefault();
|
||
if (!isAdmin) return;
|
||
setSubmitting(true);
|
||
try {
|
||
await api(`/api/thesis/versions/${versionId}/review`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
decision: reviewForm.decision,
|
||
feedback: reviewForm.feedback
|
||
})
|
||
}, token);
|
||
setReviewForm({ decision: 'approve', feedback: '' });
|
||
onShowToast('Review submitted', 'success');
|
||
await loadVersion();
|
||
if (onReviewed) onReviewed();
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to submit review'), 'error');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
if (loading) return <div style={{ padding: '4px 0' }}><SkeletonBlock lines={6} /></div>;
|
||
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
|
||
if (!data) return <div className="empty-state" style={{ padding: '20px 0' }}>No content</div>;
|
||
|
||
const body = data.body || {};
|
||
const pillars = Array.isArray(body.pillars) ? body.pillars : [];
|
||
const claims = Array.isArray(body.claims) ? body.claims : [];
|
||
const proofPoints = Array.isArray(body.proof_points) ? body.proof_points : [];
|
||
const objections = Array.isArray(body.objections) ? body.objections : [];
|
||
const segmentCuts = Array.isArray(body.segment_cuts) ? body.segment_cuts : [];
|
||
const reviews = Array.isArray(data.reviews) ? data.reviews : [];
|
||
const approvals = data.approvals ?? 0;
|
||
const required = data.required ?? 0;
|
||
|
||
const renderTextOrLabel = (item) => {
|
||
if (item == null) return '-';
|
||
if (typeof item === 'string') return item;
|
||
return item.text || item.label || item.claim || item.title || JSON.stringify(item);
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '10px', marginBottom: '12px' }}>
|
||
<div style={{ fontWeight: 600, fontSize: '15px' }}>
|
||
{data.line_key} · v{data.version_no}
|
||
<span className={`badge ${thesisStatusClass(data.status)}`} style={{ marginLeft: '10px' }}>{formatThesisStatus(data.status)}</span>
|
||
</div>
|
||
<div className="approval-meter">
|
||
<span className="approval-meter-count">{approvals} / {required}</span>
|
||
<span className="approval-meter-label">approvals</span>
|
||
</div>
|
||
</div>
|
||
|
||
{body.throughline && (
|
||
<div className="thesis-block">
|
||
<div className="thesis-block-label">Throughline</div>
|
||
<div className="thesis-throughline">{body.throughline}</div>
|
||
</div>
|
||
)}
|
||
|
||
{pillars.length > 0 && (
|
||
<div className="thesis-block">
|
||
<div className="thesis-block-label">Pillars</div>
|
||
<ul className="thesis-list">
|
||
{pillars.map((p, i) => (
|
||
<li key={i}>
|
||
<span className="thesis-list-title">{renderTextOrLabel(p)}</span>
|
||
{p && typeof p === 'object' && p.detail && <div className="thesis-list-detail">{p.detail}</div>}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{claims.length > 0 && (
|
||
<div className="thesis-block">
|
||
<div className="thesis-block-label">Claims</div>
|
||
<ul className="thesis-list">
|
||
{claims.map((c, i) => (<li key={i}>{renderTextOrLabel(c)}</li>))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{proofPoints.length > 0 && (
|
||
<div className="thesis-block">
|
||
<div className="thesis-block-label">Proof Points</div>
|
||
<ul className="thesis-list">
|
||
{proofPoints.map((p, i) => (<li key={i}>{renderTextOrLabel(p)}</li>))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{objections.length > 0 && (
|
||
<div className="thesis-block">
|
||
<div className="thesis-block-label">Objections</div>
|
||
<ul className="thesis-list">
|
||
{objections.map((o, i) => (<li key={i}>{renderTextOrLabel(o)}</li>))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{segmentCuts.length > 0 && (
|
||
<div className="thesis-block">
|
||
<div className="thesis-block-label">Segment Cuts</div>
|
||
<ul className="thesis-list">
|
||
{segmentCuts.map((s, i) => (<li key={i}>{renderTextOrLabel(s)}</li>))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
<div className="thesis-block">
|
||
<div className="thesis-block-label">Reviews & Feedback</div>
|
||
{reviews.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '16px 0' }}>No reviews yet.</div>
|
||
) : (
|
||
<div className="timeline">
|
||
{reviews.map((r, i) => (
|
||
<div key={i} className="timeline-item">
|
||
<div className="timeline-marker"></div>
|
||
<div className="timeline-content">
|
||
<div className="timeline-header">
|
||
{formatThesisStatus(r.decision)}
|
||
<span className="timeline-meta" style={{ marginLeft: '8px' }}>{r.reviewer_user_id || 'reviewer'}</span>
|
||
</div>
|
||
<div className="timeline-meta">{formatDateLong(r.created_at)}</div>
|
||
{r.feedback && <div className="timeline-body">{r.feedback}</div>}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{isAdmin ? (
|
||
<form onSubmit={handleSubmitReview} className="thesis-review-form">
|
||
<div className="thesis-block-label">Submit Review</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Decision</label>
|
||
<select
|
||
className="select-input"
|
||
value={reviewForm.decision}
|
||
onChange={(e) => setReviewForm((f) => ({ ...f, decision: e.target.value }))}
|
||
>
|
||
<option value="approve">Approve</option>
|
||
<option value="request_changes">Request changes</option>
|
||
<option value="comment">Comment</option>
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Feedback</label>
|
||
<textarea
|
||
className="text-input"
|
||
rows="4"
|
||
placeholder="Share your reasoning, concerns, or sign-off notes"
|
||
value={reviewForm.feedback}
|
||
onChange={(e) => setReviewForm((f) => ({ ...f, feedback: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="form-actions">
|
||
<button type="submit" disabled={submitting}>
|
||
{submitting ? 'Submitting…' : 'Submit Review'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
) : (
|
||
<div className="form-help" style={{ marginTop: '16px' }}>Only admins can submit a review. Two distinct approvals promote a version to canonical.</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const ThesisPage = ({ token, user, onShowToast }) => {
|
||
const [lines, setLines] = useState([]);
|
||
const [versions, setVersions] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [selectedVersionId, setSelectedVersionId] = useState(null);
|
||
const [refreshTick, setRefreshTick] = useState(0);
|
||
|
||
useEffect(() => {
|
||
const load = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const [linesResult, versionsResult] = await Promise.all([
|
||
api('/api/thesis/lines', {}, token),
|
||
api('/api/thesis/versions', {}, token)
|
||
]);
|
||
const lineList = linesResult.lines || [];
|
||
const versionList = versionsResult.versions || [];
|
||
setLines(lineList);
|
||
setVersions(versionList);
|
||
setError('');
|
||
setSelectedVersionId((prev) => {
|
||
if (prev && versionList.some((v) => v.id === prev)) return prev;
|
||
return versionList[0]?.id ?? null;
|
||
});
|
||
} catch (err) {
|
||
setError(getErrorMessage(err, 'Failed to load thesis data'));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
load();
|
||
}, [token, refreshTick]);
|
||
|
||
if (loading) return <div style={{ padding: '20px' }}><SkeletonBlock lines={8} /></div>;
|
||
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
|
||
|
||
return (
|
||
<div className="page-container">
|
||
<h2 className="section-title" style={{ marginBottom: '20px' }}>Thesis</h2>
|
||
|
||
<div className="thesis-layout">
|
||
<div className="thesis-col">
|
||
<div className="section">
|
||
<div className="section-title">Thesis Lines</div>
|
||
{lines.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '16px 0' }}>No thesis lines yet.</div>
|
||
) : (
|
||
<div className="thesis-line-list">
|
||
{lines.map((line) => (
|
||
<div key={line.id} className="thesis-line-row">
|
||
<div>
|
||
<div className="thesis-line-name">
|
||
{line.name}
|
||
{!!line.is_core && <span className="badge badge-investor" style={{ marginLeft: '8px' }}>Core</span>}
|
||
</div>
|
||
<div className="thesis-line-meta">{line.line_key}{line.segment_key ? ` · ${line.segment_key}` : ''}</div>
|
||
</div>
|
||
<span className={`badge ${thesisStatusClass(line.status)}`}>{formatThesisStatus(line.status)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="section">
|
||
<div className="section-title">In-Review Queue</div>
|
||
{versions.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '16px 0' }}>Nothing awaiting review.</div>
|
||
) : (
|
||
<div className="thesis-line-list">
|
||
{versions.map((v) => (
|
||
<button
|
||
key={v.id}
|
||
className={`thesis-version-row ${selectedVersionId === v.id ? 'active' : ''}`}
|
||
onClick={() => setSelectedVersionId(v.id)}
|
||
>
|
||
<div>
|
||
<div className="thesis-line-name">{v.name || v.line_key} · v{v.version_no}</div>
|
||
<div className="thesis-line-meta">
|
||
{v.line_key} · {formatDate(v.created_at)}
|
||
{v.rationale ? ` · ${v.rationale}` : ''}
|
||
</div>
|
||
</div>
|
||
<span className="approval-pill">{v.approvals ?? 0}/{v.required ?? 0}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="thesis-col">
|
||
<div className="section">
|
||
<div className="section-title">Version Review</div>
|
||
{selectedVersionId == null ? (
|
||
<div className="empty-state" style={{ padding: '24px 0' }}>
|
||
<div className="empty-state-icon">◷</div>
|
||
Select a version from the review queue to read it and submit feedback.
|
||
</div>
|
||
) : (
|
||
<ThesisVersionDetail
|
||
key={selectedVersionId}
|
||
token={token}
|
||
user={user}
|
||
versionId={selectedVersionId}
|
||
onShowToast={onShowToast}
|
||
onReviewed={() => setRefreshTick((t) => t + 1)}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Friendly label for the various node types in a thesis tree.
|
||
const THESIS_NODE_TYPE_LABEL = {
|
||
thesis_root: 'Thesis',
|
||
throughline: 'Throughline',
|
||
section: 'Section',
|
||
claim: 'Claim',
|
||
proof_point: 'Proof Point',
|
||
objection: 'Objection',
|
||
segment_cut: 'Segment Cut'
|
||
};
|
||
|
||
const formatNodeType = (t) => THESIS_NODE_TYPE_LABEL[t] || formatThesisStatus(t);
|
||
|
||
// Detect the server's "Architect not connected" signal so we can show a
|
||
// calm connect-the-Architect message instead of a raw 502.
|
||
const isArchitectMissingKeyError = (err) => {
|
||
const status = err?.status;
|
||
const payload = err?.payload || {};
|
||
const haystack = `${err?.message || ''} ${payload.error || ''} ${payload.reason || ''} ${payload.detail || ''}`.toLowerCase();
|
||
return status === 502 || haystack.includes('anthropic_api_key') || haystack.includes('api key') || haystack.includes('not configured');
|
||
};
|
||
|
||
const ARCHITECT_CONNECT_HINT = "The Architect isn't connected yet — add your Anthropic API key on the server to enable generation. You can still view and edit the thesis.";
|
||
|
||
// Renders a single node's competing options (its variant group). The
|
||
// number of options is whatever the server returns — could be 0, 1, or
|
||
// many — and is rendered as a dynamic list, never a fixed A/B layout.
|
||
const ThesisWorkshopOptions = ({ token, node, lineKey, isAdmin, architectReady, onShowToast, onTreeChanged }) => {
|
||
const nodeId = node.id;
|
||
const [data, setData] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [genN, setGenN] = useState(3);
|
||
const [guidance, setGuidance] = useState('');
|
||
const [generating, setGenerating] = useState(false);
|
||
const [editingId, setEditingId] = useState(null);
|
||
const [editText, setEditText] = useState('');
|
||
const [busyId, setBusyId] = useState(null);
|
||
|
||
const load = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
const result = await api(`/api/thesis/nodes/${nodeId}/variants`, {}, token);
|
||
setData(result);
|
||
setError('');
|
||
} catch (err) {
|
||
setError(getErrorMessage(err, 'Failed to load options'));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [token, nodeId]);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const handleGenerate = async (e) => {
|
||
e.preventDefault();
|
||
if (!isAdmin || generating) return;
|
||
const n = Math.max(1, Math.min(8, parseInt(genN, 10) || 1));
|
||
try {
|
||
setGenerating(true);
|
||
const result = await api(`/api/thesis/nodes/${nodeId}/generate`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ n, guidance: guidance.trim() })
|
||
}, token);
|
||
const count = result?.data?.generated ?? (result?.data?.options || []).length;
|
||
onShowToast(`The Architect drafted ${count} option${count === 1 ? '' : 's'}`, 'success');
|
||
setGuidance('');
|
||
await refresh();
|
||
} catch (err) {
|
||
if (isArchitectMissingKeyError(err)) {
|
||
onShowToast(ARCHITECT_CONNECT_HINT, 'error');
|
||
} else {
|
||
onShowToast(getErrorMessage(err, 'Failed to generate options'), 'error');
|
||
}
|
||
} finally {
|
||
setGenerating(false);
|
||
}
|
||
};
|
||
|
||
const refresh = async () => { await load(); if (onTreeChanged) onTreeChanged(); };
|
||
|
||
const chooseOption = async (optId) => {
|
||
if (busyId) return;
|
||
try {
|
||
setBusyId(optId);
|
||
await api(`/api/thesis/nodes/${optId}/choose`, { method: 'POST' }, token);
|
||
onShowToast('Set as the chosen wording', 'success');
|
||
await refresh();
|
||
} catch (err) { onShowToast(getErrorMessage(err, 'Could not choose this option'), 'error'); }
|
||
finally { setBusyId(null); }
|
||
};
|
||
|
||
const deleteOption = async (optId) => {
|
||
if (busyId) return;
|
||
if (!window.confirm('Delete this and anything under it? This can be undone by an admin.')) return;
|
||
try {
|
||
setBusyId(optId);
|
||
await api(`/api/thesis/nodes/${optId}`, { method: 'DELETE' }, token);
|
||
onShowToast('Deleted', 'success');
|
||
await refresh();
|
||
} catch (err) { onShowToast(getErrorMessage(err, 'Could not delete'), 'error'); }
|
||
finally { setBusyId(null); }
|
||
};
|
||
|
||
const saveEdit = async (optId) => {
|
||
if (busyId) return;
|
||
try {
|
||
setBusyId(optId);
|
||
await api(`/api/thesis/nodes/${optId}`, { method: 'PUT', body: JSON.stringify({ body: editText }) }, token);
|
||
setEditingId(null);
|
||
onShowToast('Saved', 'success');
|
||
await refresh();
|
||
} catch (err) { onShowToast(getErrorMessage(err, 'Could not save'), 'error'); }
|
||
finally { setBusyId(null); }
|
||
};
|
||
|
||
if (loading) return <div style={{ padding: '4px 0' }}><SkeletonBlock lines={3} /></div>;
|
||
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
|
||
|
||
const options = Array.isArray(data?.variants) ? data.variants : [];
|
||
const multi = options.length > 1;
|
||
const busy = generating;
|
||
|
||
return (
|
||
<div>
|
||
{options.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '18px 0' }}>
|
||
No wording yet for this part.{isAdmin && architectReady ? ' Ask the Architect below, or write your own.' : ''}
|
||
</div>
|
||
) : (
|
||
<div className="thesis-ws-options">
|
||
{options.map((opt, i) => {
|
||
const meta = opt.meta || {};
|
||
const rationale = meta.rationale || meta.reason || meta.notes || '';
|
||
const editing = editingId === opt.id;
|
||
const oBusy = busyId === opt.id;
|
||
return (
|
||
<div key={opt.id ?? i} className="thesis-ws-option">
|
||
<div className="thesis-ws-option-head">
|
||
<span className="thesis-ws-option-num">{multi ? `Option ${i + 1} of ${options.length}` : 'Current wording'}</span>
|
||
{opt.status && (
|
||
<span className={`badge ${thesisStatusClass(opt.status)}`}>{formatThesisStatus(opt.status)}</span>
|
||
)}
|
||
</div>
|
||
{editing ? (
|
||
<textarea className="text-input" rows="5" value={editText} onChange={(e) => setEditText(e.target.value)} />
|
||
) : (
|
||
<div className="thesis-ws-option-text">{opt.body || '—'}</div>
|
||
)}
|
||
{rationale && !editing && (
|
||
<div className="thesis-ws-rationale">
|
||
<div className="thesis-ws-rationale-label">Architect's rationale</div>
|
||
{rationale}
|
||
</div>
|
||
)}
|
||
{isAdmin && (
|
||
<div style={{ display: 'flex', gap: '8px', marginTop: '8px', flexWrap: 'wrap' }}>
|
||
{editing ? (
|
||
<>
|
||
<button type="button" disabled={oBusy} onClick={() => saveEdit(opt.id)}>{oBusy ? 'Saving…' : 'Save'}</button>
|
||
<button type="button" className="button-secondary" onClick={() => setEditingId(null)}>Cancel</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
{multi && <button type="button" disabled={oBusy} onClick={() => chooseOption(opt.id)}>Use this</button>}
|
||
<button type="button" className="button-secondary" disabled={oBusy} onClick={() => { setEditingId(opt.id); setEditText(opt.body || ''); }}>Edit</button>
|
||
<button type="button" className="button-danger" disabled={oBusy} onClick={() => deleteOption(opt.id)}>Delete</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{isAdmin ? (
|
||
<form className="thesis-ws-control-col" onSubmit={handleGenerate} style={{ marginTop: '14px' }}>
|
||
<div className="thesis-block-label">Ask the Architect for options</div>
|
||
<textarea
|
||
className="text-input"
|
||
rows="3"
|
||
placeholder="Optional — tell it what to try: sharper, shorter, a different angle, answer a specific objection…"
|
||
value={guidance}
|
||
onChange={(e) => setGuidance(e.target.value)}
|
||
disabled={busy}
|
||
/>
|
||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<input
|
||
type="number"
|
||
className="text-input thesis-ws-n-input"
|
||
min="1"
|
||
max="8"
|
||
value={genN}
|
||
onChange={(e) => setGenN(e.target.value)}
|
||
aria-label="How many options"
|
||
disabled={busy}
|
||
/>
|
||
<span className="form-help" style={{ marginTop: 0 }}>option(s)</span>
|
||
<button type="submit" disabled={busy || !architectReady}>
|
||
{generating ? 'Architect is drafting…' : 'Generate'}
|
||
</button>
|
||
</div>
|
||
{!architectReady && (
|
||
<div className="form-help">Generation needs the Architect connected.</div>
|
||
)}
|
||
</form>
|
||
) : (
|
||
<div className="form-help" style={{ marginTop: '14px' }}>Only admins can edit, choose, or generate options.</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// One node in the tree. Renders its header (click to select + reveal its
|
||
// options), its options when selected, then recurses into children.
|
||
const ThesisWorkshopNode = ({ token, node, lineKey, depth, isAdmin, architectReady, selectedId, onSelect, onShowToast, onTreeChanged }) => {
|
||
const isSelected = selectedId === node.id;
|
||
const children = Array.isArray(node.children) ? node.children : [];
|
||
const variantGroup = node.variant_group;
|
||
|
||
return (
|
||
<div className={`thesis-ws-node ${isSelected ? 'selected' : ''}`}>
|
||
<button
|
||
type="button"
|
||
className="thesis-ws-node-head"
|
||
onClick={() => onSelect(isSelected ? null : node.id)}
|
||
>
|
||
<div style={{ minWidth: 0 }}>
|
||
<div className="thesis-ws-node-type">{formatNodeType(node.node_type)}</div>
|
||
<div className="thesis-ws-node-title">{node.title || node.body || formatNodeType(node.node_type)}</div>
|
||
{node.body && node.title && node.body !== node.title && (
|
||
<div className="thesis-line-meta">{node.body}</div>
|
||
)}
|
||
</div>
|
||
<span className="thesis-ws-count">{isSelected ? 'Hide options' : 'Options'}</span>
|
||
</button>
|
||
|
||
{isSelected && (
|
||
<div className="thesis-ws-body">
|
||
<ThesisWorkshopOptions
|
||
key={variantGroup || node.id}
|
||
token={token}
|
||
node={node}
|
||
lineKey={lineKey}
|
||
isAdmin={isAdmin}
|
||
architectReady={architectReady}
|
||
onShowToast={onShowToast}
|
||
onTreeChanged={onTreeChanged}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{children.length > 0 && (
|
||
<div className="thesis-ws-children">
|
||
{children.map((child) => (
|
||
<ThesisWorkshopNode
|
||
key={child.id}
|
||
token={token}
|
||
node={child}
|
||
lineKey={lineKey}
|
||
depth={(depth || 0) + 1}
|
||
isAdmin={isAdmin}
|
||
architectReady={architectReady}
|
||
selectedId={selectedId}
|
||
onSelect={onSelect}
|
||
onShowToast={onShowToast}
|
||
onTreeChanged={onTreeChanged}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const ThesisWorkshopPage = ({ token, user, onShowToast }) => {
|
||
const isAdmin = user?.role === 'admin';
|
||
const [lines, setLines] = useState([]);
|
||
const [linesLoading, setLinesLoading] = useState(true);
|
||
const [linesError, setLinesError] = useState('');
|
||
const [selectedLineKey, setSelectedLineKey] = useState(null);
|
||
|
||
const [tree, setTree] = useState(null);
|
||
const [treeLoading, setTreeLoading] = useState(false);
|
||
const [treeError, setTreeError] = useState('');
|
||
const [selectedNodeId, setSelectedNodeId] = useState(null);
|
||
const [treeTick, setTreeTick] = useState(0);
|
||
|
||
const [architect, setArchitect] = useState(null);
|
||
const [canonical, setCanonical] = useState(null);
|
||
const [approving, setApproving] = useState(false);
|
||
|
||
// Seed-a-line form (shown when there are no lines).
|
||
const [showNewLine, setShowNewLine] = useState(false);
|
||
const [newLine, setNewLine] = useState({ line_key: '', name: '', is_core: false, description: '' });
|
||
const [creatingLine, setCreatingLine] = useState(false);
|
||
|
||
// Seed-a-node controls (shown when a line has an empty tree).
|
||
const [seedTitle, setSeedTitle] = useState('');
|
||
const [seedType, setSeedType] = useState('throughline');
|
||
const [seeding, setSeeding] = useState(false);
|
||
|
||
// Architect status — non-blocking; failure just means "unknown",
|
||
// we still let the partner view/edit the thesis.
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
const result = await api('/api/architect/status', {}, token);
|
||
if (!cancelled) setArchitect(result.data || result);
|
||
} catch (err) {
|
||
if (!cancelled) setArchitect({ ready: false, reason: getErrorMessage(err, 'Status unavailable') });
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [token]);
|
||
|
||
const loadLines = useCallback(async () => {
|
||
try {
|
||
setLinesLoading(true);
|
||
const result = await api('/api/thesis/lines', {}, token);
|
||
const lineList = result.lines || [];
|
||
setLines(lineList);
|
||
setLinesError('');
|
||
setSelectedLineKey((prev) => {
|
||
if (prev && lineList.some((l) => l.line_key === prev)) return prev;
|
||
return lineList[0]?.line_key ?? null;
|
||
});
|
||
} catch (err) {
|
||
setLinesError(getErrorMessage(err, 'Failed to load thesis lines'));
|
||
} finally {
|
||
setLinesLoading(false);
|
||
}
|
||
}, [token]);
|
||
|
||
useEffect(() => { loadLines(); }, [loadLines]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedLineKey) { setTree(null); return undefined; }
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
setTreeLoading(true);
|
||
const result = await api(`/api/thesis/${selectedLineKey}/tree`, {}, token);
|
||
if (cancelled) return;
|
||
setTree(result);
|
||
setTreeError('');
|
||
setSelectedNodeId(null);
|
||
} catch (err) {
|
||
if (!cancelled) setTreeError(getErrorMessage(err, 'Failed to load thesis tree'));
|
||
} finally {
|
||
if (!cancelled) setTreeLoading(false);
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [token, selectedLineKey, treeTick]);
|
||
|
||
// Is the selected line already someone's current (canonical) thesis?
|
||
useEffect(() => {
|
||
if (!selectedLineKey) { setCanonical(null); return undefined; }
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
const r = await api(`/api/thesis/${selectedLineKey}/canonical`, {}, token);
|
||
if (!cancelled) setCanonical(r.data || r);
|
||
} catch (err) {
|
||
if (!cancelled) setCanonical(null);
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [token, selectedLineKey, treeTick]);
|
||
|
||
const handleApproveLine = async () => {
|
||
if (!isAdmin || approving || !selectedLineKey) return;
|
||
try {
|
||
setApproving(true);
|
||
const r = await api(`/api/thesis/lines/${selectedLineKey}/approve`, { method: 'POST' }, token);
|
||
const d = r.data || r;
|
||
if (d.promoted_to_canonical) {
|
||
onShowToast('Approved — this is now your current thesis.', 'success');
|
||
} else {
|
||
const left = Math.max(0, (d.required || 2) - (d.approvals || 0));
|
||
onShowToast(`Approved (${d.approvals} of ${d.required}). ${left} more admin approval${left === 1 ? '' : 's'} needed.`, 'success');
|
||
}
|
||
setTreeTick((t) => t + 1);
|
||
await loadLines();
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Could not approve'), 'error');
|
||
} finally {
|
||
setApproving(false);
|
||
}
|
||
};
|
||
|
||
const handleCreateLine = async (e) => {
|
||
e.preventDefault();
|
||
if (!isAdmin || creatingLine) return;
|
||
if (!newLine.line_key.trim() || !newLine.name.trim()) {
|
||
onShowToast('A key and name are required', 'error');
|
||
return;
|
||
}
|
||
try {
|
||
setCreatingLine(true);
|
||
await api('/api/thesis/lines', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
line_key: newLine.line_key.trim(),
|
||
name: newLine.name.trim(),
|
||
is_core: !!newLine.is_core,
|
||
description: newLine.description.trim()
|
||
})
|
||
}, token);
|
||
onShowToast('Thesis line created', 'success');
|
||
const createdKey = newLine.line_key.trim();
|
||
setNewLine({ line_key: '', name: '', is_core: false, description: '' });
|
||
setShowNewLine(false);
|
||
await loadLines();
|
||
setSelectedLineKey(createdKey);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to create thesis line'), 'error');
|
||
} finally {
|
||
setCreatingLine(false);
|
||
}
|
||
};
|
||
|
||
const handleSeedNode = async (e) => {
|
||
e.preventDefault();
|
||
if (!isAdmin || seeding || !selectedLineKey) return;
|
||
if (!seedTitle.trim()) {
|
||
onShowToast('Give the new part a title', 'error');
|
||
return;
|
||
}
|
||
try {
|
||
setSeeding(true);
|
||
await api(`/api/thesis/lines/${selectedLineKey}/nodes`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ node_type: seedType, title: seedTitle.trim(), body: '' })
|
||
}, token);
|
||
onShowToast('Added to the thesis', 'success');
|
||
setSeedTitle('');
|
||
setTreeTick((t) => t + 1);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to add part'), 'error');
|
||
} finally {
|
||
setSeeding(false);
|
||
}
|
||
};
|
||
|
||
const architectReady = !!architect?.ready;
|
||
const nodes = Array.isArray(tree?.tree) ? tree.tree : [];
|
||
|
||
return (
|
||
<div className="page-container">
|
||
<h2 className="section-title" style={{ marginBottom: '20px' }}>Thesis Workshop</h2>
|
||
|
||
{architect && (
|
||
<div className={`thesis-ws-banner ${architectReady ? 'ready' : ''}`}>
|
||
<span className="thesis-ws-banner-icon">{architectReady ? '◆' : 'ⓘ'}</span>
|
||
<div>
|
||
{architectReady ? (
|
||
<>The Architect is connected{architect.model ? ` (${architect.model})` : ''} and ready to draft options with you.</>
|
||
) : (
|
||
<>{ARCHITECT_CONNECT_HINT}{architect.reason ? ` (${architect.reason})` : ''}</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="thesis-layout">
|
||
<div className="thesis-col">
|
||
<div className="section">
|
||
<div className="section-title">Thesis Lines</div>
|
||
{linesLoading ? (
|
||
<SkeletonBlock lines={4} />
|
||
) : linesError ? (
|
||
<div className="toast error" style={{ position: 'static' }}>{linesError}</div>
|
||
) : lines.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '20px 0' }}>
|
||
<div className="empty-state-icon">§</div>
|
||
No thesis lines yet.
|
||
{isAdmin ? ' Seed one to start working with the Architect.' : ' An admin needs to create one.'}
|
||
</div>
|
||
) : (
|
||
<div className="thesis-line-list">
|
||
{lines.map((line) => (
|
||
<button
|
||
key={line.id}
|
||
className={`thesis-version-row ${selectedLineKey === line.line_key ? 'active' : ''}`}
|
||
onClick={() => setSelectedLineKey(line.line_key)}
|
||
>
|
||
<div style={{ minWidth: 0 }}>
|
||
<div className="thesis-line-name">
|
||
{line.name}
|
||
{!!line.is_core && <span className="badge badge-investor" style={{ marginLeft: '8px' }}>Core</span>}
|
||
</div>
|
||
<div className="thesis-line-meta">{line.line_key}{line.segment_key ? ` · ${line.segment_key}` : ''}</div>
|
||
</div>
|
||
<span className={`badge ${thesisStatusClass(line.status)}`}>{formatThesisStatus(line.status)}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{isAdmin && !linesLoading && (
|
||
<div style={{ marginTop: '14px' }}>
|
||
{showNewLine ? (
|
||
<form onSubmit={handleCreateLine} className="thesis-review-form" style={{ marginTop: 0 }}>
|
||
<div className="form-group">
|
||
<label className="form-label">Key</label>
|
||
<input
|
||
className="text-input"
|
||
placeholder="e.g. bitcoin_energy"
|
||
value={newLine.line_key}
|
||
onChange={(e) => setNewLine((f) => ({ ...f, line_key: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Name</label>
|
||
<input
|
||
className="text-input"
|
||
placeholder="Human-friendly name"
|
||
value={newLine.name}
|
||
onChange={(e) => setNewLine((f) => ({ ...f, name: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Description</label>
|
||
<textarea
|
||
className="text-input"
|
||
rows="2"
|
||
placeholder="Optional one-liner"
|
||
value={newLine.description}
|
||
onChange={(e) => setNewLine((f) => ({ ...f, description: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<label className="form-help" style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={newLine.is_core}
|
||
onChange={(e) => setNewLine((f) => ({ ...f, is_core: e.target.checked }))}
|
||
/>
|
||
Mark as a core thesis line
|
||
</label>
|
||
<div className="form-actions">
|
||
<button type="button" className="button-secondary" onClick={() => setShowNewLine(false)} disabled={creatingLine}>Cancel</button>
|
||
<button type="submit" disabled={creatingLine}>{creatingLine ? 'Creating…' : 'Create line'}</button>
|
||
</div>
|
||
</form>
|
||
) : (
|
||
<button className="button-secondary" style={{ width: '100%' }} onClick={() => setShowNewLine(true)}>+ New thesis line</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="thesis-col">
|
||
<div className="section">
|
||
<div className="section-title">Workshop</div>
|
||
{selectedLineKey && (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', flexWrap: 'wrap', marginBottom: '14px', paddingBottom: '12px', borderBottom: '1px solid #1e2a3a' }}>
|
||
<div className="form-help" style={{ marginTop: 0, maxWidth: '60%' }}>
|
||
Click any part to draft and compare wordings, pick the one you like, then approve the line as your current thesis.
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||
{canonical && canonical.status === 'ok' && (
|
||
<span className="badge badge-investor">Current ✓{canonical.version_no ? ` · v${canonical.version_no}` : ''}</span>
|
||
)}
|
||
{isAdmin && (
|
||
<button type="button" disabled={approving} onClick={handleApproveLine}>
|
||
{approving ? 'Approving…' : (canonical && canonical.status === 'ok' ? 'Re-approve as current' : 'Approve as current')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{!selectedLineKey ? (
|
||
<div className="empty-state" style={{ padding: '24px 0' }}>
|
||
<div className="empty-state-icon">◷</div>
|
||
Pick a thesis line on the left to start iterating on it.
|
||
</div>
|
||
) : treeLoading ? (
|
||
<SkeletonBlock lines={6} />
|
||
) : treeError ? (
|
||
<div className="toast error" style={{ position: 'static' }}>{treeError}</div>
|
||
) : (
|
||
<div>
|
||
{nodes.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '20px 0' }}>
|
||
This line has no content yet.
|
||
{isAdmin ? ' Add a throughline or a pillar to seed it.' : ' An admin needs to seed it.'}
|
||
</div>
|
||
) : (
|
||
<div>
|
||
{nodes.map((node) => (
|
||
<ThesisWorkshopNode
|
||
key={node.id}
|
||
token={token}
|
||
node={node}
|
||
lineKey={selectedLineKey}
|
||
depth={0}
|
||
isAdmin={isAdmin}
|
||
architectReady={architectReady}
|
||
selectedId={selectedNodeId}
|
||
onSelect={setSelectedNodeId}
|
||
onShowToast={onShowToast}
|
||
onTreeChanged={() => setTreeTick((t) => t + 1)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{isAdmin && (
|
||
<form onSubmit={handleSeedNode} className="thesis-review-form">
|
||
<div className="thesis-block-label">Add a part</div>
|
||
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||
<div className="form-group" style={{ marginBottom: 0, flex: '0 0 auto' }}>
|
||
<label className="form-label">Type</label>
|
||
<select
|
||
className="select-input"
|
||
value={seedType}
|
||
onChange={(e) => setSeedType(e.target.value)}
|
||
>
|
||
<option value="throughline">Throughline</option>
|
||
<option value="section">Section / Pillar</option>
|
||
<option value="claim">Claim</option>
|
||
<option value="proof_point">Proof Point</option>
|
||
<option value="objection">Objection</option>
|
||
<option value="segment_cut">Segment Cut</option>
|
||
</select>
|
||
</div>
|
||
<div className="form-group" style={{ marginBottom: 0, flex: '1 1 200px' }}>
|
||
<label className="form-label">Title</label>
|
||
<input
|
||
className="text-input"
|
||
placeholder="Short name for this part"
|
||
value={seedTitle}
|
||
onChange={(e) => setSeedTitle(e.target.value)}
|
||
/>
|
||
</div>
|
||
<button type="submit" disabled={seeding}>{seeding ? 'Adding…' : 'Add'}</button>
|
||
</div>
|
||
<div className="form-help" style={{ marginTop: '8px' }}>
|
||
Each part holds its own set of competing options — one or many — that you grow with the Architect.
|
||
</div>
|
||
</form>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const OutreachPage = ({ token, user, onShowToast }) => {
|
||
const [investors, setInvestors] = useState([]);
|
||
const [investorId, setInvestorId] = useState('');
|
||
const [type, setType] = useState('follow_up');
|
||
const [guidance, setGuidance] = useState('');
|
||
const [drafting, setDrafting] = useState(false);
|
||
const [result, setResult] = useState(null);
|
||
const [draftText, setDraftText] = useState('');
|
||
const [radar, setRadar] = useState([]);
|
||
const [lastInvestor, setLastInvestor] = useState('');
|
||
const [gmailBusy, setGmailBusy] = useState(false);
|
||
const [gmailResult, setGmailResult] = useState(null);
|
||
const GMAIL_FAIL = {
|
||
no_recipient: "No email address on file for this investor, so there's nothing to address the draft to.",
|
||
integration_disabled: 'Gmail integration is off on the server.',
|
||
auth_error: 'Could not authorize Gmail — check the compose scope is authorized in Workspace admin.',
|
||
no_sender: 'Your account has no email on file to draft from.',
|
||
empty: 'The draft is empty.',
|
||
gmail_error: 'Gmail rejected the draft.',
|
||
};
|
||
const TYPES = [
|
||
['intro', 'Intro'],
|
||
['follow_up', 'Warm follow-up'],
|
||
['fund_update', 'Fund update'],
|
||
['meeting_follow_up', 'Meeting follow-up'],
|
||
['nurture', 'Nurture / stay in touch'],
|
||
];
|
||
const FAIL = {
|
||
not_found: 'That investor was not found.',
|
||
scrub_unavailable: 'The redaction boundary could not be prepared, so nothing was sent to Claude.',
|
||
claude_not_configured: 'The Architect (Claude) is not configured on the server.',
|
||
rehydrate_failed: 'The draft could not be safely personalized (an unexpected placeholder). Try again.',
|
||
};
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
const r = await api('/api/outreach/investors', {}, token);
|
||
if (!cancelled) setInvestors(Array.isArray(r?.investors) ? r.investors : []);
|
||
} catch (_) { /* none */ }
|
||
try {
|
||
const rr = await api('/api/outreach/radar', {}, token);
|
||
if (!cancelled) setRadar(Array.isArray(rr?.items) ? rr.items : []);
|
||
} catch (_) { /* none */ }
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [token]);
|
||
|
||
const draft = async (ovInvestor, ovType) => {
|
||
if (drafting) return;
|
||
const inv = ovInvestor || investorId;
|
||
const t = ovType || type;
|
||
if (!inv) { onShowToast('Pick an investor first', 'error'); return; }
|
||
try {
|
||
setDrafting(true);
|
||
setResult(null);
|
||
setGmailResult(null);
|
||
setLastInvestor(inv);
|
||
const res = await api('/api/outreach/draft', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ investor_id: inv, outreach_type: t, guidance }),
|
||
}, token);
|
||
const data = res.data || res;
|
||
setResult(data);
|
||
if (data.status === 'ok') setDraftText(data.draft || '');
|
||
} catch (err) {
|
||
const msg = getErrorMessage(err, 'Drafting failed');
|
||
setResult({ status: 'error', reason: msg });
|
||
onShowToast(msg, 'error');
|
||
} finally {
|
||
setDrafting(false);
|
||
}
|
||
};
|
||
|
||
const copy = async () => {
|
||
try { await navigator.clipboard.writeText(draftText); onShowToast('Draft copied', 'success'); }
|
||
catch (_) { onShowToast('Could not copy', 'error'); }
|
||
};
|
||
|
||
const createGmailDraft = async () => {
|
||
if (gmailBusy || !lastInvestor) return;
|
||
try {
|
||
setGmailBusy(true);
|
||
setGmailResult(null);
|
||
const res = await api('/api/outreach/gmail-draft', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ investor_id: lastInvestor, draft: draftText }),
|
||
}, token);
|
||
const d = res.data || res;
|
||
setGmailResult(d);
|
||
if (d.status === 'ok') onShowToast(d.threaded ? 'Reply draft created in your Gmail' : 'Draft created in your Gmail', 'success');
|
||
else onShowToast(GMAIL_FAIL[d.status] || d.reason || 'Could not create the draft', 'error');
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Could not create the draft'), 'error');
|
||
} finally {
|
||
setGmailBusy(false);
|
||
}
|
||
};
|
||
|
||
const ok = result && result.status === 'ok';
|
||
|
||
return (
|
||
<div className="page-container">
|
||
<h2 className="section-title" style={{ marginBottom: '20px' }}>Outreach</h2>
|
||
|
||
{radar.length > 0 && (
|
||
<div className="section">
|
||
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
Needs attention
|
||
<span className="approval-pill">{radar.length}</span>
|
||
</div>
|
||
<div className="index-action-hint" style={{ marginTop: 0, marginBottom: '12px' }}>
|
||
Investors who are waiting on a reply or have gone quiet, most urgent first. Every reason is verifiable from your email history — no guesswork. Click Draft to compose in your voice.
|
||
</div>
|
||
{radar.map((it) => (
|
||
<div key={it.investor_id} className="merge-candidate-card"
|
||
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||
<div>
|
||
<div style={{ fontWeight: 600 }}>{it.name}</div>
|
||
<div className="kpi-subtitle">{it.reason}</div>
|
||
</div>
|
||
<button
|
||
onClick={() => { setInvestorId(it.investor_id); setType(it.suggested_type); draft(it.investor_id, it.suggested_type); }}
|
||
disabled={drafting}>
|
||
{drafting ? '…' : `Draft ${it.suggested_type === 'nurture' ? 'nurture' : 'follow-up'}`}
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="section">
|
||
<div className="index-action-hint" style={{ marginTop: 0, marginBottom: '12px' }}>
|
||
Drafts a tailored LP email in Ten31's voice, grounded in the thesis and that investor's CRM notes + email history. The investor's details are de-identified before Claude sees them and restored locally, so the LP list never leaves Ten31. Drafts only — you review, edit, and send.
|
||
</div>
|
||
<div className="form-group" style={{ marginBottom: '12px', maxWidth: '420px' }}>
|
||
<label className="form-label">Investor</label>
|
||
<select className="select-input" value={investorId} onChange={(e) => setInvestorId(e.target.value)}>
|
||
<option value="">Select an investor…</option>
|
||
{investors.map((iv) => <option key={iv.id} value={iv.id}>{iv.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="form-group" style={{ marginBottom: '12px', maxWidth: '420px' }}>
|
||
<label className="form-label">Type</label>
|
||
<select className="select-input" value={type} onChange={(e) => setType(e.target.value)}>
|
||
{TYPES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="form-group" style={{ marginBottom: '12px' }}>
|
||
<label className="form-label">Guidance (optional)</label>
|
||
<textarea className="text-input" style={{ width: '100%', minHeight: '54px' }}
|
||
placeholder="e.g. mention the new Giga deal; they asked about lock-up terms"
|
||
value={guidance} onChange={(e) => setGuidance(e.target.value)} />
|
||
</div>
|
||
<div className="index-action-buttons">
|
||
<button onClick={() => draft()} disabled={drafting || !investorId}>
|
||
{drafting ? 'Drafting… (this can take a moment)' : 'Draft outreach'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{drafting && <div className="section"><SkeletonBlock lines={6} /></div>}
|
||
|
||
{result && !drafting && (
|
||
<div className="section">
|
||
{ok ? (
|
||
<>
|
||
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
Draft for {result.investor_name}
|
||
{result.scrub_stats && result.scrub_stats.tokens != null
|
||
? <span className="approval-pill">{result.scrub_stats.tokens} identifiers protected</span> : null}
|
||
</div>
|
||
<textarea className="text-input" style={{ width: '100%', minHeight: '260px', lineHeight: 1.5 }}
|
||
value={draftText} onChange={(e) => setDraftText(e.target.value)} />
|
||
<div className="index-action-buttons" style={{ marginTop: '10px' }}>
|
||
<button onClick={copy}>Copy draft</button>
|
||
<button onClick={createGmailDraft} disabled={gmailBusy}>
|
||
{gmailBusy ? 'Creating…' : 'Create Gmail draft'}
|
||
</button>
|
||
</div>
|
||
{gmailResult && gmailResult.status === 'ok' && (
|
||
<div className="index-action-hint" style={{ marginTop: '8px' }}>
|
||
{gmailResult.threaded ? 'In-thread reply draft' : 'Draft'} created in your Gmail. <a href={gmailResult.gmail_url} target="_blank" rel="noopener noreferrer">Open Gmail Drafts</a> to review and send.
|
||
</div>
|
||
)}
|
||
<div className="index-action-hint" style={{ marginTop: '12px' }}>
|
||
<strong>Voice based on:</strong>{' '}
|
||
{result.voice_examples && result.voice_examples.length > 0
|
||
? <>your codified rules + {result.voice_examples.length} of your prior emails — {result.voice_examples.map((v, i) => (
|
||
<span key={i}>{i > 0 ? '; ' : ''}"{v.subject}"{v.date ? ` (${v.date})` : ''}</span>
|
||
))}</>
|
||
: 'your codified voice rules only (no prior emails of yours were found to learn from yet)'}
|
||
</div>
|
||
<div className="index-action-hint" style={{ marginTop: '6px' }}>
|
||
Review and edit before sending. Nothing is sent automatically.
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="toast error" style={{ position: 'static' }}>
|
||
{FAIL[result.status] || result.reason || 'Drafting did not complete.'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const EmailCapturePage = ({ token, user, onShowToast }) => {
|
||
const isAdmin = user?.role === 'admin';
|
||
const [status, setStatus] = useState(null);
|
||
const [accounts, setAccounts] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [busy, setBusy] = useState('');
|
||
const [oneEmail, setOneEmail] = useState(() => user?.email || '');
|
||
const [proposals, setProposals] = useState([]);
|
||
const [edits, setEdits] = useState({});
|
||
const [deciding, setDeciding] = useState(null);
|
||
const [openEmail, setOpenEmail] = useState(null); // proposal id whose source email is expanded
|
||
const [emailCache, setEmailCache] = useState({}); // email_id -> {loading, data, error}
|
||
|
||
const load = useCallback(async () => {
|
||
let s;
|
||
try {
|
||
s = await api('/api/email/status', {}, token);
|
||
} catch (err) {
|
||
// Integration off on the server -> render the informative disabled state, not an error.
|
||
if (err?.status === 503 || /disabl/i.test(err?.payload?.error || '')) {
|
||
setStatus({ enabled: false, accounts_summary: {}, matched_emails: 0, captured_emails: 0, backfilling: false, last_run: null });
|
||
setAccounts([]);
|
||
return;
|
||
}
|
||
throw err;
|
||
}
|
||
let a = { accounts: [] };
|
||
try { a = await api('/api/email/accounts', {}, token); } catch (_) { /* none / disabled */ }
|
||
let pr = { proposals: [] };
|
||
try { pr = await api('/api/activity/proposals', {}, token); } catch (_) { /* admin-only / none */ }
|
||
setStatus(s);
|
||
setAccounts(Array.isArray(a?.accounts) ? a.accounts : []);
|
||
setProposals(Array.isArray(pr?.proposals) ? pr.proposals : []);
|
||
}, [token]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
setLoading(true);
|
||
await load();
|
||
if (!cancelled) setError('');
|
||
} catch (err) {
|
||
if (!cancelled) setError(getErrorMessage(err, 'Failed to load email status'));
|
||
} finally {
|
||
if (!cancelled) setLoading(false);
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [load]);
|
||
|
||
// While a backfill is in progress the captured-email count climbs page by page,
|
||
// so poll the status to show live progress without a manual refresh.
|
||
const backfilling = !!status && (status.backfilling || status.last_run?.status === 'running' || status.running);
|
||
useEffect(() => {
|
||
if (!backfilling) return undefined;
|
||
let cancelled = false;
|
||
const iv = setInterval(async () => { try { if (!cancelled) await load(); } catch (_) { /* transient */ } }, 5000);
|
||
return () => { cancelled = true; clearInterval(iv); };
|
||
}, [backfilling, load]);
|
||
|
||
// Steady-state poll of just the proposals so a decision made on Matrix (approve/dismiss
|
||
// in the review room) clears its card here without a manual reload — the mirror of the
|
||
// bot announcing a web-side decision in-thread. Admin-only (only admins see proposals).
|
||
const refreshProposals = useCallback(async () => {
|
||
try {
|
||
const pr = await api('/api/activity/proposals', {}, token);
|
||
setProposals(Array.isArray(pr?.proposals) ? pr.proposals : []);
|
||
} catch (_) { /* admin-only / transient — leave the current list */ }
|
||
}, [token]);
|
||
useEffect(() => {
|
||
if (!isAdmin) return undefined;
|
||
const iv = setInterval(() => { refreshProposals(); }, 25000);
|
||
return () => clearInterval(iv);
|
||
}, [isAdmin, refreshProposals]);
|
||
|
||
const runAction = async (key, endpoint, successMsg, confirmMsg, body) => {
|
||
if (busy) return;
|
||
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||
try {
|
||
setBusy(key);
|
||
const opts = { method: 'POST' };
|
||
if (body) opts.body = JSON.stringify(body);
|
||
const res = await api(endpoint, opts, token);
|
||
onShowToast(typeof successMsg === 'function' ? successMsg(res) : successMsg, 'success');
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Action failed'), 'error');
|
||
} finally {
|
||
setBusy('');
|
||
try { await load(); } catch (_) { /* ignore refresh failure */ }
|
||
}
|
||
};
|
||
|
||
const decide = async (p, decision) => {
|
||
if (deciding) return;
|
||
try {
|
||
setDeciding(p.id);
|
||
const body = decision === 'approve'
|
||
? JSON.stringify({ note: (edits[p.id] != null ? edits[p.id] : p.proposed_note) })
|
||
: undefined;
|
||
await api(`/api/activity/proposals/${p.id}/${decision}`, { method: 'POST', body }, token);
|
||
setProposals((prev) => prev.filter((x) => x.id !== p.id));
|
||
onShowToast(decision === 'approve' ? 'Note added to the grid' : 'Proposal dismissed', 'success');
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Action failed'), 'error');
|
||
} finally {
|
||
setDeciding(null);
|
||
}
|
||
};
|
||
|
||
// Click a proposal to see the email it was drafted from (from/to/cc/date/subject +
|
||
// scrollable body) so you can judge whether the note is right. Lazily fetched +
|
||
// cached per email; reuses the admin-only /api/email/detail used by Communications.
|
||
const toggleEmail = async (p) => {
|
||
if (openEmail === p.id) { setOpenEmail(null); return; }
|
||
setOpenEmail(p.id);
|
||
const eid = p.email_id;
|
||
if (!eid || emailCache[eid]) return;
|
||
setEmailCache((c) => ({ ...c, [eid]: { loading: true } }));
|
||
try {
|
||
const res = await api(`/api/email/detail?id=${encodeURIComponent(eid)}`, {}, token);
|
||
setEmailCache((c) => ({ ...c, [eid]: { loading: false, data: res } }));
|
||
} catch (err) {
|
||
setEmailCache((c) => ({ ...c, [eid]: { loading: false, error: getErrorMessage(err, 'Failed to load email') } }));
|
||
}
|
||
};
|
||
|
||
const renderProposalEmail = (p) => {
|
||
const det = emailCache[p.email_id];
|
||
if (!det) return null;
|
||
if (det.loading) return <div style={{ marginTop: '8px' }}><SkeletonBlock lines={4} /></div>;
|
||
if (det.error) return <div className="toast error" style={{ position: 'static', marginTop: '8px' }}>{det.error}</div>;
|
||
const d = det.data || {};
|
||
const rcpt = (kind) => (d.recipients || []).filter((r) => r.kind === kind)
|
||
.map((r) => r.display_name ? `${r.display_name} <${r.address}>` : r.address).join(', ');
|
||
const to = rcpt('to'), cc = rcpt('cc');
|
||
const from = d.from_name ? `${d.from_name} <${d.from_email || ''}>` : (d.from_email || '—');
|
||
const lbl = { fontSize: '11px', color: '#8ea2b7' };
|
||
return (
|
||
<div style={{ marginTop: '8px', border: '1px solid #263548', borderRadius: '6px', background: '#0d1622', padding: '10px' }}>
|
||
<div style={lbl}><b>From:</b> {from}</div>
|
||
{to && <div style={lbl}><b>To:</b> {to}</div>}
|
||
{cc && <div style={lbl}><b>Cc:</b> {cc}</div>}
|
||
<div style={lbl}><b>Date:</b> {d.sent_at ? new Date(d.sent_at).toLocaleString() : '—'}</div>
|
||
<div style={lbl}><b>Subject:</b> {d.subject || '(no subject)'}</div>
|
||
{(d.attachments || []).length > 0 && (
|
||
<div style={{ ...lbl, marginTop: '2px' }}><b>Attachments:</b> {d.attachments.map((a) => a.filename).join(', ')}</div>
|
||
)}
|
||
<pre style={{ margin: '8px 0 0', maxHeight: '280px', overflowY: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: '12px', lineHeight: 1.5, color: '#cdd9e5', fontFamily: 'inherit' }}>
|
||
{d.body_text || (d.has_html ? '(HTML-only email — open in Gmail to view formatting)' : '(no body captured)')}
|
||
</pre>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
if (loading) return <div style={{ padding: '20px' }}><SkeletonBlock lines={8} /></div>;
|
||
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
|
||
if (!status) return <div className="empty-state">No data</div>;
|
||
|
||
const sum = status.accounts_summary || {};
|
||
const enabled = !!status.enabled;
|
||
const nAccounts = sum.n_accounts || 0;
|
||
const nError = sum.n_error || 0;
|
||
const capturing = enabled && nAccounts > 0;
|
||
const captured = status.captured_emails ?? 0;
|
||
const lastRun = status.last_run;
|
||
const lastSync = lastRun ? formatDate(lastRun.finished_at || lastRun.started_at)
|
||
: (status.last_run_unix ? formatDate(new Date(status.last_run_unix * 1000).toISOString()) : '—');
|
||
const statusWord = backfilling ? 'Backfilling' : capturing ? 'Capturing' : enabled ? 'Not enrolled' : 'Disabled';
|
||
|
||
return (
|
||
<div className="page-container">
|
||
<h2 className="section-title" style={{ marginBottom: '20px' }}>Email Capture</h2>
|
||
|
||
<div className="section">
|
||
<div className="index-action-status">
|
||
{!enabled ? (
|
||
<span className="index-job-pill idle"><span className="index-job-dot" /> Integration disabled — no Gmail service-account key on the server.</span>
|
||
) : nAccounts === 0 ? (
|
||
<span className="index-job-pill idle"><span className="index-job-dot" /> Enabled, but no mailbox is enrolled yet, so nothing is being captured.</span>
|
||
) : backfilling ? (
|
||
<span className="index-job-pill running"><span className="index-job-dot" /> Backfilling history… {captured} emails captured so far ({status.matched_emails || 0} matched to investors). Updates live; first backfill can take a while.</span>
|
||
) : nError ? (
|
||
<span className="index-job-pill running"><span className="index-job-dot" /> {nAccounts} mailbox(es), {nError} in error — see below.</span>
|
||
) : (
|
||
<span className="index-job-pill"><span className="index-job-dot" /> Capturing: {nAccounts} mailbox(es), {captured} emails captured, {status.matched_emails || 0} matched to investors.</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="kpi-grid">
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Status</div>
|
||
<div className="kpi-value" style={{ fontSize: '16px' }}>{statusWord}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Mailboxes</div>
|
||
<div className="kpi-value">{nAccounts}</div>
|
||
{nError ? <div className="kpi-subtitle">{nError} error</div> : null}
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Emails captured</div>
|
||
<div className="kpi-value">{captured}{backfilling ? '…' : ''}</div>
|
||
<div className="kpi-subtitle">{status.matched_emails ?? 0} matched to investors</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Last sync</div>
|
||
<div className="kpi-value" style={{ fontSize: '16px' }}>{backfilling ? 'in progress' : lastSync}</div>
|
||
<div className="kpi-subtitle">{lastRun?.status ? `last run: ${lastRun.status}` : (status.interval_sec ? `every ${Math.round(status.interval_sec / 60)} min` : '')}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{isAdmin && proposals.length > 0 && (
|
||
<div className="section">
|
||
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
Proposed grid notes
|
||
<span className="approval-pill">{proposals.length} to review</span>
|
||
</div>
|
||
<div className="index-action-hint" style={{ marginTop: 0, marginBottom: '12px' }}>
|
||
The agent drafted these from matched emails on your local model. Edit if needed, then Approve to append the note to that investor's grid notes, or Dismiss.
|
||
</div>
|
||
{proposals.map((p) => (
|
||
<div key={p.id} className="merge-candidate-card" style={{ marginBottom: '12px' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '10px', marginBottom: '6px' }}>
|
||
<div style={{ fontWeight: 600 }}>{p.investor_name || 'Unknown investor'}</div>
|
||
<div className="kpi-subtitle">{p.direction === 'sent' ? 'Sent' : 'Received'}{p.email_date ? ` · ${formatDate(p.email_date)}` : ''}</div>
|
||
</div>
|
||
{p.email_subject ? <div className="kpi-subtitle" style={{ marginBottom: '8px' }}>Subject: {p.email_subject}</div> : null}
|
||
<textarea
|
||
className="text-input"
|
||
style={{ width: '100%', minHeight: '54px', marginBottom: '8px' }}
|
||
value={edits[p.id] != null ? edits[p.id] : (p.proposed_note || '')}
|
||
onChange={(e) => setEdits((m) => ({ ...m, [p.id]: e.target.value }))}
|
||
/>
|
||
<div className="index-action-buttons">
|
||
<button onClick={() => decide(p, 'approve')} disabled={deciding === p.id}>
|
||
{deciding === p.id ? 'Adding…' : 'Approve & add to grid'}
|
||
</button>
|
||
<button onClick={() => decide(p, 'dismiss')} disabled={deciding === p.id}>Dismiss</button>
|
||
{p.email_id && (
|
||
<button
|
||
onClick={() => toggleEmail(p)}
|
||
style={{ background: 'transparent', border: '1px solid #263548', color: '#8ea2b7' }}
|
||
>
|
||
{openEmail === p.id ? 'Hide email' : 'View email'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{openEmail === p.id && renderProposalEmail(p)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{isAdmin && (
|
||
<div className="section">
|
||
<div className="section-title">Actions</div>
|
||
<div className="index-action-hint" style={{ marginTop: 0, marginBottom: '8px' }}>
|
||
Test with a single mailbox first:
|
||
</div>
|
||
<div className="index-action-buttons" style={{ alignItems: 'center', marginBottom: '16px' }}>
|
||
<input
|
||
className="text-input"
|
||
type="email"
|
||
placeholder="name@ten31.xyz"
|
||
value={oneEmail}
|
||
onChange={(e) => setOneEmail(e.target.value)}
|
||
style={{ maxWidth: '260px' }}
|
||
/>
|
||
<button
|
||
onClick={() => {
|
||
const addr = (oneEmail || '').trim();
|
||
if (!addr) { onShowToast('Enter a mailbox address', 'error'); return; }
|
||
runAction('enroll-one', '/api/email/accounts/enroll', (r) => `Enrolled ${r?.email || addr}`, `Enroll just ${addr} for capture?`, { email: addr });
|
||
}}
|
||
disabled={!enabled || !!busy}
|
||
>
|
||
{busy === 'enroll-one' ? 'Enrolling…' : 'Enroll this mailbox'}
|
||
</button>
|
||
</div>
|
||
<div className="index-action-buttons">
|
||
<button
|
||
onClick={() => runAction('enroll', '/api/email/accounts/enroll-all', (r) => `Enrolled ${r?.count ?? 0} mailbox(es)`, 'Enroll ALL Ten31 mailboxes for capture? This connects every active @ten31.xyz user’s Gmail via the service account. Domain-wide delegation must already be authorized in Google Workspace admin.')}
|
||
disabled={!enabled || !!busy}
|
||
>
|
||
{busy === 'enroll' ? 'Enrolling…' : 'Enroll all Ten31 mailboxes'}
|
||
</button>
|
||
<button
|
||
onClick={() => runAction('sync', '/api/email/sync/run-now', 'Sync started')}
|
||
disabled={!enabled || nAccounts === 0 || !!busy}
|
||
>
|
||
{busy === 'sync' ? 'Syncing…' : 'Sync now'}
|
||
</button>
|
||
<button
|
||
onClick={() => runAction('rematch', '/api/email/rematch', 'Re-match started')}
|
||
disabled={!enabled || nAccounts === 0 || !!busy}
|
||
>
|
||
{busy === 'rematch' ? 'Re-matching…' : 'Re-match to investors'}
|
||
</button>
|
||
</div>
|
||
<div className="index-action-hint">
|
||
Enroll connects each Ten31 mailbox through the Google service account (requires domain-wide delegation authorized in Google Workspace admin first). Sync pulls new mail; Re-match links already-captured emails to investors. Captured email is sensitive and only ever feeds the Architect through the redaction boundary, never to Claude directly.
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="section">
|
||
<div className="section-title">Mailboxes</div>
|
||
{accounts.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '20px 0' }}>
|
||
<div className="empty-state-icon">✉</div>
|
||
No mailbox enrolled yet.{isAdmin ? ' Use “Enroll Ten31 mailboxes” above to start capturing.' : ''}
|
||
</div>
|
||
) : (
|
||
<div className="kpi-grid" style={{ marginBottom: 0 }}>
|
||
{accounts.map((a) => (
|
||
<div key={a.id} className="kpi-card">
|
||
<div className="kpi-label">{a.email_address}</div>
|
||
<div className="kpi-value" style={{ fontSize: '15px' }}>{a.sync_status || 'pending'}{a.backfill_complete ? '' : ' · backfilling'}</div>
|
||
<div className="kpi-subtitle">{(a.captured ?? 0)} captured · {(a.matched ?? 0)} matched</div>
|
||
<div className="kpi-subtitle">{a.last_synced_at ? `last ${formatDate(a.last_synced_at)}` : 'never synced'}{a.sync_error ? ` · ${a.sync_error}` : ''}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const SystemStatusPage = ({ token, user, onShowToast }) => {
|
||
const isAdmin = user?.role === 'admin';
|
||
const [data, setData] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [actionBusy, setActionBusy] = useState(false);
|
||
const [candidates, setCandidates] = useState([]);
|
||
const [candidatesLoading, setCandidatesLoading] = useState(false);
|
||
const [decidingId, setDecidingId] = useState(null);
|
||
|
||
const loadStatus = useCallback(async () => {
|
||
const result = await api('/api/system/status', {}, token);
|
||
setData(result.data);
|
||
return result.data;
|
||
}, [token]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
const load = async () => {
|
||
try {
|
||
setLoading(true);
|
||
await loadStatus();
|
||
if (!cancelled) setError('');
|
||
} catch (err) {
|
||
if (!cancelled) setError(getErrorMessage(err, 'Failed to load system status'));
|
||
} finally {
|
||
if (!cancelled) setLoading(false);
|
||
}
|
||
};
|
||
load();
|
||
return () => { cancelled = true; };
|
||
}, [loadStatus]);
|
||
|
||
const indexJob = data?.index_job || null;
|
||
const jobRunning = !!(indexJob && indexJob.running);
|
||
|
||
// Poll the status endpoint while an index job is running so the
|
||
// indicator + last result update without a manual refresh.
|
||
useEffect(() => {
|
||
if (!jobRunning) return undefined;
|
||
let cancelled = false;
|
||
const interval = setInterval(async () => {
|
||
try {
|
||
if (!cancelled) await loadStatus();
|
||
} catch (_) { /* transient; next tick retries */ }
|
||
}, 3000);
|
||
return () => { cancelled = true; clearInterval(interval); };
|
||
}, [jobRunning, loadStatus]);
|
||
|
||
const loadCandidates = useCallback(async () => {
|
||
if (!isAdmin) return;
|
||
try {
|
||
setCandidatesLoading(true);
|
||
const result = await api('/api/entities/merge-candidates', {}, token);
|
||
setCandidates(Array.isArray(result.candidates) ? result.candidates : []);
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to load duplicate review queue'), 'error');
|
||
} finally {
|
||
setCandidatesLoading(false);
|
||
}
|
||
}, [isAdmin, token, onShowToast]);
|
||
|
||
useEffect(() => {
|
||
loadCandidates();
|
||
}, [loadCandidates]);
|
||
|
||
const runIndexAction = async (endpoint, label, confirmMessage) => {
|
||
if (jobRunning || actionBusy) return;
|
||
if (confirmMessage && !window.confirm(confirmMessage)) return;
|
||
try {
|
||
setActionBusy(true);
|
||
const result = await api(endpoint, { method: 'POST' }, token);
|
||
const kind = result?.data?.kind || label;
|
||
onShowToast(`Started: ${kind}`, 'success');
|
||
} catch (err) {
|
||
if (err?.status === 409 || err?.payload?.error === 'job_running') {
|
||
onShowToast('An index job is already running', 'error');
|
||
} else {
|
||
onShowToast(getErrorMessage(err, `Failed to start ${label}`), 'error');
|
||
}
|
||
} finally {
|
||
setActionBusy(false);
|
||
try { await loadStatus(); } catch (_) { /* ignore refresh failure */ }
|
||
}
|
||
};
|
||
|
||
const decideCandidate = async (candidate, decision) => {
|
||
if (decidingId) return;
|
||
try {
|
||
setDecidingId(candidate.id);
|
||
await api(`/api/entities/merge-candidates/${candidate.id}`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ decision })
|
||
}, token);
|
||
setCandidates((prev) => prev.filter((c) => c.id !== candidate.id));
|
||
onShowToast(decision === 'approve' ? 'Contacts merged' : 'Kept separate', 'success');
|
||
} catch (err) {
|
||
onShowToast(getErrorMessage(err, 'Failed to record decision'), 'error');
|
||
} finally {
|
||
setDecidingId(null);
|
||
}
|
||
};
|
||
|
||
if (loading) return <div style={{ padding: '20px' }}><SkeletonBlock lines={8} /></div>;
|
||
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
|
||
if (!data) return <div className="empty-state">No data</div>;
|
||
|
||
const fmtBytes = (n) => {
|
||
if (n == null) return '—';
|
||
if (n < 1024) return n + ' B';
|
||
const u = ['KB', 'MB', 'GB', 'TB']; let i = -1; let v = n;
|
||
do { v /= 1024; i++; } while (v >= 1024 && i < u.length - 1);
|
||
return v.toFixed(v < 10 ? 1 : 0) + ' ' + u[i];
|
||
};
|
||
const storage = data.storage;
|
||
|
||
const entities = data.canonical_entities || {};
|
||
const sync = data.last_index_sync;
|
||
const thesis = data.thesis || {};
|
||
const activity = Array.isArray(data.recent_activity) ? data.recent_activity : [];
|
||
|
||
return (
|
||
<div className="page-container">
|
||
<h2 className="section-title" style={{ marginBottom: '20px' }}>System Status</h2>
|
||
|
||
<div className="kpi-grid">
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Investors</div>
|
||
<div className="kpi-value">{entities.investor ?? 0}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">People (resolved)</div>
|
||
<div className="kpi-value">{entities.person ?? 0}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Contacts in CRM</div>
|
||
<div className="kpi-value">{(data.source_counts || {}).contacts ?? '—'}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Grid contacts</div>
|
||
<div className="kpi-value">{(data.source_counts || {}).fundraising_contacts ?? '—'}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="section">
|
||
<div className="section-title">Last Index Sync</div>
|
||
{!sync ? (
|
||
<div className="empty-state" style={{ padding: '20px 0' }}>
|
||
<div className="empty-state-icon">◷</div>
|
||
The search index has not been built yet.
|
||
</div>
|
||
) : (
|
||
<div className="kpi-grid" style={{ marginBottom: 0 }}>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">When</div>
|
||
<div className="kpi-value" style={{ fontSize: '16px' }}>{formatDate(sync.ts)}</div>
|
||
<div className="kpi-subtitle">{formatDateLong(sync.ts)}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Mode</div>
|
||
<div className="kpi-value" style={{ fontSize: '16px' }}>{sync.mode || '-'}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Qdrant Points</div>
|
||
<div className="kpi-value">{sync.qdrant_points ?? 0}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Rows Embedded</div>
|
||
<div className="kpi-value">{sync.rows_embedded ?? 0}</div>
|
||
<div className="kpi-subtitle">{sync.total_chunks ?? 0} chunks</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{storage && (
|
||
<div className="section">
|
||
<div className="section-title">Storage</div>
|
||
<div className="kpi-grid" style={{ marginBottom: 0 }}>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Database</div>
|
||
<div className="kpi-value" style={{ fontSize: '18px' }}>{fmtBytes(storage.database_bytes)}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Email attachments</div>
|
||
<div className="kpi-value" style={{ fontSize: '18px' }}>{fmtBytes(storage.attachments_bytes)}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Backups</div>
|
||
<div className="kpi-value" style={{ fontSize: '18px' }}>{fmtBytes(storage.backups_bytes)}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Disk free</div>
|
||
<div className="kpi-value" style={{ fontSize: '18px' }}>{fmtBytes(storage.disk_free_bytes)}</div>
|
||
<div className="kpi-subtitle">of {fmtBytes(storage.disk_total_bytes)} total</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isAdmin && (
|
||
<div className="section">
|
||
<div className="section-title">Index Actions</div>
|
||
<div className="index-action-status">
|
||
{jobRunning ? (
|
||
<span className="index-job-pill running">
|
||
<span className="index-job-dot" /> running: {indexJob.kind || 'job'}
|
||
{indexJob.started_at ? ` · started ${formatDate(indexJob.started_at)}` : ''}
|
||
</span>
|
||
) : indexJob && indexJob.result ? (
|
||
<span className="index-job-pill">
|
||
last: {indexJob.kind ? `${indexJob.kind} — ` : ''}{indexJob.result}
|
||
{indexJob.finished_at ? ` · ${formatDate(indexJob.finished_at)}` : ''}
|
||
</span>
|
||
) : (
|
||
<span className="index-job-pill idle">No index job has run yet.</span>
|
||
)}
|
||
</div>
|
||
{jobRunning && indexJob.tail ? (
|
||
<pre className="index-job-tail">{indexJob.tail}</pre>
|
||
) : null}
|
||
<div className="index-action-buttons">
|
||
<button
|
||
onClick={() => runIndexAction('/api/index/update', 'update search index')}
|
||
disabled={jobRunning || actionBusy}
|
||
>
|
||
Update search index
|
||
</button>
|
||
<button
|
||
onClick={() => runIndexAction('/api/index/rebuild', 'rebuild search index', 'Rebuild the entire search index? This re-embeds every record and can take several minutes.')}
|
||
disabled={jobRunning || actionBusy}
|
||
>
|
||
Rebuild search index
|
||
</button>
|
||
<button
|
||
onClick={() => runIndexAction('/api/entities/find-duplicates', 'find duplicate contacts')}
|
||
disabled={jobRunning || actionBusy}
|
||
>
|
||
Find duplicate contacts
|
||
</button>
|
||
</div>
|
||
<div className="index-action-hint">
|
||
Update is a fast incremental pass. Rebuild re-embeds everything. Find duplicates runs the local model and fills the review queue below.
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isAdmin && (
|
||
<div className="section">
|
||
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '16px' }}>
|
||
Duplicate Review
|
||
<span className="approval-pill">{candidates.length} pending</span>
|
||
</div>
|
||
{candidatesLoading ? (
|
||
<SkeletonBlock lines={4} />
|
||
) : candidates.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '20px 0' }}>No duplicates to review.</div>
|
||
) : (
|
||
<div className="merge-candidate-list">
|
||
{candidates.map((c) => {
|
||
const verdict = (c.verdict || '').toLowerCase();
|
||
const confidencePct = c.confidence != null
|
||
? `${Math.round((c.confidence <= 1 ? c.confidence * 100 : c.confidence))}%`
|
||
: null;
|
||
const busy = decidingId === c.id;
|
||
return (
|
||
<div key={c.id} className="merge-candidate-card">
|
||
<div className="merge-candidate-people">
|
||
<div className="merge-candidate-person">
|
||
<div className="merge-candidate-name">{c.name_a || '(no name)'}</div>
|
||
<div className="merge-candidate-email">{c.email_a || '—'}</div>
|
||
</div>
|
||
<div className="merge-candidate-vs">vs</div>
|
||
<div className="merge-candidate-person">
|
||
<div className="merge-candidate-name">{c.name_b || '(no name)'}</div>
|
||
<div className="merge-candidate-email">{c.email_b || '—'}</div>
|
||
</div>
|
||
</div>
|
||
{c.context ? (
|
||
<div className="merge-candidate-context">{c.context}</div>
|
||
) : null}
|
||
<div className="merge-candidate-suggestion">
|
||
<span className={`badge ${verdict === 'same' ? 'badge-funded' : 'badge-other'}`}>
|
||
Suggestion: {c.verdict || 'unknown'}
|
||
</span>
|
||
{confidencePct ? (
|
||
<span className="merge-candidate-confidence">{confidencePct} confidence</span>
|
||
) : null}
|
||
</div>
|
||
{c.reason ? (
|
||
<div className="merge-candidate-reason">{c.reason}</div>
|
||
) : null}
|
||
<div className="merge-candidate-actions">
|
||
<button
|
||
onClick={() => decideCandidate(c, 'approve')}
|
||
disabled={busy}
|
||
>
|
||
Same person — merge
|
||
</button>
|
||
<button
|
||
className="button-secondary"
|
||
onClick={() => decideCandidate(c, 'reject')}
|
||
disabled={busy}
|
||
>
|
||
Different — keep separate
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="section">
|
||
<div className="section-title">Thesis</div>
|
||
<div className="kpi-grid" style={{ marginBottom: 0 }}>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Lines</div>
|
||
<div className="kpi-value">{thesis.lines ?? 0}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">Canonical Versions</div>
|
||
<div className="kpi-value">{thesis.canonical_versions ?? 0}</div>
|
||
</div>
|
||
<div className="kpi-card">
|
||
<div className="kpi-label">In Review</div>
|
||
<div className="kpi-value">{thesis.in_review ?? 0}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="section">
|
||
<div className="section-title">Recent Activity</div>
|
||
{activity.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '20px 0' }}>No recent activity.</div>
|
||
) : (
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th>When</th>
|
||
<th>Actor</th>
|
||
<th>Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{activity.slice(0, 30).map((a, i) => (
|
||
<tr key={i}>
|
||
<td>{formatDateLong(a.ts)}</td>
|
||
<td>{a.actor_type}{a.actor_id ? ` · ${a.actor_id}` : ''}</td>
|
||
<td>{a.action}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ==================== Main App ====================
|
||
const App = () => {
|
||
const { token, user, logout } = useAuth();
|
||
const [page, setPage] = useState('fundraising-grid');
|
||
const [toasts, setToasts] = useState([]);
|
||
const [sidebarHidden, setSidebarHidden] = useState(false);
|
||
const [gridViews, setGridViews] = useState(loadGridViews());
|
||
const [activeGridView, setActiveGridView] = useState('view-main');
|
||
const [gridUiAction, setGridUiAction] = useState(null);
|
||
const [sidebarContextMenu, setSidebarContextMenu] = useState(null);
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem(GRID_VIEW_STORAGE_KEY, JSON.stringify(gridViews));
|
||
}, [gridViews]);
|
||
|
||
useEffect(() => {
|
||
if (!gridViews.find((v) => v.id === activeGridView)) {
|
||
setActiveGridView(gridViews[0]?.id || 'view-main');
|
||
}
|
||
}, [gridViews, activeGridView]);
|
||
|
||
useEffect(() => {
|
||
if (!sidebarContextMenu) return undefined;
|
||
const close = () => setSidebarContextMenu(null);
|
||
const onKeyDown = (e) => {
|
||
if (e.key === 'Escape') close();
|
||
};
|
||
window.addEventListener('click', close);
|
||
window.addEventListener('contextmenu', close);
|
||
window.addEventListener('keydown', onKeyDown);
|
||
return () => {
|
||
window.removeEventListener('click', close);
|
||
window.removeEventListener('contextmenu', close);
|
||
window.removeEventListener('keydown', onKeyDown);
|
||
};
|
||
}, [sidebarContextMenu]);
|
||
|
||
const showToast = useCallback((message, type = 'info') => {
|
||
const id = Math.random();
|
||
setToasts(t => [...t, { id, message, type }]);
|
||
}, []);
|
||
|
||
if (!token) {
|
||
return <LoginPage />;
|
||
}
|
||
|
||
const handleLogout = () => {
|
||
logout();
|
||
setPage('fundraising-grid');
|
||
};
|
||
|
||
const openSidebarContextMenu = (event, payload) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setSidebarContextMenu({
|
||
x: event.clientX,
|
||
y: event.clientY,
|
||
...payload
|
||
});
|
||
};
|
||
|
||
const applySidebarContextAction = (action) => {
|
||
const menu = sidebarContextMenu;
|
||
setSidebarContextMenu(null);
|
||
if (!menu) return;
|
||
|
||
const targetViewId = menu.viewId || activeGridView;
|
||
const targetView = gridViews.find((v) => v.id === targetViewId);
|
||
const isProtected = PROTECTED_VIEW_IDS.has(targetViewId);
|
||
|
||
if (action === 'save-active-view') {
|
||
setPage('fundraising-grid');
|
||
setActiveGridView(targetViewId);
|
||
setGridUiAction('save-active-view');
|
||
return;
|
||
}
|
||
|
||
if (action === 'save-as-new-view') {
|
||
setPage('fundraising-grid');
|
||
if (targetViewId) setActiveGridView(targetViewId);
|
||
setGridUiAction('open-save-view');
|
||
return;
|
||
}
|
||
|
||
if (action === 'rename-view') {
|
||
if (!targetView) return;
|
||
if (isProtected) {
|
||
showToast('Default views cannot be renamed', 'error');
|
||
return;
|
||
}
|
||
const nextName = window.prompt('Rename view', targetView.name || '');
|
||
if (nextName === null) return;
|
||
const trimmed = String(nextName).trim();
|
||
if (!trimmed) {
|
||
showToast('View name is required', 'error');
|
||
return;
|
||
}
|
||
setGridViews((prev) => prev.map((v) => (v.id === targetViewId ? { ...v, name: trimmed } : v)));
|
||
showToast('View renamed', 'success');
|
||
return;
|
||
}
|
||
|
||
if (action === 'delete-view') {
|
||
if (!targetView) return;
|
||
if (isProtected) {
|
||
showToast('Default views cannot be deleted', 'error');
|
||
return;
|
||
}
|
||
const confirmed = window.confirm(`Delete saved view "${targetView.name}"?`);
|
||
if (!confirmed) return;
|
||
setGridViews((prev) => prev.filter((v) => v.id !== targetViewId));
|
||
if (activeGridView === targetViewId) {
|
||
setActiveGridView('view-main');
|
||
}
|
||
showToast('View deleted', 'success');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="app-container">
|
||
<div
|
||
className={`sidebar ${sidebarHidden ? 'hidden' : ''}`}
|
||
onContextMenu={(e) => {
|
||
e.preventDefault();
|
||
}}
|
||
>
|
||
<div className="sidebar-header-row">
|
||
<div className="sidebar-header" aria-label="Ten31">
|
||
<span className="sidebar-logo">
|
||
<img className="sidebar-logo-img" src="/assets/ten31-inverted-square.png" alt="Ten31" />
|
||
</span>
|
||
</div>
|
||
<button className="sidebar-toggle" onClick={() => setSidebarHidden(true)}>Hide</button>
|
||
</div>
|
||
<div className="nav-items">
|
||
<button
|
||
className={`nav-item ${page === 'fundraising-grid' ? 'active' : ''}`}
|
||
onClick={() => setPage('fundraising-grid')}
|
||
onContextMenu={(e) => openSidebarContextMenu(e, { kind: 'fundraising-root' })}
|
||
>
|
||
<span className="nav-item-icon">▦</span> Fundraising Grid
|
||
</button>
|
||
<div className="sub-nav-items">
|
||
{gridViews.map((v) => (
|
||
<button
|
||
key={v.id}
|
||
className={`sub-nav-item ${activeGridView === v.id ? 'active' : ''}`}
|
||
onClick={() => {
|
||
setPage('fundraising-grid');
|
||
setActiveGridView(v.id);
|
||
}}
|
||
onContextMenu={(e) => openSidebarContextMenu(e, { kind: 'view', viewId: v.id })}
|
||
>
|
||
• {v.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button className={`nav-item ${page === 'dashboard' ? 'active' : ''}`} onClick={() => setPage('dashboard')}>
|
||
<span className="nav-item-icon">◫</span> Dashboard
|
||
</button>
|
||
<button className={`nav-item ${page === 'contacts' ? 'active' : ''}`} onClick={() => setPage('contacts')}>
|
||
<span className="nav-item-icon">◎</span> Contacts
|
||
</button>
|
||
<button className={`nav-item ${page === 'pipeline' ? 'active' : ''}`} onClick={() => setPage('pipeline')}>
|
||
<span className="nav-item-icon">↗</span> Pipeline
|
||
</button>
|
||
{user?.role === 'admin' && (
|
||
<button className={`nav-item ${page === 'communications' ? 'active' : ''}`} onClick={() => setPage('communications')}>
|
||
<span className="nav-item-icon">◌</span> Communications
|
||
</button>
|
||
)}
|
||
<button className={`nav-item ${page === 'thesis' ? 'active' : ''}`} onClick={() => setPage('thesis')}>
|
||
<span className="nav-item-icon">§</span> Thesis
|
||
</button>
|
||
<button className={`nav-item ${page === 'thesis-workshop' ? 'active' : ''}`} onClick={() => setPage('thesis-workshop')}>
|
||
<span className="nav-item-icon">◆</span> Thesis Workshop
|
||
</button>
|
||
<button className={`nav-item ${page === 'outreach' ? 'active' : ''}`} onClick={() => setPage('outreach')}>
|
||
<span className="nav-item-icon">✎</span> Outreach
|
||
</button>
|
||
<button className={`nav-item ${page === 'system-status' ? 'active' : ''}`} onClick={() => setPage('system-status')}>
|
||
<span className="nav-item-icon">◉</span> System Status
|
||
</button>
|
||
{user?.role === 'admin' && (
|
||
<button className={`nav-item ${page === 'email-capture' ? 'active' : ''}`} onClick={() => setPage('email-capture')}>
|
||
<span className="nav-item-icon">✉</span> Email Capture
|
||
</button>
|
||
)}
|
||
<button className={`nav-item ${page === 'feature-requests' ? 'active' : ''}`} onClick={() => setPage('feature-requests')}>
|
||
<span className="nav-item-icon">✦</span> Feedback
|
||
</button>
|
||
<button className={`nav-item ${page === 'instructions' ? 'active' : ''}`} onClick={() => setPage('instructions')}>
|
||
<span className="nav-item-icon">ⓘ</span> Instructions
|
||
</button>
|
||
<button className={`nav-item ${page === 'settings' ? 'active' : ''}`} onClick={() => setPage('settings')}>
|
||
<span className="nav-item-icon">◍</span> Settings
|
||
</button>
|
||
</div>
|
||
<div className="sidebar-footer">
|
||
<button className="logout-btn" onClick={handleLogout}>Logout</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="main-content">
|
||
<div className="header">
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||
{sidebarHidden && <button className="sidebar-toggle" onClick={() => setSidebarHidden(false)}>Show Menu</button>}
|
||
<div className="header-title">
|
||
{page === 'fundraising-grid' && 'Fundraising Grid'}
|
||
{page === 'dashboard' && 'Dashboard'}
|
||
{page === 'contacts' && 'Contacts'}
|
||
{page === 'pipeline' && 'Pipeline'}
|
||
{page === 'communications' && 'Communications'}
|
||
{page === 'thesis' && 'Thesis'}
|
||
{page === 'thesis-workshop' && 'Thesis Workshop'}
|
||
{page === 'outreach' && 'Outreach'}
|
||
{page === 'system-status' && 'System Status'}
|
||
{page === 'email-capture' && 'Email Capture'}
|
||
{page === 'feature-requests' && 'Feature Requests'}
|
||
{page === 'instructions' && 'Instructions'}
|
||
{page === 'settings' && 'Settings'}
|
||
</div>
|
||
</div>
|
||
<div className="user-info">
|
||
{user?.full_name || user?.username}{MOCK_MODE ? ' · Mock Mode' : ''}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="content">
|
||
{page === 'fundraising-grid' && (
|
||
<FundraisingGridPage
|
||
user={user}
|
||
token={token}
|
||
onShowToast={showToast}
|
||
views={gridViews}
|
||
activeView={activeGridView}
|
||
setActiveView={setActiveGridView}
|
||
setViews={setGridViews}
|
||
uiAction={gridUiAction}
|
||
onUiActionHandled={() => setGridUiAction(null)}
|
||
/>
|
||
)}
|
||
{page === 'dashboard' && <DashboardPage token={token} />}
|
||
{page === 'contacts' && <ContactsPage token={token} onShowToast={showToast} />}
|
||
{page === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} />}
|
||
{page === 'communications' && <CommunicationsPage token={token} user={user} onShowToast={showToast} />}
|
||
{page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />}
|
||
{page === 'thesis-workshop' && <ThesisWorkshopPage token={token} user={user} onShowToast={showToast} />}
|
||
{page === 'outreach' && <OutreachPage token={token} user={user} onShowToast={showToast} />}
|
||
{page === 'system-status' && <SystemStatusPage token={token} user={user} onShowToast={showToast} />}
|
||
{page === 'email-capture' && <EmailCapturePage token={token} user={user} onShowToast={showToast} />}
|
||
{page === 'feature-requests' && <FeatureRequestsPage token={token} onShowToast={showToast} user={user} />}
|
||
{page === 'instructions' && <InstructionsPage />}
|
||
{page === 'settings' && (
|
||
<SettingsPage
|
||
token={token}
|
||
onShowToast={showToast}
|
||
user={user}
|
||
onOpenAirtableImport={() => {
|
||
setPage('fundraising-grid');
|
||
setGridUiAction('open-import');
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{toasts.map(toast => (
|
||
<Toast
|
||
key={toast.id}
|
||
message={toast.message}
|
||
type={toast.type}
|
||
onClose={() => setToasts(t => t.filter(x => x.id !== toast.id))}
|
||
/>
|
||
))}
|
||
|
||
{sidebarContextMenu && (
|
||
<div className="context-menu" style={{ left: `${sidebarContextMenu.x}px`, top: `${sidebarContextMenu.y}px` }} onClick={(e) => e.stopPropagation()}>
|
||
<button className="context-menu-item" onClick={() => applySidebarContextAction('save-active-view')}>Save Current View</button>
|
||
<button className="context-menu-item" onClick={() => applySidebarContextAction('save-as-new-view')}>Save as New View</button>
|
||
{sidebarContextMenu.kind === 'view' && (
|
||
<>
|
||
<div className="context-menu-sep" />
|
||
<button className="context-menu-item" onClick={() => applySidebarContextAction('rename-view')}>Rename View</button>
|
||
<button className="context-menu-item" onClick={() => applySidebarContextAction('delete-view')}>Delete View</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
ReactDOM.render(
|
||
<AuthProvider>
|
||
<AppErrorBoundary>
|
||
<App />
|
||
</AppErrorBoundary>
|
||
</AuthProvider>,
|
||
document.getElementById('root')
|
||
);
|
||
</script>
|
||
</body>
|
||
</html>
|