3560 lines
163 KiB
HTML
3560 lines
163 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 CRM</title>
|
||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||
background: #0f172a;
|
||
color: #e2e8f0;
|
||
}
|
||
|
||
#root {
|
||
display: flex;
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Sidebar */
|
||
.sidebar {
|
||
width: 250px;
|
||
background: #1e293b;
|
||
border-right: 1px solid #334155;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.sidebar-header {
|
||
padding: 20px;
|
||
border-bottom: 1px solid #334155;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: #6366f1;
|
||
}
|
||
|
||
.sidebar-section {
|
||
flex: 1;
|
||
padding: 16px 0;
|
||
border-bottom: 1px solid #334155;
|
||
}
|
||
|
||
.sidebar-section-title {
|
||
padding: 8px 16px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.view-item {
|
||
padding: 10px 16px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
color: #cbd5e1;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.view-item:hover {
|
||
background: #334155;
|
||
}
|
||
|
||
.view-item.active {
|
||
background: #334155;
|
||
color: #6366f1;
|
||
font-weight: 600;
|
||
border-left: 3px solid #6366f1;
|
||
padding-left: 13px;
|
||
}
|
||
|
||
.view-item.team-member {
|
||
padding-left: 32px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.filter-badge {
|
||
display: inline-block;
|
||
background: #6366f1;
|
||
color: #fff;
|
||
border-radius: 4px;
|
||
padding: 2px 6px;
|
||
font-size: 11px;
|
||
margin-left: 8px;
|
||
}
|
||
|
||
/* Main Content */
|
||
.main-content {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.top-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16px 24px;
|
||
border-bottom: 1px solid #334155;
|
||
background: #0f172a;
|
||
}
|
||
|
||
.search-input {
|
||
flex: 1;
|
||
max-width: 300px;
|
||
padding: 8px 12px;
|
||
background: #1e293b;
|
||
border: 1px solid #334155;
|
||
border-radius: 6px;
|
||
color: #e2e8f0;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.search-input::placeholder {
|
||
color: #64748b;
|
||
}
|
||
|
||
.search-input:focus {
|
||
outline: none;
|
||
border-color: #6366f1;
|
||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
|
||
}
|
||
|
||
.add-button {
|
||
padding: 8px 16px;
|
||
background: #6366f1;
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-weight: 500;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.add-button:hover {
|
||
background: #4f46e5;
|
||
}
|
||
|
||
/* Table */
|
||
.table-container {
|
||
flex: 1;
|
||
overflow: auto;
|
||
position: relative;
|
||
}
|
||
|
||
.table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.table thead {
|
||
background: #1e293b;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
}
|
||
|
||
.table th {
|
||
padding: 12px 16px;
|
||
text-align: left;
|
||
border-bottom: 1px solid #334155;
|
||
color: #94a3b8;
|
||
font-weight: 600;
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.table th:hover {
|
||
cursor: pointer;
|
||
background: #334155;
|
||
}
|
||
|
||
.table td {
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid #334155;
|
||
}
|
||
|
||
.table tbody tr {
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.table tbody tr:hover {
|
||
background: #1e293b;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.table tbody tr.selected {
|
||
background: #334155;
|
||
}
|
||
|
||
.table td.editable {
|
||
cursor: cell;
|
||
}
|
||
|
||
.table td.editable:hover {
|
||
background: rgba(99, 102, 241, 0.08);
|
||
outline: 1px dashed rgba(99, 102, 241, 0.3);
|
||
outline-offset: -1px;
|
||
}
|
||
|
||
.table td.editing {
|
||
background: rgba(99, 102, 241, 0.15);
|
||
outline: 2px solid #6366f1;
|
||
outline-offset: -2px;
|
||
}
|
||
|
||
.cell-edit-input, .cell-edit-select {
|
||
width: 100%;
|
||
padding: 4px 6px;
|
||
background: #0f172a;
|
||
color: #e2e8f0;
|
||
border: 1px solid #6366f1;
|
||
border-radius: 3px;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
outline: none;
|
||
}
|
||
|
||
.table input[type="text"],
|
||
.table input[type="number"],
|
||
.table input[type="email"],
|
||
.table select {
|
||
width: 100%;
|
||
padding: 4px 6px;
|
||
background: #0f172a;
|
||
border: 1px solid #6366f1;
|
||
border-radius: 4px;
|
||
color: #e2e8f0;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.table input[type="text"]:focus,
|
||
.table input[type="number"]:focus,
|
||
.table input[type="email"]:focus,
|
||
.table select:focus {
|
||
outline: none;
|
||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3);
|
||
}
|
||
|
||
/* Badge Styles */
|
||
.badge {
|
||
display: inline-block;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.badge.investor {
|
||
background: #065f46;
|
||
color: #d1fae5;
|
||
}
|
||
|
||
.badge.prospect {
|
||
background: #3730a3;
|
||
color: #e0e7ff;
|
||
}
|
||
|
||
.badge.lead {
|
||
background: #92400e;
|
||
color: #fef3c7;
|
||
}
|
||
|
||
.badge.high {
|
||
background: #7c2d12;
|
||
color: #fed7aa;
|
||
}
|
||
|
||
.badge.medium {
|
||
background: #713f12;
|
||
color: #fcd34d;
|
||
}
|
||
|
||
.badge.low {
|
||
background: #374151;
|
||
color: #d1d5db;
|
||
}
|
||
|
||
/* Detail Panel */
|
||
.context-menu {
|
||
position: fixed;
|
||
z-index: 2000;
|
||
background: #1e293b;
|
||
border: 1px solid #334155;
|
||
border-radius: 8px;
|
||
padding: 4px 0;
|
||
min-width: 200px;
|
||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||
}
|
||
|
||
.context-menu-item {
|
||
padding: 8px 14px;
|
||
font-size: 13px;
|
||
color: #cbd5e1;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.context-menu-item:hover {
|
||
background: #334155;
|
||
color: #fff;
|
||
}
|
||
|
||
.context-menu-item.danger {
|
||
color: #fca5a5;
|
||
}
|
||
|
||
.context-menu-item.danger:hover {
|
||
background: #7f1d1d;
|
||
color: #fff;
|
||
}
|
||
|
||
.context-menu-divider {
|
||
height: 1px;
|
||
background: #334155;
|
||
margin: 4px 0;
|
||
}
|
||
|
||
.context-menu-label {
|
||
padding: 4px 14px;
|
||
font-size: 11px;
|
||
color: #64748b;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.context-menu-check {
|
||
width: 14px;
|
||
text-align: center;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.detail-panel {
|
||
position: fixed;
|
||
right: 0;
|
||
top: 0;
|
||
height: 100vh;
|
||
width: 450px;
|
||
background: #1e293b;
|
||
border-left: 1px solid #334155;
|
||
z-index: 100;
|
||
overflow-y: auto;
|
||
transform: translateX(450px);
|
||
transition: transform 0.3s ease;
|
||
}
|
||
|
||
.detail-panel.open {
|
||
transform: translateX(0);
|
||
}
|
||
|
||
.detail-header {
|
||
padding: 24px;
|
||
border-bottom: 1px solid #334155;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.detail-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.detail-title h2 {
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.close-button {
|
||
background: none;
|
||
border: none;
|
||
color: #94a3b8;
|
||
cursor: pointer;
|
||
font-size: 24px;
|
||
padding: 0;
|
||
transition: color 0.2s;
|
||
}
|
||
|
||
.close-button:hover {
|
||
color: #e2e8f0;
|
||
}
|
||
|
||
.detail-body {
|
||
padding: 24px;
|
||
}
|
||
|
||
.detail-section {
|
||
margin-bottom: 28px;
|
||
}
|
||
|
||
.detail-section-title {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
color: #94a3b8;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.detail-field {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.detail-field label {
|
||
display: block;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #cbd5e1;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.detail-field input,
|
||
.detail-field select,
|
||
.detail-field textarea {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
background: #0f172a;
|
||
border: 1px solid #334155;
|
||
border-radius: 6px;
|
||
color: #e2e8f0;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.detail-field input:focus,
|
||
.detail-field select:focus,
|
||
.detail-field textarea:focus {
|
||
outline: none;
|
||
border-color: #6366f1;
|
||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
|
||
}
|
||
|
||
.detail-field textarea {
|
||
resize: vertical;
|
||
min-height: 80px;
|
||
}
|
||
|
||
/* Contacts Section */
|
||
.contacts-section {
|
||
margin-bottom: 28px;
|
||
}
|
||
|
||
.contacts-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.add-contact-button {
|
||
padding: 6px 12px;
|
||
background: #6366f1;
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.add-contact-button:hover {
|
||
background: #4f46e5;
|
||
}
|
||
|
||
.contact-card {
|
||
background: #0f172a;
|
||
border: 1px solid #334155;
|
||
border-radius: 6px;
|
||
padding: 12px;
|
||
margin-bottom: 10px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.contact-info {
|
||
flex: 1;
|
||
}
|
||
|
||
.contact-name {
|
||
font-weight: 600;
|
||
color: #e2e8f0;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.contact-email {
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.contact-title {
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
}
|
||
|
||
.delete-contact-button {
|
||
background: none;
|
||
border: none;
|
||
color: #ef4444;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
padding: 0;
|
||
transition: color 0.2s;
|
||
}
|
||
|
||
.delete-contact-button:hover {
|
||
color: #dc2626;
|
||
}
|
||
|
||
/* Tooltip */
|
||
.tooltip {
|
||
position: absolute;
|
||
background: #0f172a;
|
||
border: 1px solid #334155;
|
||
border-radius: 6px;
|
||
padding: 12px;
|
||
font-size: 13px;
|
||
z-index: 200;
|
||
max-width: 300px;
|
||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
|
||
}
|
||
|
||
.tooltip-contact {
|
||
padding: 6px 0;
|
||
border-bottom: 1px solid #334155;
|
||
}
|
||
|
||
.tooltip-contact:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.tooltip-contact-name {
|
||
font-weight: 600;
|
||
color: #e2e8f0;
|
||
}
|
||
|
||
.tooltip-contact-email {
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
/* Modal */
|
||
.modal {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 300;
|
||
}
|
||
|
||
.modal.open {
|
||
display: flex;
|
||
}
|
||
|
||
.modal-content {
|
||
background: #1e293b;
|
||
border: 1px solid #334155;
|
||
border-radius: 8px;
|
||
padding: 24px;
|
||
max-width: 500px;
|
||
width: 90%;
|
||
max-height: 80vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.modal-header {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
margin-bottom: 20px;
|
||
color: #e2e8f0;
|
||
}
|
||
|
||
.modal-field {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.modal-field label {
|
||
display: block;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #cbd5e1;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.modal-field input,
|
||
.modal-field select {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
background: #0f172a;
|
||
border: 1px solid #334155;
|
||
border-radius: 6px;
|
||
color: #e2e8f0;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.modal-field input:focus,
|
||
.modal-field select:focus {
|
||
outline: none;
|
||
border-color: #6366f1;
|
||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
|
||
}
|
||
|
||
.modal-buttons {
|
||
display: flex;
|
||
gap: 12px;
|
||
justify-content: flex-end;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.modal-button {
|
||
padding: 8px 16px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-weight: 500;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.modal-button.cancel {
|
||
background: #334155;
|
||
color: #e2e8f0;
|
||
}
|
||
|
||
.modal-button.cancel:hover {
|
||
background: #475569;
|
||
}
|
||
|
||
.modal-button.primary {
|
||
background: #6366f1;
|
||
color: #fff;
|
||
}
|
||
|
||
.modal-button.primary:hover {
|
||
background: #4f46e5;
|
||
}
|
||
|
||
/* Dashboard */
|
||
.dashboard {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 16px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.stat-card {
|
||
background: #1e293b;
|
||
border: 1px solid #334155;
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
text-align: center;
|
||
}
|
||
|
||
.stat-card-value {
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
color: #6366f1;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.stat-card-label {
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
/* Scrollbar */
|
||
::-webkit-scrollbar {
|
||
width: 8px;
|
||
height: 8px;
|
||
}
|
||
|
||
::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb {
|
||
background: #475569;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb:hover {
|
||
background: #64748b;
|
||
}
|
||
|
||
/* Column Visibility Dropdown */
|
||
.columns-button {
|
||
background: none;
|
||
border: 1px solid #475569;
|
||
color: #e2e8f0;
|
||
padding: 6px 10px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
transition: all 0.2s;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.columns-button:hover {
|
||
background: #334155;
|
||
border-color: #64748b;
|
||
}
|
||
|
||
.columns-dropdown {
|
||
position: absolute;
|
||
background: #1e293b;
|
||
border: 1px solid #475569;
|
||
border-radius: 6px;
|
||
min-width: 200px;
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
z-index: 1000;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.columns-dropdown-item {
|
||
padding: 10px 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
color: #cbd5e1;
|
||
border-bottom: 1px solid #334155;
|
||
}
|
||
|
||
.columns-dropdown-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.columns-dropdown-item:hover {
|
||
background: #334155;
|
||
}
|
||
|
||
.columns-dropdown-item input[type="checkbox"] {
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* Save View Button */
|
||
.save-view-button {
|
||
background: none;
|
||
border: 1px solid #6366f1;
|
||
color: #6366f1;
|
||
padding: 6px 12px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.save-view-button:hover {
|
||
background: #6366f1;
|
||
color: #fff;
|
||
}
|
||
|
||
.save-view-button:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
/* Save View Modal */
|
||
.modal-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 2000;
|
||
}
|
||
|
||
.modal-content {
|
||
background: #1e293b;
|
||
border: 1px solid #475569;
|
||
border-radius: 8px;
|
||
padding: 24px;
|
||
min-width: 350px;
|
||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.modal-content h2 {
|
||
font-size: 18px;
|
||
margin-bottom: 16px;
|
||
color: #e2e8f0;
|
||
}
|
||
|
||
.modal-content input {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
background: #0f172a;
|
||
border: 1px solid #475569;
|
||
border-radius: 4px;
|
||
color: #e2e8f0;
|
||
margin-bottom: 16px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.modal-content input:focus {
|
||
outline: none;
|
||
border-color: #6366f1;
|
||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
|
||
}
|
||
|
||
.modal-buttons {
|
||
display: flex;
|
||
gap: 8px;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.modal-buttons button {
|
||
padding: 8px 16px;
|
||
border-radius: 4px;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.modal-buttons .save {
|
||
background: #6366f1;
|
||
color: #fff;
|
||
}
|
||
|
||
.modal-buttons .save:hover {
|
||
background: #4f46e5;
|
||
}
|
||
|
||
.modal-buttons .cancel {
|
||
background: #334155;
|
||
color: #cbd5e1;
|
||
}
|
||
|
||
.modal-buttons .cancel:hover {
|
||
background: #475569;
|
||
}
|
||
|
||
/* Custom Views Section */
|
||
.custom-views-section {
|
||
padding: 16px 0;
|
||
border-bottom: 1px solid #334155;
|
||
}
|
||
|
||
.view-item-with-delete {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 10px 16px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
color: #cbd5e1;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.view-item-with-delete:hover {
|
||
background: #334155;
|
||
}
|
||
|
||
.view-item-with-delete.active {
|
||
background: #334155;
|
||
color: #6366f1;
|
||
font-weight: 600;
|
||
border-left: 3px solid #6366f1;
|
||
padding-left: 13px;
|
||
}
|
||
|
||
.delete-view-btn {
|
||
background: none;
|
||
border: none;
|
||
color: #94a3b8;
|
||
cursor: pointer;
|
||
padding: 4px 6px;
|
||
font-size: 16px;
|
||
opacity: 0;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.view-item-with-delete:hover .delete-view-btn {
|
||
opacity: 1;
|
||
color: #ef4444;
|
||
}
|
||
|
||
.delete-view-btn:hover {
|
||
color: #dc2626;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="root"></div>
|
||
|
||
<script type="text/babel">
|
||
const React = window.React;
|
||
const { useState, useCallback, useMemo } = React;
|
||
|
||
// Utility functions (OUTSIDE component)
|
||
function formatCurrency(value) {
|
||
if (!value) return "$0";
|
||
return "$" + value.toLocaleString();
|
||
}
|
||
|
||
function getRelativeDate(dateString) {
|
||
if (!dateString) return "";
|
||
const date = new Date(dateString);
|
||
const now = new Date();
|
||
const diffMs = now - date;
|
||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||
|
||
if (diffDays === 0) return "Today";
|
||
if (diffDays === 1) return "Yesterday";
|
||
if (diffDays < 7) return `${diffDays}d ago`;
|
||
if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`;
|
||
if (diffDays < 365) return `${Math.floor(diffDays / 30)}m ago`;
|
||
return `${Math.floor(diffDays / 365)}y ago`;
|
||
}
|
||
|
||
function calculateTotal(items, key) {
|
||
return items.reduce((sum, item) => {
|
||
const val = parseFloat(item[key]) || 0;
|
||
return sum + val;
|
||
}, 0);
|
||
}
|
||
|
||
function getTypeColor(type) {
|
||
const colors = {
|
||
investor: "#065f46",
|
||
prospect: "#3730a3",
|
||
lead: "#92400e"
|
||
};
|
||
return colors[type] || "#374151";
|
||
}
|
||
|
||
function getPriorityColor(priority) {
|
||
const colors = {
|
||
high: "#7c2d12",
|
||
medium: "#713f12",
|
||
low: "#374151"
|
||
};
|
||
return colors[priority] || "#374151";
|
||
}
|
||
|
||
// Mock data with new structure
|
||
const initialInvestors = [
|
||
{
|
||
id: 1,
|
||
name: "Caprock / Grey Street",
|
||
type: "investor",
|
||
lead: "Grant",
|
||
priority: "high",
|
||
followUp: false,
|
||
graveyard: false,
|
||
leadSource: "conference",
|
||
manualLeadSource: "Ten31 Bitcoin Summit 2023",
|
||
contacts: [
|
||
{ name: "Jeffrey Friedstein", email: "jeff@caprock.com", title: "Managing Director", location: "Austin, TX, USA" },
|
||
{ name: "Jay Page", email: "jay@caprock.com", title: "Partner", location: "Austin, TX, USA" }
|
||
],
|
||
fundI: 500000,
|
||
fundII: 750000,
|
||
fundIII: 1000000,
|
||
tactical: 250000,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Strong commitment across funds. Jeff leads portfolio decisions. Quarterly check-ins scheduled.",
|
||
lastContact: "2025-02-01"
|
||
},
|
||
{
|
||
id: 2,
|
||
name: "Stepstone Group",
|
||
type: "investor",
|
||
lead: "Matt",
|
||
priority: "high",
|
||
followUp: true,
|
||
graveyard: false,
|
||
leadSource: "inbound",
|
||
manualLeadSource: "Direct inquiry via website",
|
||
contacts: [
|
||
{ name: "Michael Chen", email: "mchen@stepstone.com", title: "Director", location: "New York City, NY, USA" },
|
||
{ name: "Sarah Williams", email: "swilliams@stepstone.com", title: "VP", location: "New York City, NY, USA" }
|
||
],
|
||
fundI: 750000,
|
||
fundII: 1000000,
|
||
fundIII: 1500000,
|
||
tactical: 500000,
|
||
pawnE4: 250000,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Follow up on fund III allocation by Feb 28. Michael prefers async comms.",
|
||
lastContact: "2025-01-28"
|
||
},
|
||
{
|
||
id: 3,
|
||
name: "Cashel Capital",
|
||
type: "investor",
|
||
lead: "JP",
|
||
priority: "high",
|
||
followUp: false,
|
||
graveyard: false,
|
||
leadSource: "referral",
|
||
manualLeadSource: "Introduced by Carnegie Endowment",
|
||
contacts: [
|
||
{ name: "Brendan O'Brien", email: "brendan@cashelcap.com", title: "Founder", location: "Dublin, Ireland" },
|
||
{ name: "Laura Martinez", email: "laura@cashelcap.com", title: "COO", location: "Dublin, Ireland" }
|
||
],
|
||
fundI: 2000000,
|
||
fundII: 2500000,
|
||
fundIII: 3000000,
|
||
tactical: 1000000,
|
||
pawnE4: 500000,
|
||
pawnF4: 250000,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Top investor relationship. Brendan very engaged. Laura handles ops coordination.",
|
||
lastContact: "2025-02-10"
|
||
},
|
||
{
|
||
id: 4,
|
||
name: "Corbin Capital",
|
||
type: "investor",
|
||
lead: "Grant",
|
||
priority: "medium",
|
||
followUp: false,
|
||
graveyard: false,
|
||
leadSource: "conference",
|
||
manualLeadSource: "Bitcoin Miami 2024",
|
||
contacts: [
|
||
{ name: "Craig Bergstrom", email: "craig@corbincap.com", title: "CIO", location: "Greenwich, CT, USA" },
|
||
{ name: "Michelle Torres", email: "michelle@corbincap.com", title: "Analyst", location: "Greenwich, CT, USA" }
|
||
],
|
||
fundI: 300000,
|
||
fundII: 400000,
|
||
fundIII: 500000,
|
||
tactical: 150000,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Craig is crypto-curious. Michelle will do detailed DD. Plan follow-up in March.",
|
||
lastContact: "2025-01-15"
|
||
},
|
||
{
|
||
id: 5,
|
||
name: "Bessemer Trust",
|
||
type: "investor",
|
||
lead: "Matt",
|
||
priority: "medium",
|
||
followUp: false,
|
||
graveyard: false,
|
||
leadSource: "outbound",
|
||
manualLeadSource: "Cold email campaign",
|
||
contacts: [
|
||
{ name: "Richard Park", email: "rpark@bessemer.com", title: "SVP", location: "New York City, NY, USA" },
|
||
{ name: "Amanda Liu", email: "aliu@bessemer.com", title: "Wealth Advisor", location: "New York City, NY, USA" }
|
||
],
|
||
fundI: 200000,
|
||
fundII: 250000,
|
||
fundIII: 300000,
|
||
tactical: 100000,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Bessemer considering alternative allocation. Richard approval needed. Slow process.",
|
||
lastContact: "2025-01-20"
|
||
},
|
||
{
|
||
id: 6,
|
||
name: "Carnegie Endowment",
|
||
type: "investor",
|
||
lead: "JP",
|
||
priority: "medium",
|
||
followUp: false,
|
||
graveyard: false,
|
||
leadSource: "referral",
|
||
manualLeadSource: "Board introduction",
|
||
contacts: [
|
||
{ name: "David Walsh", email: "dwalsh@carnegie.org", title: "Investment Director", location: "Washington, DC, USA" },
|
||
{ name: "Patricia Nguyen", email: "pnguyen@carnegie.org", title: "Portfolio Manager", location: "Washington, DC, USA" }
|
||
],
|
||
fundI: 1000000,
|
||
fundII: 1200000,
|
||
fundIII: 1500000,
|
||
tactical: 500000,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Long-term strategic relationship. David approves big picture, Patricia manages details.",
|
||
lastContact: "2025-02-05"
|
||
},
|
||
{
|
||
id: 7,
|
||
name: "Stanford Endowment",
|
||
type: "investor",
|
||
lead: "Grant",
|
||
priority: "medium",
|
||
followUp: false,
|
||
graveyard: false,
|
||
leadSource: "network",
|
||
manualLeadSource: "Stanford alumni event",
|
||
contacts: [
|
||
{ name: "Robert Kim", email: "rkim@stanford.edu", title: "Director of Alternatives", location: "Palo Alto, CA, USA" },
|
||
{ name: "Jennifer Adams", email: "jadams@stanford.edu", title: "Associate", location: "Palo Alto, CA, USA" }
|
||
],
|
||
fundI: 800000,
|
||
fundII: 1000000,
|
||
fundIII: 1200000,
|
||
tactical: 400000,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Strong endowment interest. Robert decision-maker. Jennifer coordinating technical reviews.",
|
||
lastContact: "2025-01-30"
|
||
},
|
||
{
|
||
id: 8,
|
||
name: "Danny Kramer",
|
||
type: "investor",
|
||
lead: "Grant",
|
||
priority: "high",
|
||
followUp: false,
|
||
graveyard: false,
|
||
leadSource: "personal",
|
||
manualLeadSource: "Personal connection",
|
||
contacts: [
|
||
{ name: "Danny Kramer", email: "danny@kramerinv.com", title: "", location: "Miami, FL, USA" }
|
||
],
|
||
fundI: 500000,
|
||
fundII: 500000,
|
||
fundIII: 750000,
|
||
tactical: 250000,
|
||
pawnE4: 100000,
|
||
pawnF4: 50000,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Early supporter. Very active in community. Attends all investor events.",
|
||
lastContact: "2025-02-12"
|
||
},
|
||
{
|
||
id: 9,
|
||
name: "Howard Love",
|
||
type: "investor",
|
||
lead: "Matt",
|
||
priority: "high",
|
||
followUp: false,
|
||
graveyard: false,
|
||
leadSource: "referral",
|
||
manualLeadSource: "Caprock introduction",
|
||
contacts: [
|
||
{ name: "Howard Love", email: "howard@loveinv.com", title: "Founder", location: "San Francisco, CA, USA" }
|
||
],
|
||
fundI: 300000,
|
||
fundII: 400000,
|
||
fundIII: 500000,
|
||
tactical: 200000,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Family office based in Austin. Very responsive. Casual culture.",
|
||
lastContact: "2025-02-08"
|
||
},
|
||
{
|
||
id: 10,
|
||
name: "Mark Patterson",
|
||
type: "investor",
|
||
lead: "JP",
|
||
priority: "medium",
|
||
followUp: false,
|
||
graveyard: false,
|
||
leadSource: "conference",
|
||
manualLeadSource: "Porcfest 2024",
|
||
contacts: [
|
||
{ name: "Mark Patterson", email: "mark@pattersonwealth.com", title: "", location: "Dallas, TX, USA" }
|
||
],
|
||
fundI: 150000,
|
||
fundII: 150000,
|
||
fundIII: 200000,
|
||
tactical: 50000,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Bitcoin maxi. Small allocation but very supportive of mission.",
|
||
lastContact: "2025-02-02"
|
||
},
|
||
{
|
||
id: 11,
|
||
name: "Thistledown Capital",
|
||
type: "prospect",
|
||
lead: "Grant",
|
||
priority: "high",
|
||
followUp: true,
|
||
graveyard: false,
|
||
leadSource: "outbound",
|
||
manualLeadSource: "Prospect research list",
|
||
contacts: [
|
||
{ name: "James McCaffrey", email: "james@thistledown.com", title: "Principal", location: "Boston, MA, USA" }
|
||
],
|
||
fundI: 0,
|
||
fundII: 0,
|
||
fundIII: 0,
|
||
tactical: 0,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Strong prospect. Need to schedule initial meeting for Feb. James responsive on email.",
|
||
lastContact: "2025-01-22"
|
||
},
|
||
{
|
||
id: 12,
|
||
name: "Ridgeline Ventures",
|
||
type: "prospect",
|
||
lead: "Matt",
|
||
priority: "high",
|
||
followUp: false,
|
||
graveyard: false,
|
||
leadSource: "inbound",
|
||
manualLeadSource: "Warm intro from AngelList",
|
||
contacts: [
|
||
{ name: "Karen Wu", email: "karen@ridgelinevc.com", title: "GP", location: "Denver, CO, USA" }
|
||
],
|
||
fundI: 0,
|
||
fundII: 0,
|
||
fundIII: 0,
|
||
tactical: 0,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "VC firm focused on fintech. Karen saw pitch video, wants full deck.",
|
||
lastContact: "2025-02-11"
|
||
},
|
||
{
|
||
id: 13,
|
||
name: "Northstar Family Office",
|
||
type: "prospect",
|
||
lead: "JP",
|
||
priority: "medium",
|
||
followUp: false,
|
||
graveyard: false,
|
||
leadSource: "network",
|
||
manualLeadSource: "Ten31 ecosystem",
|
||
contacts: [
|
||
{ name: "Tom Bradley", email: "tom@northstarfo.com", title: "CIO", location: "Chicago, IL, USA" }
|
||
],
|
||
fundI: 0,
|
||
fundII: 0,
|
||
fundIII: 0,
|
||
tactical: 0,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Moderate assets under management. Conservative mandate. Long sales cycle expected.",
|
||
lastContact: "2025-01-25"
|
||
},
|
||
{
|
||
id: 14,
|
||
name: "Briarwood Wealth",
|
||
type: "prospect",
|
||
lead: "Grant",
|
||
priority: "medium",
|
||
followUp: false,
|
||
graveyard: false,
|
||
leadSource: "outbound",
|
||
manualLeadSource: "LinkedIn outreach",
|
||
contacts: [
|
||
{ name: "Susan Chen", email: "susan@briarwood.com", title: "Managing Partner", location: "Nashville, TN, USA" }
|
||
],
|
||
fundI: 0,
|
||
fundII: 0,
|
||
fundIII: 0,
|
||
tactical: 0,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "RIA with crypto-friendly clients. Susan curious about allocation strategy.",
|
||
lastContact: "2025-01-18"
|
||
},
|
||
{
|
||
id: 15,
|
||
name: "Citadel Peak Advisors",
|
||
type: "prospect",
|
||
lead: "Matt",
|
||
priority: "medium",
|
||
followUp: false,
|
||
graveyard: false,
|
||
leadSource: "referral",
|
||
manualLeadSource: "Referred by Briarwood",
|
||
contacts: [
|
||
{ name: "Alex Rivera", email: "alex@citadelpeak.com", title: "Founder", location: "Scottsdale, AZ, USA" }
|
||
],
|
||
fundI: 0,
|
||
fundII: 0,
|
||
fundIII: 0,
|
||
tactical: 0,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Young, dynamic firm. Alex very interested in Bitcoin-focused products.",
|
||
lastContact: "2025-02-03"
|
||
},
|
||
{
|
||
id: 16,
|
||
name: "Granite Point Capital",
|
||
type: "prospect",
|
||
lead: "JP",
|
||
priority: "low",
|
||
followUp: false,
|
||
graveyard: false,
|
||
leadSource: "conference",
|
||
manualLeadSource: "Consensus 2024",
|
||
contacts: [
|
||
{ name: "William Foster", email: "wfoster@granitept.com", title: "Portfolio Manager", location: "Salt Lake City, UT, USA" }
|
||
],
|
||
fundI: 0,
|
||
fundII: 0,
|
||
fundIII: 0,
|
||
tactical: 0,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Met William at conference. Cautious on crypto but open to discussion.",
|
||
lastContact: "2025-01-28"
|
||
},
|
||
{
|
||
id: 17,
|
||
name: "Blue Ridge Endowment",
|
||
type: "prospect",
|
||
lead: "Grant",
|
||
priority: "low",
|
||
followUp: false,
|
||
graveyard: false,
|
||
leadSource: "outbound",
|
||
manualLeadSource: "Endowment database",
|
||
contacts: [
|
||
{ name: "Margaret Collins", email: "mcollins@blueridge.org", title: "Executive Director", location: "Charlotte, NC, USA" }
|
||
],
|
||
fundI: 0,
|
||
fundII: 0,
|
||
fundIII: 0,
|
||
tactical: 0,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Early stage outreach. Margaret responded briefly but needs more context.",
|
||
lastContact: "2025-01-10"
|
||
},
|
||
{
|
||
id: 18,
|
||
name: "Blackthorn Group",
|
||
type: "lead",
|
||
lead: "Matt",
|
||
priority: "high",
|
||
followUp: true,
|
||
graveyard: false,
|
||
leadSource: "inbound",
|
||
manualLeadSource: "Webinar signup",
|
||
contacts: [
|
||
{ name: "Sean Murphy", email: "sean@blackthorn.com", title: "Partner", location: "London, England, UK" }
|
||
],
|
||
fundI: 0,
|
||
fundII: 0,
|
||
fundIII: 0,
|
||
tactical: 0,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Signed up for webinar, attended live. Need to send follow-up deck.",
|
||
lastContact: "2025-02-06"
|
||
},
|
||
{
|
||
id: 19,
|
||
name: "Vermillion Partners",
|
||
type: "lead",
|
||
lead: "JP",
|
||
priority: "medium",
|
||
followUp: false,
|
||
graveyard: false,
|
||
leadSource: "outbound",
|
||
manualLeadSource: "Cold email",
|
||
contacts: [
|
||
{ name: "Rachel Goldstein", email: "rachel@vermillion.com", title: "Managing Director", location: "Palm Beach, FL, USA" }
|
||
],
|
||
fundI: 0,
|
||
fundII: 0,
|
||
fundIII: 0,
|
||
tactical: 0,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Rachel replied to cold email. Initial discovery call scheduled for Feb 20.",
|
||
lastContact: "2025-02-01"
|
||
},
|
||
{
|
||
id: 20,
|
||
name: "Ironwood Associates",
|
||
type: "lead",
|
||
lead: "Grant",
|
||
priority: "medium",
|
||
followUp: false,
|
||
graveyard: false,
|
||
leadSource: "referral",
|
||
manualLeadSource: "Investor network",
|
||
contacts: [
|
||
{ name: "Peter Chang", email: "peter@ironwood.com", title: "CIO", location: "Houston, TX, USA" }
|
||
],
|
||
fundI: 0,
|
||
fundII: 0,
|
||
fundIII: 0,
|
||
tactical: 0,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Warm intro from Danny Kramer. Peter open to learning about fund structure.",
|
||
lastContact: "2025-01-31"
|
||
},
|
||
{
|
||
id: 21,
|
||
name: "Summit Rock Advisors",
|
||
type: "lead",
|
||
lead: "Matt",
|
||
priority: "low",
|
||
followUp: false,
|
||
graveyard: false,
|
||
leadSource: "conference",
|
||
manualLeadSource: "Bitcoin Miami 2024",
|
||
contacts: [
|
||
{ name: "Catherine Blake", email: "catherine@summitrock.com", title: "Director", location: "New York City, NY, USA" }
|
||
],
|
||
fundI: 0,
|
||
fundII: 0,
|
||
fundIII: 0,
|
||
tactical: 0,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Collected business card. Catherine on passive contact list.",
|
||
lastContact: "2024-12-20"
|
||
},
|
||
{
|
||
id: 22,
|
||
name: "Osprey Capital",
|
||
type: "graveyard",
|
||
lead: "JP",
|
||
priority: "low",
|
||
followUp: false,
|
||
graveyard: true,
|
||
leadSource: "inbound",
|
||
manualLeadSource: "Website form",
|
||
contacts: [
|
||
{ name: "Nathan Drake", email: "nathan@ospreycap.com", title: "GP", location: "Seattle, WA, USA" }
|
||
],
|
||
fundI: 0,
|
||
fundII: 0,
|
||
fundIII: 0,
|
||
tactical: 0,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Marked graveyard after multiple follow-up attempts. Nathan unresponsive.",
|
||
lastContact: "2024-10-15"
|
||
},
|
||
{
|
||
id: 23,
|
||
name: "Silverleaf Investments",
|
||
type: "graveyard",
|
||
lead: "Grant",
|
||
priority: "low",
|
||
followUp: false,
|
||
graveyard: true,
|
||
leadSource: "outbound",
|
||
manualLeadSource: "LinkedIn outreach",
|
||
contacts: [
|
||
{ name: "Diana Ross", email: "diana@silverleaf.com", title: "Founder", location: "Aspen, CO, USA" }
|
||
],
|
||
fundI: 0,
|
||
fundII: 0,
|
||
fundIII: 0,
|
||
tactical: 0,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "No response to 3+ outreach attempts. Archived for now.",
|
||
lastContact: "2024-09-05"
|
||
},
|
||
{
|
||
id: 24,
|
||
name: "Pacific Grove Trust",
|
||
type: "graveyard",
|
||
lead: "Matt",
|
||
priority: "low",
|
||
followUp: false,
|
||
graveyard: true,
|
||
leadSource: "referral",
|
||
manualLeadSource: "Bessemer introduction",
|
||
contacts: [
|
||
{ name: "Eric Tanaka", email: "eric@pacificgrove.com", title: "VP", location: "San Diego, CA, USA" }
|
||
],
|
||
fundI: 0,
|
||
fundII: 0,
|
||
fundIII: 0,
|
||
tactical: 0,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Eric took meeting but firm policy prevents crypto allocation. Not revisiting.",
|
||
lastContact: "2024-11-30"
|
||
},
|
||
{
|
||
id: 25,
|
||
name: "Mountainview Endowment",
|
||
type: "prospect",
|
||
lead: "JP",
|
||
priority: "low",
|
||
followUp: false,
|
||
graveyard: false,
|
||
leadSource: "network",
|
||
manualLeadSource: "Annual conference",
|
||
contacts: [
|
||
{ name: "Lisa Chen", email: "lchen@mountainview.org", title: "Director of Investments", location: "Portland, OR, USA" }
|
||
],
|
||
fundI: 0,
|
||
fundII: 0,
|
||
fundIII: 0,
|
||
tactical: 0,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "Lisa expressed interest at conference. Sent preliminary materials.",
|
||
lastContact: "2025-01-12"
|
||
}
|
||
];
|
||
|
||
// View definitions with column visibility
|
||
const defaultViews = [
|
||
{
|
||
id: 'all',
|
||
name: 'All Investors',
|
||
builtin: true,
|
||
visibleColumns: ['name', 'contacts', 'type', 'lead', 'priority', 'status', 'fundI', 'fundII', 'fundIII', 'tactical', 'pawnE4', 'pawnF4', 'ten31Terahash', 'satsAndStats', 'joinTheFold', 'total', 'lastContact', 'notes'],
|
||
sortColumn: 'name',
|
||
sortOrder: 'asc',
|
||
filterCriteria: {}
|
||
},
|
||
{
|
||
id: 'investors',
|
||
name: 'Investors',
|
||
builtin: true,
|
||
visibleColumns: ['name', 'contacts', 'type', 'lead', 'priority', 'status', 'fundI', 'fundII', 'fundIII', 'tactical', 'pawnE4', 'pawnF4', 'ten31Terahash', 'satsAndStats', 'joinTheFold', 'total', 'lastContact', 'notes'],
|
||
sortColumn: 'name',
|
||
sortOrder: 'asc',
|
||
filterCriteria: { type: 'investor' }
|
||
},
|
||
{
|
||
id: 'prospects',
|
||
name: 'Prospects',
|
||
builtin: true,
|
||
visibleColumns: ['name', 'contacts', 'type', 'lead', 'priority', 'status', 'total', 'lastContact', 'notes'],
|
||
sortColumn: 'name',
|
||
sortOrder: 'asc',
|
||
filterCriteria: { type: 'prospect' }
|
||
},
|
||
{
|
||
id: 'leads',
|
||
name: 'Leads',
|
||
builtin: true,
|
||
visibleColumns: ['name', 'contacts', 'lead', 'priority', 'status', 'lastContact', 'notes'],
|
||
sortColumn: 'priority',
|
||
sortOrder: 'asc',
|
||
filterCriteria: { type: 'lead' }
|
||
},
|
||
{
|
||
id: 'followUp',
|
||
name: 'Follow-Up List',
|
||
builtin: true,
|
||
visibleColumns: ['name', 'contacts', 'lead', 'priority', 'lastContact', 'notes'],
|
||
sortColumn: 'priority',
|
||
sortOrder: 'asc',
|
||
filterCriteria: { followUp: true }
|
||
},
|
||
{
|
||
id: 'graveyard',
|
||
name: 'Graveyard',
|
||
builtin: true,
|
||
visibleColumns: ['name', 'type', 'lead', 'notes'],
|
||
sortColumn: 'name',
|
||
sortOrder: 'asc',
|
||
filterCriteria: { graveyard: true }
|
||
},
|
||
{
|
||
id: 'fundI',
|
||
name: 'Fund I Investors',
|
||
builtin: true,
|
||
visibleColumns: ['name', 'contacts', 'type', 'lead', 'priority', 'status', 'fundI', 'total', 'lastContact', 'notes'],
|
||
sortColumn: 'name',
|
||
sortOrder: 'asc',
|
||
filterCriteria: { fundFilter: 'fundI' }
|
||
},
|
||
{
|
||
id: 'fundII',
|
||
name: 'Fund II Investors',
|
||
builtin: true,
|
||
visibleColumns: ['name', 'contacts', 'type', 'lead', 'priority', 'status', 'fundII', 'total', 'lastContact', 'notes'],
|
||
sortColumn: 'name',
|
||
sortOrder: 'asc',
|
||
filterCriteria: { fundFilter: 'fundII' }
|
||
},
|
||
{
|
||
id: 'fundIII',
|
||
name: 'Fund III Investors',
|
||
builtin: true,
|
||
visibleColumns: ['name', 'contacts', 'type', 'lead', 'priority', 'status', 'fundIII', 'total', 'lastContact', 'notes'],
|
||
sortColumn: 'name',
|
||
sortOrder: 'asc',
|
||
filterCriteria: { fundFilter: 'fundIII' }
|
||
},
|
||
{
|
||
id: 'tactical',
|
||
name: 'Tactical Fund Investors',
|
||
builtin: true,
|
||
visibleColumns: ['name', 'contacts', 'type', 'lead', 'priority', 'status', 'tactical', 'total', 'lastContact', 'notes'],
|
||
sortColumn: 'name',
|
||
sortOrder: 'asc',
|
||
filterCriteria: { fundFilter: 'tactical' }
|
||
}
|
||
];
|
||
|
||
const allColumns = [
|
||
{ id: 'name', label: 'Name' },
|
||
{ id: 'contacts', label: 'Contacts' },
|
||
{ id: 'type', label: 'Type' },
|
||
{ id: 'lead', label: 'Lead' },
|
||
{ id: 'priority', label: 'Priority' },
|
||
{ id: 'status', label: 'Status' },
|
||
{ id: 'fundI', label: 'Fund I' },
|
||
{ id: 'fundII', label: 'Fund II' },
|
||
{ id: 'fundIII', label: 'Fund III' },
|
||
{ id: 'tactical', label: 'Tactical Fund' },
|
||
{ id: 'pawnE4', label: 'Pawn to E4' },
|
||
{ id: 'pawnF4', label: 'Pawn to f4' },
|
||
{ id: 'ten31Terahash', label: 'Ten31 Terahash' },
|
||
{ id: 'satsAndStats', label: 'Sats and Stats' },
|
||
{ id: 'joinTheFold', label: 'Join the Fold' },
|
||
{ id: 'total', label: 'Total Invested' },
|
||
{ id: 'lastContact', label: 'Last Contact' },
|
||
{ id: 'notes', label: 'Notes' }
|
||
];
|
||
|
||
// Utility functions outside App component
|
||
const applyFilterCriteria = (investors, criteria) => {
|
||
let filtered = investors;
|
||
|
||
if (criteria.type) {
|
||
filtered = filtered.filter(i => i.type === criteria.type);
|
||
}
|
||
|
||
if (criteria.followUp) {
|
||
filtered = filtered.filter(i => i.followUp);
|
||
}
|
||
|
||
if (criteria.graveyard) {
|
||
filtered = filtered.filter(i => i.graveyard);
|
||
}
|
||
|
||
if (criteria.fundFilter) {
|
||
const fundKey = criteria.fundFilter;
|
||
filtered = filtered.filter(i => i[fundKey] > 0);
|
||
}
|
||
|
||
return filtered;
|
||
};
|
||
|
||
const getCurrentView = (selectedView, defaultViews, customViews) => {
|
||
const built = defaultViews.find(v => v.id === selectedView);
|
||
if (built) return built;
|
||
return customViews.find(v => v.id === selectedView);
|
||
};
|
||
|
||
const getCurrentVisibleColumns = (selectedView, defaultViews, customViews) => {
|
||
const view = getCurrentView(selectedView, defaultViews, customViews);
|
||
if (view) return view.visibleColumns;
|
||
return defaultViews[0].visibleColumns;
|
||
};
|
||
|
||
|
||
// Location autocomplete data
|
||
const LOCATIONS = [
|
||
"New York City, NY, USA", "Los Angeles, CA, USA", "Chicago, IL, USA", "Houston, TX, USA",
|
||
"Phoenix, AZ, USA", "Philadelphia, PA, USA", "San Antonio, TX, USA", "San Diego, CA, USA",
|
||
"Dallas, TX, USA", "San Jose, CA, USA", "Austin, TX, USA", "Jacksonville, FL, USA",
|
||
"Fort Worth, TX, USA", "Columbus, OH, USA", "Charlotte, NC, USA", "Indianapolis, IN, USA",
|
||
"San Francisco, CA, USA", "Seattle, WA, USA", "Denver, CO, USA", "Nashville, TN, USA",
|
||
"Oklahoma City, OK, USA", "El Paso, TX, USA", "Washington, DC, USA", "Boston, MA, USA",
|
||
"Portland, OR, USA", "Las Vegas, NV, USA", "Memphis, TN, USA", "Louisville, KY, USA",
|
||
"Baltimore, MD, USA", "Milwaukee, WI, USA", "Albuquerque, NM, USA", "Tucson, AZ, USA",
|
||
"Fresno, CA, USA", "Sacramento, CA, USA", "Mesa, AZ, USA", "Kansas City, MO, USA",
|
||
"Atlanta, GA, USA", "Omaha, NE, USA", "Colorado Springs, CO, USA", "Raleigh, NC, USA",
|
||
"Long Beach, CA, USA", "Virginia Beach, VA, USA", "Miami, FL, USA", "Oakland, CA, USA",
|
||
"Minneapolis, MN, USA", "Tampa, FL, USA", "Tulsa, OK, USA", "Arlington, TX, USA",
|
||
"New Orleans, LA, USA", "Cleveland, OH, USA", "Honolulu, HI, USA", "Anchorage, AK, USA",
|
||
"Pittsburgh, PA, USA", "Cincinnati, OH, USA", "St. Louis, MO, USA", "Detroit, MI, USA",
|
||
"Salt Lake City, UT, USA", "Scottsdale, AZ, USA", "Greenwich, CT, USA", "Stamford, CT, USA",
|
||
"Palm Beach, FL, USA", "Naples, FL, USA", "Palo Alto, CA, USA", "Menlo Park, CA, USA",
|
||
"Beverly Hills, CA, USA", "Boca Raton, FL, USA", "Aspen, CO, USA", "Jackson Hole, WY, USA",
|
||
"Charleston, SC, USA", "Savannah, GA, USA", "Boulder, CO, USA", "Santa Monica, CA, USA",
|
||
"Newport Beach, CA, USA", "Westport, CT, USA", "Darien, CT, USA", "New Canaan, CT, USA",
|
||
"Short Hills, NJ, USA", "Summit, NJ, USA", "Princeton, NJ, USA", "Montclair, NJ, USA",
|
||
"London, England, UK", "Edinburgh, Scotland, UK", "Manchester, England, UK",
|
||
"Toronto, ON, Canada", "Vancouver, BC, Canada", "Montreal, QC, Canada", "Calgary, AB, Canada",
|
||
"Zurich, Switzerland", "Geneva, Switzerland", "Singapore", "Hong Kong, China",
|
||
"Sydney, NSW, Australia", "Melbourne, VIC, Australia", "Dubai, UAE", "Abu Dhabi, UAE",
|
||
"Frankfurt, Germany", "Munich, Germany", "Berlin, Germany", "Paris, France",
|
||
"Amsterdam, Netherlands", "Luxembourg City, Luxembourg", "Tokyo, Japan", "Seoul, South Korea",
|
||
"Tel Aviv, Israel", "São Paulo, Brazil", "Mexico City, Mexico", "Dublin, Ireland",
|
||
"Cayman Islands", "Nassau, Bahamas", "Hamilton, Bermuda", "Zug, Switzerland",
|
||
"El Salvador", "Panama City, Panama", "Liechtenstein"
|
||
];
|
||
|
||
const LOCATION_ALIASES = {
|
||
"new york": "New York City, NY, USA", "nyc": "New York City, NY, USA", "manhattan": "New York City, NY, USA",
|
||
"la": "Los Angeles, CA, USA", "sf": "San Francisco, CA, USA", "dc": "Washington, DC, USA",
|
||
"vegas": "Las Vegas, NV, USA", "philly": "Philadelphia, PA, USA", "nola": "New Orleans, LA, USA",
|
||
"silicon valley": "Palo Alto, CA, USA", "the city": "San Francisco, CA, USA"
|
||
};
|
||
|
||
function LocationInput({ value, onChange, style }) {
|
||
const [inputVal, setInputVal] = useState(value || "");
|
||
const [suggestions, setSuggestions] = useState([]);
|
||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||
const [selectedIdx, setSelectedIdx] = useState(-1);
|
||
|
||
const handleInputChange = (e) => {
|
||
const val = e.target.value;
|
||
setInputVal(val);
|
||
setSelectedIdx(-1);
|
||
|
||
if (val.length < 2) {
|
||
setSuggestions([]);
|
||
setShowSuggestions(false);
|
||
return;
|
||
}
|
||
|
||
const lower = val.toLowerCase();
|
||
// Check aliases first
|
||
const aliasMatch = LOCATION_ALIASES[lower];
|
||
let matches = [];
|
||
if (aliasMatch) {
|
||
matches.push(aliasMatch);
|
||
}
|
||
// Filter locations
|
||
const filtered = LOCATIONS.filter(loc =>
|
||
loc.toLowerCase().includes(lower) && loc !== aliasMatch
|
||
);
|
||
matches = [...matches, ...filtered].slice(0, 8);
|
||
setSuggestions(matches);
|
||
setShowSuggestions(matches.length > 0);
|
||
};
|
||
|
||
const selectSuggestion = (loc) => {
|
||
setInputVal(loc);
|
||
onChange(loc);
|
||
setShowSuggestions(false);
|
||
setSuggestions([]);
|
||
};
|
||
|
||
const handleKeyDown = (e) => {
|
||
if (!showSuggestions) return;
|
||
if (e.key === "ArrowDown") {
|
||
e.preventDefault();
|
||
setSelectedIdx(prev => Math.min(prev + 1, suggestions.length - 1));
|
||
} else if (e.key === "ArrowUp") {
|
||
e.preventDefault();
|
||
setSelectedIdx(prev => Math.max(prev - 1, -1));
|
||
} else if (e.key === "Enter" && selectedIdx >= 0) {
|
||
e.preventDefault();
|
||
selectSuggestion(suggestions[selectedIdx]);
|
||
} else if (e.key === "Escape") {
|
||
setShowSuggestions(false);
|
||
}
|
||
};
|
||
|
||
const handleBlur = () => {
|
||
// Delay to allow click on suggestion
|
||
setTimeout(() => {
|
||
setShowSuggestions(false);
|
||
if (inputVal !== value) onChange(inputVal);
|
||
}, 200);
|
||
};
|
||
|
||
return React.createElement("div", { style: { position: "relative", ...style } },
|
||
React.createElement("input", {
|
||
type: "text",
|
||
placeholder: "City, State, Country",
|
||
value: inputVal,
|
||
onChange: handleInputChange,
|
||
onKeyDown: handleKeyDown,
|
||
onBlur: handleBlur,
|
||
onFocus: () => { if (suggestions.length > 0) setShowSuggestions(true); },
|
||
style: { display: "block", width: "100%", padding: "4px", background: "#0f172a", border: "1px solid #334155", borderRadius: "4px", color: "#e2e8f0", fontSize: "13px" }
|
||
}),
|
||
showSuggestions && React.createElement("div", {
|
||
style: {
|
||
position: "absolute", top: "100%", left: 0, right: 0, zIndex: 1000,
|
||
background: "#1e293b", border: "1px solid #334155", borderRadius: "4px",
|
||
maxHeight: "200px", overflowY: "auto", marginTop: "2px", boxShadow: "0 4px 12px rgba(0,0,0,0.4)"
|
||
}
|
||
}, suggestions.map((loc, i) =>
|
||
React.createElement("div", {
|
||
key: loc,
|
||
onMouseDown: (e) => { e.preventDefault(); selectSuggestion(loc); },
|
||
style: {
|
||
padding: "6px 8px", cursor: "pointer", fontSize: "12px",
|
||
color: i === selectedIdx ? "#fff" : "#cbd5e1",
|
||
background: i === selectedIdx ? "#6366f1" : "transparent"
|
||
}
|
||
}, loc)
|
||
))
|
||
);
|
||
}
|
||
|
||
// Main App Component
|
||
function App() {
|
||
const [investors, setInvestors] = useState(initialInvestors);
|
||
const [selectedView, setSelectedView] = useState("all");
|
||
const [searchTerm, setSearchTerm] = useState("");
|
||
const [sortBy, setSortBy] = useState("name");
|
||
const [sortOrder, setSortOrder] = useState("asc");
|
||
const [selectedInvestor, setSelectedInvestor] = useState(null);
|
||
const [editingCell, setEditingCell] = useState(null);
|
||
const [showAddModal, setShowAddModal] = useState(false);
|
||
const [newInvestor, setNewInvestor] = useState({
|
||
name: "",
|
||
type: "prospect",
|
||
lead: "Grant",
|
||
priority: "medium",
|
||
contacts: [{ name: "", email: "", title: "", location: "" }]
|
||
});
|
||
const [tooltipData, setTooltipData] = useState(null);
|
||
const [customViews, setCustomViews] = useState([]);
|
||
const [visibleColumns, setVisibleColumns] = useState(null);
|
||
const [showColumnDropdown, setShowColumnDropdown] = useState(false);
|
||
const [showSaveViewModal, setShowSaveViewModal] = useState(false);
|
||
const [saveViewName, setSaveViewName] = useState("");
|
||
const [viewModified, setViewModified] = useState(false);
|
||
const [filterCriteria, setFilterCriteria] = useState({
|
||
search: "",
|
||
type: "",
|
||
graveyard: null,
|
||
followUp: null,
|
||
fundFilter: ""
|
||
});
|
||
const [activeFilters, setActiveFilters] = useState([]);
|
||
const [showFilterPanel, setShowFilterPanel] = useState(false);
|
||
const [customColumns, setCustomColumns] = useState([]);
|
||
const [showCustomColumnModal, setShowCustomColumnModal] = useState(false);
|
||
const [newColumnName, setNewColumnName] = useState("");
|
||
const [newColumnType, setNewColumnType] = useState("text");
|
||
const [newColumnOptions, setNewColumnOptions] = useState("");
|
||
const [contextMenu, setContextMenu] = useState(null); // { x, y, investor }
|
||
|
||
// Close context menu on click anywhere
|
||
useEffect(() => {
|
||
const handleClick = () => setContextMenu(null);
|
||
document.addEventListener("click", handleClick);
|
||
return () => document.removeEventListener("click", handleClick);
|
||
}, []);
|
||
|
||
const handleContextMenu = (e, investor) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setContextMenu({ x: e.clientX, y: e.clientY, investor });
|
||
};
|
||
|
||
const contextMenuAction = (action) => {
|
||
if (!contextMenu) return;
|
||
const inv = contextMenu.investor;
|
||
const update = (field, value) => {
|
||
const updated = { ...inv, [field]: value };
|
||
setInvestors(investors.map(i => i.id === inv.id ? updated : i));
|
||
if (selectedInvestor && selectedInvestor.id === inv.id) setSelectedInvestor(updated);
|
||
};
|
||
|
||
switch (action) {
|
||
case "view":
|
||
setSelectedInvestor(inv);
|
||
break;
|
||
case "edit":
|
||
setEditingCell({ investorId: inv.id, field: "name" });
|
||
break;
|
||
case "followUp":
|
||
update("followUp", !inv.followUp);
|
||
break;
|
||
case "graveyard":
|
||
update("graveyard", !inv.graveyard);
|
||
break;
|
||
case "assignGrant":
|
||
update("lead", "Grant");
|
||
break;
|
||
case "assignMatt":
|
||
update("lead", "Matt");
|
||
break;
|
||
case "assignJP":
|
||
update("lead", "JP");
|
||
break;
|
||
case "priorityHigh":
|
||
update("priority", "high");
|
||
break;
|
||
case "priorityMedium":
|
||
update("priority", "medium");
|
||
break;
|
||
case "priorityLow":
|
||
update("priority", "low");
|
||
break;
|
||
case "duplicate":
|
||
const dup = { ...inv, id: Math.max(...investors.map(i => i.id)) + 1, name: inv.name + " (Copy)" };
|
||
setInvestors([...investors, dup]);
|
||
break;
|
||
case "delete":
|
||
if (confirm("Delete " + inv.name + "? This cannot be undone.")) {
|
||
setInvestors(investors.filter(i => i.id !== inv.id));
|
||
if (selectedInvestor && selectedInvestor.id === inv.id) setSelectedInvestor(null);
|
||
}
|
||
break;
|
||
}
|
||
setContextMenu(null);
|
||
};
|
||
|
||
// Filter investors
|
||
const filteredInvestors = useMemo(() => {
|
||
let filtered = investors;
|
||
|
||
// View filter
|
||
if (selectedView === "investors") {
|
||
filtered = filtered.filter(i => i.type === "investor");
|
||
} else if (selectedView === "prospects") {
|
||
filtered = filtered.filter(i => i.type === "prospect");
|
||
} else if (selectedView === "leads") {
|
||
filtered = filtered.filter(i => i.type === "lead");
|
||
} else if (selectedView === "followUp") {
|
||
filtered = filtered.filter(i => i.followUp);
|
||
} else if (selectedView === "graveyard") {
|
||
filtered = filtered.filter(i => i.graveyard);
|
||
} else if (selectedView.startsWith("team-")) {
|
||
const member = selectedView.replace("team-", "");
|
||
filtered = filtered.filter(i => i.lead === member);
|
||
}
|
||
|
||
// Search filter (match against entity name AND contact names/emails)
|
||
if (searchTerm) {
|
||
const lowerSearch = searchTerm.toLowerCase();
|
||
filtered = filtered.filter(i => {
|
||
const nameMatch = i.name.toLowerCase().includes(lowerSearch);
|
||
const contactMatch = i.contacts.some(c =>
|
||
c.name.toLowerCase().includes(lowerSearch) ||
|
||
c.email.toLowerCase().includes(lowerSearch)
|
||
);
|
||
return nameMatch || contactMatch;
|
||
});
|
||
}
|
||
|
||
|
||
// Advanced filters (ANDed together)
|
||
if (activeFilters.length > 0) {
|
||
filtered = filtered.filter(investor => {
|
||
return activeFilters.every(filter => {
|
||
const val = investor[filter.field];
|
||
if (filter.field === "contacts" || filter.field === "location") {
|
||
// Search in contact names and locations
|
||
const contactMatch = investor.contacts.some(c => {
|
||
const fieldVal = filter.field === "contacts" ? c.name : c.location;
|
||
if (filter.operator === "contains") return fieldVal.toLowerCase().includes(filter.value.toLowerCase());
|
||
if (filter.operator === "does not contain") return !fieldVal.toLowerCase().includes(filter.value.toLowerCase());
|
||
return true;
|
||
});
|
||
return contactMatch;
|
||
}
|
||
|
||
// Text fields
|
||
if (["name", "lead", "notes"].includes(filter.field)) {
|
||
if (filter.operator === "contains") return String(val || "").toLowerCase().includes(filter.value.toLowerCase());
|
||
if (filter.operator === "does not contain") return !String(val || "").toLowerCase().includes(filter.value.toLowerCase());
|
||
if (filter.operator === "equals") return String(val || "").toLowerCase() === filter.value.toLowerCase();
|
||
if (filter.operator === "is empty") return !val || val === "";
|
||
if (filter.operator === "is not empty") return val && val !== "";
|
||
}
|
||
|
||
// Number/currency fields
|
||
if (["fundI", "fundII", "fundIII", "tactical", "pawnE4", "pawnF4", "ten31Terahash", "satsAndStats", "joinTheFold", "total"].includes(filter.field)) {
|
||
const numVal = parseFloat(val) || 0;
|
||
const filterNum = parseFloat(filter.value) || 0;
|
||
if (filter.operator === "equals") return numVal === filterNum;
|
||
if (filter.operator === "greater than") return numVal > filterNum;
|
||
if (filter.operator === "less than") return numVal < filterNum;
|
||
if (filter.operator === "between") {
|
||
const parts = filter.value.split(",");
|
||
const min = parseFloat(parts[0]) || 0;
|
||
const max = parseFloat(parts[1]) || 0;
|
||
return numVal >= min && numVal <= max;
|
||
}
|
||
if (filter.operator === "is empty") return !val || numVal === 0;
|
||
if (filter.operator === "is not empty") return val && numVal !== 0;
|
||
}
|
||
|
||
// Date fields
|
||
if (filter.field === "lastContact") {
|
||
if (filter.operator === "is") return val === filter.value;
|
||
if (filter.operator === "is before") return val < filter.value;
|
||
if (filter.operator === "is after") return val > filter.value;
|
||
if (filter.operator === "in last N days") {
|
||
const daysNum = parseInt(filter.value) || 0;
|
||
const filterDate = new Date();
|
||
filterDate.setDate(filterDate.getDate() - daysNum);
|
||
const valDate = new Date(val);
|
||
return valDate >= filterDate;
|
||
}
|
||
if (filter.operator === "is empty") return !val;
|
||
}
|
||
|
||
// Select fields
|
||
if (["type", "priority"].includes(filter.field)) {
|
||
if (filter.operator === "is") return val === filter.value;
|
||
if (filter.operator === "is not") return val !== filter.value;
|
||
}
|
||
|
||
// Boolean fields
|
||
if (["followUp", "graveyard"].includes(filter.field)) {
|
||
if (filter.operator === "is true") return val === true;
|
||
if (filter.operator === "is false") return val === false;
|
||
}
|
||
|
||
return true;
|
||
});
|
||
});
|
||
}
|
||
|
||
// Sort
|
||
filtered.sort((a, b) => {
|
||
let aVal, bVal;
|
||
if (sortBy === "name") {
|
||
aVal = a.name;
|
||
bVal = b.name;
|
||
} else if (sortBy === "lead") {
|
||
aVal = a.lead;
|
||
bVal = b.lead;
|
||
} else if (sortBy === "priority") {
|
||
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
||
aVal = priorityOrder[a.priority] || 999;
|
||
bVal = priorityOrder[b.priority] || 999;
|
||
} else if (sortBy === "type") {
|
||
aVal = a.type;
|
||
bVal = b.type;
|
||
} else {
|
||
aVal = a[sortBy] || 0;
|
||
bVal = b[sortBy] || 0;
|
||
}
|
||
|
||
if (typeof aVal === "string") {
|
||
aVal = aVal.toLowerCase();
|
||
bVal = bVal.toLowerCase();
|
||
}
|
||
|
||
if (aVal < bVal) return sortOrder === "asc" ? -1 : 1;
|
||
if (aVal > bVal) return sortOrder === "asc" ? 1 : -1;
|
||
return 0;
|
||
});
|
||
|
||
return filtered;
|
||
}, [investors, selectedView, searchTerm, sortBy, sortOrder]);
|
||
|
||
// Calculate stats
|
||
const stats = useMemo(() => {
|
||
const totalInvestors = investors.filter(i => i.type === "investor").length;
|
||
const totalCapitalCommitted = calculateTotal(
|
||
investors.filter(i => i.type === "investor"),
|
||
"fundI"
|
||
) + calculateTotal(
|
||
investors.filter(i => i.type === "investor"),
|
||
"fundII"
|
||
) + calculateTotal(
|
||
investors.filter(i => i.type === "investor"),
|
||
"fundIII"
|
||
);
|
||
const totalProspects = investors.filter(i => i.type === "prospect").length;
|
||
const followUpCount = investors.filter(i => i.followUp && !i.graveyard).length;
|
||
|
||
return { totalInvestors, totalCapitalCommitted, totalProspects, followUpCount };
|
||
}, [investors]);
|
||
|
||
const handleSort = useCallback((column) => {
|
||
if (sortBy === column) {
|
||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||
} else {
|
||
setSortBy(column);
|
||
setSortOrder("asc");
|
||
}
|
||
}, [sortBy, sortOrder]);
|
||
|
||
const handleCellChange = useCallback((id, field, value) => {
|
||
setInvestors(investors.map(inv =>
|
||
inv.id === id ? { ...inv, [field]: value } : inv
|
||
));
|
||
}, [investors]);
|
||
|
||
const handleAddInvestor = useCallback(() => {
|
||
const id = Math.max(...investors.map(i => i.id), 0) + 1;
|
||
const newInv = {
|
||
...newInvestor,
|
||
id,
|
||
followUp: false,
|
||
graveyard: false,
|
||
leadSource: "manual",
|
||
manualLeadSource: "",
|
||
fundI: 0,
|
||
fundII: 0,
|
||
fundIII: 0,
|
||
tactical: 0,
|
||
pawnE4: 0,
|
||
pawnF4: 0,
|
||
ten31Terahash: 0,
|
||
satsAndStats: 0,
|
||
joinTheFold: 0,
|
||
notes: "",
|
||
lastContact: new Date().toISOString().split("T")[0]
|
||
};
|
||
setInvestors([...investors, newInv]);
|
||
setShowAddModal(false);
|
||
setNewInvestor({
|
||
name: "",
|
||
type: "prospect",
|
||
lead: "Grant",
|
||
priority: "medium",
|
||
contacts: [{ name: "", email: "", title: "", location: "" }]
|
||
});
|
||
}, [investors, newInvestor]);
|
||
|
||
const handleDeleteInvestor = useCallback((id) => {
|
||
if (confirm("Are you sure you want to delete this investor?")) {
|
||
setInvestors(investors.filter(i => i.id !== id));
|
||
setSelectedInvestor(null);
|
||
}
|
||
}, [investors]);
|
||
|
||
const handleAddContact = useCallback(() => {
|
||
if (selectedInvestor) {
|
||
const updatedInvestor = {
|
||
...selectedInvestor,
|
||
contacts: [...selectedInvestor.contacts, { name: "", email: "", title: "", location: "" }]
|
||
};
|
||
setInvestors(investors.map(inv =>
|
||
inv.id === selectedInvestor.id ? updatedInvestor : inv
|
||
));
|
||
setSelectedInvestor(updatedInvestor);
|
||
}
|
||
}, [investors, selectedInvestor]);
|
||
|
||
const handleUpdateContact = useCallback((contactIdx, field, value) => {
|
||
if (selectedInvestor) {
|
||
const updatedContacts = selectedInvestor.contacts.map((c, idx) =>
|
||
idx === contactIdx ? { ...c, [field]: value } : c
|
||
);
|
||
const updatedInvestor = { ...selectedInvestor, contacts: updatedContacts };
|
||
setInvestors(investors.map(inv =>
|
||
inv.id === selectedInvestor.id ? updatedInvestor : inv
|
||
));
|
||
setSelectedInvestor(updatedInvestor);
|
||
}
|
||
}, [investors, selectedInvestor]);
|
||
|
||
const handleDeleteContact = useCallback((contactIdx) => {
|
||
if (selectedInvestor && selectedInvestor.contacts.length > 1) {
|
||
const updatedContacts = selectedInvestor.contacts.filter((_, idx) => idx !== contactIdx);
|
||
const updatedInvestor = { ...selectedInvestor, contacts: updatedContacts };
|
||
setInvestors(investors.map(inv =>
|
||
inv.id === selectedInvestor.id ? updatedInvestor : inv
|
||
));
|
||
setSelectedInvestor(updatedInvestor);
|
||
}
|
||
}, [investors, selectedInvestor]);
|
||
|
||
const handleInvestorUpdate = useCallback((field, value) => {
|
||
if (selectedInvestor) {
|
||
const updatedInvestor = { ...selectedInvestor, [field]: value };
|
||
setInvestors(investors.map(inv =>
|
||
inv.id === selectedInvestor.id ? updatedInvestor : inv
|
||
));
|
||
setSelectedInvestor(updatedInvestor);
|
||
}
|
||
}, [investors, selectedInvestor]);
|
||
|
||
const handleContactsHover = (e, investor) => {
|
||
if (investor.contacts.length > 1) {
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
setTooltipData({
|
||
contacts: investor.contacts,
|
||
x: rect.right + 10,
|
||
y: rect.top
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleContactsLeave = () => {
|
||
setTooltipData(null);
|
||
};
|
||
|
||
return React.createElement(
|
||
"div",
|
||
{ style: { display: "flex", height: "100vh", overflow: "hidden" } },
|
||
// Sidebar
|
||
React.createElement(
|
||
"div",
|
||
{ className: "sidebar" },
|
||
React.createElement("div", { className: "sidebar-header" }, "Ten31 CRM"),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "sidebar-section" },
|
||
React.createElement("div", { className: "sidebar-section-title" }, "Views"),
|
||
React.createElement(
|
||
"div",
|
||
{
|
||
className: `view-item ${selectedView === "all" ? "active" : ""}`,
|
||
onClick: () => setSelectedView("all")
|
||
},
|
||
"All Investors"
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{
|
||
className: `view-item ${selectedView === "investors" ? "active" : ""}`,
|
||
onClick: () => setSelectedView("investors")
|
||
},
|
||
"Investors",
|
||
React.createElement("span", { className: "filter-badge" }, stats.totalInvestors)
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{
|
||
className: `view-item ${selectedView === "prospects" ? "active" : ""}`,
|
||
onClick: () => setSelectedView("prospects")
|
||
},
|
||
"Prospects",
|
||
React.createElement("span", { className: "filter-badge" }, stats.totalProspects)
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{
|
||
className: `view-item ${selectedView === "leads" ? "active" : ""}`,
|
||
onClick: () => setSelectedView("leads")
|
||
},
|
||
"Leads"
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{
|
||
className: `view-item ${selectedView === "followUp" ? "active" : ""}`,
|
||
onClick: () => setSelectedView("followUp")
|
||
},
|
||
"Follow Up",
|
||
React.createElement("span", { className: "filter-badge" }, stats.followUpCount)
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{
|
||
className: `view-item ${selectedView === "graveyard" ? "active" : ""}`,
|
||
onClick: () => setSelectedView("graveyard")
|
||
},
|
||
"Graveyard"
|
||
)
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "sidebar-section" },
|
||
React.createElement("div", { className: "sidebar-section-title" }, "Team"),
|
||
["Grant", "Matt", "JP"].map(member =>
|
||
React.createElement(
|
||
"div",
|
||
{
|
||
key: member,
|
||
className: `view-item team-member ${selectedView === `team-${member}` ? "active" : ""}`,
|
||
onClick: () => setSelectedView(`team-${member}`)
|
||
},
|
||
member
|
||
)
|
||
)
|
||
)
|
||
),
|
||
// Main Content
|
||
React.createElement(
|
||
"div",
|
||
{ className: "main-content" },
|
||
// Top Bar
|
||
React.createElement(
|
||
"div",
|
||
{ className: "top-bar" },
|
||
React.createElement("input", {
|
||
type: "text",
|
||
className: "search-input",
|
||
placeholder: "Search investors, contacts, emails...",
|
||
value: searchTerm,
|
||
onChange: (e) => setSearchTerm(e.target.value)
|
||
}),
|
||
React.createElement(
|
||
"button",
|
||
{
|
||
className: "add-button",
|
||
style: { background: activeFilters.length > 0 ? "#4f46e5" : "#334155" },
|
||
onClick: () => setShowFilterPanel(!showFilterPanel)
|
||
},
|
||
"Filter" + (activeFilters.length > 0 ? " (" + activeFilters.length + ")" : "")
|
||
),
|
||
React.createElement(
|
||
"button",
|
||
{
|
||
className: "add-button",
|
||
onClick: () => setShowAddModal(true)
|
||
},
|
||
"+ Add Investor"
|
||
)
|
||
),
|
||
|
||
// Filter Panel
|
||
showFilterPanel && React.createElement(
|
||
"div",
|
||
{ style: {
|
||
background: "#1e293b",
|
||
borderTop: "1px solid #334155",
|
||
padding: "12px 24px",
|
||
maxHeight: "400px",
|
||
overflowY: "auto"
|
||
} },
|
||
React.createElement(
|
||
"div",
|
||
{ style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "12px" } },
|
||
React.createElement("div", { style: { fontSize: "12px", fontWeight: "600", color: "#94a3b8" } }, "Filter Rules"),
|
||
activeFilters.length > 0 && React.createElement(
|
||
"a",
|
||
{
|
||
href: "#",
|
||
onClick: (e) => { e.preventDefault(); setActiveFilters([]); },
|
||
style: { fontSize: "12px", color: "#6366f1", cursor: "pointer" }
|
||
},
|
||
"Clear All"
|
||
)
|
||
),
|
||
activeFilters.map((filter, idx) => {
|
||
const allFields = ["name", "type", "lead", "priority", "contacts", "location", "fundI", "fundII", "fundIII", "tactical", "pawnE4", "pawnF4", "ten31Terahash", "satsAndStats", "joinTheFold", "total", "lastContact", "notes", "followUp", "graveyard", ...customColumns.map(c => c.id)];
|
||
const getOperators = (field) => {
|
||
if (["name", "lead", "notes", "contacts", "location"].includes(field)) return ["contains", "does not contain", "equals", "is empty", "is not empty"];
|
||
if (["fundI", "fundII", "fundIII", "tactical", "pawnE4", "pawnF4", "ten31Terahash", "satsAndStats", "joinTheFold", "total"].includes(field)) return ["equals", "greater than", "less than", "between", "is empty", "is not empty"];
|
||
if (field === "lastContact") return ["is", "is before", "is after", "in last N days", "is empty"];
|
||
if (["type", "priority"].includes(field)) return ["is", "is not"];
|
||
if (["followUp", "graveyard"].includes(field)) return ["is true", "is false"];
|
||
return ["contains", "equals"];
|
||
};
|
||
|
||
return React.createElement(
|
||
"div",
|
||
{ key: idx, style: { display: "flex", gap: "8px", marginBottom: "8px", alignItems: "center", height: "40px" } },
|
||
React.createElement(
|
||
"select",
|
||
{
|
||
value: filter.field,
|
||
onChange: (e) => {
|
||
const updated = [...activeFilters];
|
||
updated[idx].field = e.target.value;
|
||
updated[idx].operator = getOperators(e.target.value)[0];
|
||
updated[idx].value = "";
|
||
setActiveFilters(updated);
|
||
},
|
||
style: { padding: "4px", background: "#0f172a", border: "1px solid #334155", color: "#e2e8f0", borderRadius: "4px", fontSize: "12px", flex: "0 1 120px" }
|
||
},
|
||
allFields.map(f => React.createElement("option", { key: f, value: f }, f))
|
||
),
|
||
React.createElement(
|
||
"select",
|
||
{
|
||
value: filter.operator,
|
||
onChange: (e) => {
|
||
const updated = [...activeFilters];
|
||
updated[idx].operator = e.target.value;
|
||
updated[idx].value = "";
|
||
setActiveFilters(updated);
|
||
},
|
||
style: { padding: "4px", background: "#0f172a", border: "1px solid #334155", color: "#e2e8f0", borderRadius: "4px", fontSize: "12px", flex: "0 1 110px" }
|
||
},
|
||
getOperators(filter.field).map(op => React.createElement("option", { key: op, value: op }, op))
|
||
),
|
||
["is empty", "is not empty", "is true", "is false"].includes(filter.operator) ? null : (
|
||
filter.field === "type" ? React.createElement(
|
||
"select",
|
||
{
|
||
value: filter.value,
|
||
onChange: (e) => {
|
||
const updated = [...activeFilters];
|
||
updated[idx].value = e.target.value;
|
||
setActiveFilters(updated);
|
||
},
|
||
style: { padding: "4px", background: "#0f172a", border: "1px solid #334155", color: "#e2e8f0", borderRadius: "4px", fontSize: "12px", flex: "1" }
|
||
},
|
||
["investor", "prospect", "advisor", "lead"].map(t => React.createElement("option", { key: t, value: t }, t))
|
||
) : filter.field === "priority" ? React.createElement(
|
||
"select",
|
||
{
|
||
value: filter.value,
|
||
onChange: (e) => {
|
||
const updated = [...activeFilters];
|
||
updated[idx].value = e.target.value;
|
||
setActiveFilters(updated);
|
||
},
|
||
style: { padding: "4px", background: "#0f172a", border: "1px solid #334155", color: "#e2e8f0", borderRadius: "4px", fontSize: "12px", flex: "1" }
|
||
},
|
||
["high", "medium", "low"].map(p => React.createElement("option", { key: p, value: p }, p))
|
||
) : filter.field === "lead" ? React.createElement(
|
||
"select",
|
||
{
|
||
value: filter.value,
|
||
onChange: (e) => {
|
||
const updated = [...activeFilters];
|
||
updated[idx].value = e.target.value;
|
||
setActiveFilters(updated);
|
||
},
|
||
style: { padding: "4px", background: "#0f172a", border: "1px solid #334155", color: "#e2e8f0", borderRadius: "4px", fontSize: "12px", flex: "1" }
|
||
},
|
||
["Grant", "Matt", "JP"].map(l => React.createElement("option", { key: l, value: l }, l))
|
||
) : filter.operator === "between" ? React.createElement(
|
||
"div",
|
||
{ style: { display: "flex", gap: "4px", flex: "1" } },
|
||
React.createElement("input", {
|
||
type: "text",
|
||
inputMode: "numeric",
|
||
placeholder: "Min",
|
||
value: filter.value.split(",")[0] || "",
|
||
onChange: (e) => {
|
||
const updated = [...activeFilters];
|
||
const parts = filter.value.split(",");
|
||
updated[idx].value = e.target.value + "," + (parts[1] || "");
|
||
setActiveFilters(updated);
|
||
},
|
||
style: { padding: "4px", background: "#0f172a", border: "1px solid #334155", color: "#e2e8f0", borderRadius: "4px", fontSize: "12px", flex: "1" }
|
||
}),
|
||
React.createElement("input", {
|
||
type: "text",
|
||
inputMode: "numeric",
|
||
placeholder: "Max",
|
||
value: filter.value.split(",")[1] || "",
|
||
onChange: (e) => {
|
||
const updated = [...activeFilters];
|
||
const parts = filter.value.split(",");
|
||
updated[idx].value = (parts[0] || "") + "," + e.target.value;
|
||
setActiveFilters(updated);
|
||
},
|
||
style: { padding: "4px", background: "#0f172a", border: "1px solid #334155", color: "#e2e8f0", borderRadius: "4px", fontSize: "12px", flex: "1" }
|
||
})
|
||
) : React.createElement("input", {
|
||
type: filter.field === "lastContact" ? "date" : (["fundI", "fundII", "fundIII", "tactical", "pawnE4", "pawnF4", "ten31Terahash", "satsAndStats", "joinTheFold", "total"].includes(filter.field) ? "text" : "text"),
|
||
inputMode: ["fundI", "fundII", "fundIII", "tactical", "pawnE4", "pawnF4", "ten31Terahash", "satsAndStats", "joinTheFold", "total"].includes(filter.field) ? "numeric" : undefined,
|
||
placeholder: filter.operator === "in last N days" ? "Days" : "Value",
|
||
value: filter.value,
|
||
onChange: (e) => {
|
||
const updated = [...activeFilters];
|
||
updated[idx].value = e.target.value;
|
||
setActiveFilters(updated);
|
||
},
|
||
style: { padding: "4px", background: "#0f172a", border: "1px solid #334155", color: "#e2e8f0", borderRadius: "4px", fontSize: "12px", flex: "1" }
|
||
})
|
||
),
|
||
React.createElement(
|
||
"button",
|
||
{
|
||
onClick: () => setActiveFilters(activeFilters.filter((_, i) => i !== idx)),
|
||
style: { padding: "4px 8px", background: "#ef4444", border: "none", color: "#fff", borderRadius: "4px", cursor: "pointer", fontSize: "12px", flex: "0 0 30px" }
|
||
},
|
||
"×"
|
||
)
|
||
);
|
||
}),
|
||
React.createElement(
|
||
"button",
|
||
{
|
||
onClick: () => setActiveFilters([...activeFilters, { field: "name", operator: "contains", value: "" }]),
|
||
style: { marginTop: "8px", padding: "4px 12px", background: "#6366f1", border: "none", color: "#fff", borderRadius: "4px", cursor: "pointer", fontSize: "12px" }
|
||
},
|
||
"+ Add Filter"
|
||
)
|
||
),// Dashboard (if viewing all)
|
||
selectedView === "all" && React.createElement(
|
||
"div",
|
||
{ style: { padding: "16px 24px" } },
|
||
React.createElement(
|
||
"div",
|
||
{ className: "dashboard" },
|
||
React.createElement(
|
||
"div",
|
||
{ className: "stat-card" },
|
||
React.createElement("div", { className: "stat-card-value" }, stats.totalInvestors),
|
||
React.createElement("div", { className: "stat-card-label" }, "Active Investors")
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "stat-card" },
|
||
React.createElement("div", { className: "stat-card-value" }, formatCurrency(stats.totalCapitalCommitted)),
|
||
React.createElement("div", { className: "stat-card-label" }, "Capital Committed")
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "stat-card" },
|
||
React.createElement("div", { className: "stat-card-value" }, stats.totalProspects),
|
||
React.createElement("div", { className: "stat-card-label" }, "Prospects")
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "stat-card" },
|
||
React.createElement("div", { className: "stat-card-value" }, stats.followUpCount),
|
||
React.createElement("div", { className: "stat-card-label" }, "Follow-Ups")
|
||
)
|
||
)
|
||
),
|
||
// Table
|
||
React.createElement(
|
||
"div",
|
||
{ className: "table-container" },
|
||
React.createElement(
|
||
"table",
|
||
{ className: "table" },
|
||
React.createElement(
|
||
"thead",
|
||
null,
|
||
React.createElement(
|
||
"tr",
|
||
null,
|
||
React.createElement(
|
||
"th",
|
||
{ onClick: () => handleSort("name"), style: { cursor: "pointer" } },
|
||
"Name " + (sortBy === "name" ? (sortOrder === "asc" ? "↑" : "↓") : "")
|
||
),
|
||
React.createElement(
|
||
"th",
|
||
{ onClick: () => handleSort("type"), style: { cursor: "pointer" } },
|
||
"Type " + (sortBy === "type" ? (sortOrder === "asc" ? "↑" : "↓") : "")
|
||
),
|
||
React.createElement(
|
||
"th",
|
||
{ onClick: () => handleSort("contacts"), style: { cursor: "pointer" } },
|
||
"Contacts " + (sortBy === "contacts" ? (sortOrder === "asc" ? "↑" : "↓") : "")
|
||
),
|
||
React.createElement(
|
||
"th",
|
||
{ onClick: () => handleSort("lead"), style: { cursor: "pointer" } },
|
||
"Lead " + (sortBy === "lead" ? (sortOrder === "asc" ? "↑" : "↓") : "")
|
||
),
|
||
React.createElement(
|
||
"th",
|
||
{ onClick: () => handleSort("priority"), style: { cursor: "pointer" } },
|
||
"Priority " + (sortBy === "priority" ? (sortOrder === "asc" ? "↑" : "↓") : "")
|
||
),
|
||
React.createElement("th", null, "Fund I"),
|
||
React.createElement("th", null, "Fund II"),
|
||
React.createElement("th", null, "Fund III"),
|
||
React.createElement("th", null, "Tactical"),
|
||
React.createElement(
|
||
"th",
|
||
null,
|
||
"Custom Columns"
|
||
),
|
||
React.createElement("th", { style: { width: "30px", textAlign: "center" } }, "+"),
|
||
React.createElement("th", null, "Last Contact")
|
||
)
|
||
),
|
||
React.createElement(
|
||
"tbody",
|
||
null,
|
||
filteredInvestors.map((investor, rowIdx) => {
|
||
const editableColumns = ["name","type","lead","priority","fundI","fundII","fundIII","tactical"];
|
||
const isEd = (field) => editingCell && editingCell.investorId === investor.id && editingCell.field === field;
|
||
const dblClick = (field) => (e) => { e.stopPropagation(); setEditingCell({ investorId: investor.id, field }); };
|
||
const commitEdit = (field, raw) => {
|
||
const fundFields = ["fundI","fundII","fundIII","tactical","pawnE4","pawnF4","ten31Terahash","satsAndStats","joinTheFold"];
|
||
const val = fundFields.includes(field) ? (parseInt(String(raw).replace(/[^0-9]/g,"")) || 0) : raw;
|
||
handleCellChange(investor.id, field, val);
|
||
setEditingCell(null);
|
||
};
|
||
const navigateCell = (field, raw, direction) => {
|
||
const fundFields = ["fundI","fundII","fundIII","tactical","pawnE4","pawnF4","ten31Terahash","satsAndStats","joinTheFold"];
|
||
const val = fundFields.includes(field) ? (parseInt(String(raw).replace(/[^0-9]/g,"")) || 0) : raw;
|
||
handleCellChange(investor.id, field, val);
|
||
const colIdx = editableColumns.indexOf(field);
|
||
if (direction === "right") {
|
||
if (colIdx < editableColumns.length - 1) {
|
||
setEditingCell({ investorId: investor.id, field: editableColumns[colIdx + 1] });
|
||
} else if (rowIdx < filteredInvestors.length - 1) {
|
||
setEditingCell({ investorId: filteredInvestors[rowIdx + 1].id, field: editableColumns[0] });
|
||
} else { setEditingCell(null); }
|
||
} else if (direction === "left") {
|
||
if (colIdx > 0) {
|
||
setEditingCell({ investorId: investor.id, field: editableColumns[colIdx - 1] });
|
||
} else if (rowIdx > 0) {
|
||
setEditingCell({ investorId: filteredInvestors[rowIdx - 1].id, field: editableColumns[editableColumns.length - 1] });
|
||
} else { setEditingCell(null); }
|
||
} else if (direction === "down") {
|
||
if (rowIdx < filteredInvestors.length - 1) {
|
||
setEditingCell({ investorId: filteredInvestors[rowIdx + 1].id, field });
|
||
} else { setEditingCell(null); }
|
||
} else if (direction === "up") {
|
||
if (rowIdx > 0) {
|
||
setEditingCell({ investorId: filteredInvestors[rowIdx - 1].id, field });
|
||
} else { setEditingCell(null); }
|
||
}
|
||
};
|
||
const cellKeyHandler = (field) => (e) => {
|
||
if (e.key === "Tab") {
|
||
e.preventDefault();
|
||
navigateCell(field, e.target.value, e.shiftKey ? "left" : "right");
|
||
} else if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
navigateCell(field, e.target.value, "down");
|
||
} else if (e.key === "Escape") {
|
||
setEditingCell(null);
|
||
}
|
||
};
|
||
const editInput = (field, val, type) => {
|
||
const isNum = type === "num";
|
||
return React.createElement("input", {
|
||
type: "text",
|
||
inputMode: isNum ? "numeric" : "text",
|
||
pattern: isNum ? "[0-9]*" : undefined,
|
||
className: "cell-edit-input",
|
||
defaultValue: isNum ? val : val,
|
||
autoFocus: true,
|
||
onClick: (e) => e.stopPropagation(),
|
||
onBlur: (e) => { if (editingCell && editingCell.field === field && editingCell.investorId === investor.id) commitEdit(field, e.target.value); },
|
||
onKeyDown: cellKeyHandler(field)
|
||
});
|
||
};
|
||
const editSelect = (field, val, options) => {
|
||
return React.createElement("select", {
|
||
className: "cell-edit-select",
|
||
defaultValue: val,
|
||
autoFocus: true,
|
||
onClick: (e) => e.stopPropagation(),
|
||
onBlur: (e) => { if (editingCell && editingCell.field === field && editingCell.investorId === investor.id) commitEdit(field, e.target.value); },
|
||
onKeyDown: cellKeyHandler(field),
|
||
onChange: (e) => commitEdit(field, e.target.value)
|
||
}, options.map(o => React.createElement("option", { key: o, value: o }, o)));
|
||
};
|
||
|
||
return React.createElement(
|
||
"tr",
|
||
{
|
||
key: investor.id,
|
||
onClick: () => setSelectedInvestor(investor),
|
||
onContextMenu: (e) => handleContextMenu(e, investor),
|
||
className: selectedInvestor?.id === investor.id ? "selected" : ""
|
||
},
|
||
// Name
|
||
React.createElement("td", {
|
||
className: isEd("name") ? "editable editing" : "editable",
|
||
onDoubleClick: dblClick("name")
|
||
}, isEd("name") ? editInput("name", investor.name, "text") : investor.name),
|
||
// Type
|
||
React.createElement("td", {
|
||
className: isEd("type") ? "editable editing" : "editable",
|
||
onDoubleClick: dblClick("type")
|
||
}, isEd("type")
|
||
? editSelect("type", investor.type, ["investor","prospect","advisor"])
|
||
: React.createElement("span", { className: `badge ${investor.type}`, style: { backgroundColor: getTypeColor(investor.type) } }, investor.type)
|
||
),
|
||
// Contacts (not inline editable)
|
||
React.createElement("td", {
|
||
onMouseEnter: (e) => handleContactsHover(e, investor),
|
||
onMouseLeave: handleContactsLeave,
|
||
style: { position: "relative" }
|
||
}, investor.contacts[0]?.name + (investor.contacts.length > 1 ? ` +${investor.contacts.length - 1} more` : "")),
|
||
// Lead
|
||
React.createElement("td", {
|
||
className: isEd("lead") ? "editable editing" : "editable",
|
||
onDoubleClick: dblClick("lead")
|
||
}, isEd("lead")
|
||
? editSelect("lead", investor.lead, ["Grant","Matt","JP"])
|
||
: investor.lead
|
||
),
|
||
// Priority
|
||
React.createElement("td", {
|
||
className: isEd("priority") ? "editable editing" : "editable",
|
||
onDoubleClick: dblClick("priority")
|
||
}, isEd("priority")
|
||
? editSelect("priority", investor.priority, ["high","medium","low"])
|
||
: React.createElement("span", { className: `badge ${investor.priority}`, style: { backgroundColor: getPriorityColor(investor.priority) } }, investor.priority)
|
||
),
|
||
// Fund I
|
||
React.createElement("td", {
|
||
className: isEd("fundI") ? "editable editing" : "editable",
|
||
onDoubleClick: dblClick("fundI")
|
||
}, isEd("fundI") ? editInput("fundI", investor.fundI, "num") : (investor.fundI ? formatCurrency(investor.fundI) : "—")),
|
||
// Fund II
|
||
React.createElement("td", {
|
||
className: isEd("fundII") ? "editable editing" : "editable",
|
||
onDoubleClick: dblClick("fundII")
|
||
}, isEd("fundII") ? editInput("fundII", investor.fundII, "num") : (investor.fundII ? formatCurrency(investor.fundII) : "—")),
|
||
// Fund III
|
||
React.createElement("td", {
|
||
className: isEd("fundIII") ? "editable editing" : "editable",
|
||
onDoubleClick: dblClick("fundIII")
|
||
}, isEd("fundIII") ? editInput("fundIII", investor.fundIII, "num") : (investor.fundIII ? formatCurrency(investor.fundIII) : "—")),
|
||
// Tactical
|
||
React.createElement("td", {
|
||
className: isEd("tactical") ? "editable editing" : "editable",
|
||
onDoubleClick: dblClick("tactical")
|
||
}, isEd("tactical") ? editInput("tactical", investor.tactical, "num") : (investor.tactical ? formatCurrency(investor.tactical) : "—")),
|
||
// Last Contact
|
||
React.createElement("td", null, getRelativeDate(investor.lastContact),
|
||
// Custom columns
|
||
customColumns.map(col => {
|
||
const val = investor.customFields?.[col.id] ?? (col.type === "checkbox" ? false : col.type === "number" || col.type === "currency" ? 0 : "");
|
||
const isEd2 = editingCell && editingCell.investorId === investor.id && editingCell.field === col.id;
|
||
|
||
return React.createElement("td", {
|
||
key: col.id,
|
||
className: isEd2 ? "editable editing" : "editable",
|
||
onDoubleClick: () => { if (col.type !== "checkbox") { setEditingCell({ investorId: investor.id, field: col.id }); } },
|
||
style: { cursor: col.type === "checkbox" ? "pointer" : "default" }
|
||
},
|
||
col.type === "checkbox" ?
|
||
React.createElement("input", {
|
||
type: "checkbox",
|
||
checked: val || false,
|
||
onChange: (e) => {
|
||
handleCellChange(investor.id, "customFields", { ...(investor.customFields || {}), [col.id]: e.target.checked });
|
||
},
|
||
onClick: (e) => e.stopPropagation(),
|
||
style: { cursor: "pointer" }
|
||
}) :
|
||
col.type === "currency" ?
|
||
isEd2 ? editInput(col.id, val, "num") : (val ? formatCurrency(val) : "—") :
|
||
col.type === "date" ?
|
||
isEd2 ? React.createElement("input", {
|
||
type: "date",
|
||
defaultValue: val,
|
||
autoFocus: true,
|
||
onClick: (e) => e.stopPropagation(),
|
||
onBlur: (e) => { handleCellChange(investor.id, "customFields", { ...(investor.customFields || {}), [col.id]: e.target.value }); setEditingCell(null); }
|
||
}) : (val ? getRelativeDate(val) : "—") :
|
||
col.type === "dropdown" ?
|
||
isEd2 ? React.createElement("select", {
|
||
defaultValue: val,
|
||
autoFocus: true,
|
||
onClick: (e) => e.stopPropagation(),
|
||
onBlur: (e) => { handleCellChange(investor.id, "customFields", { ...(investor.customFields || {}), [col.id]: e.target.value }); setEditingCell(null); }
|
||
}, (col.options || []).map(o => React.createElement("option", { key: o, value: o }, o))) : val :
|
||
col.type === "longText" ?
|
||
isEd2 ? React.createElement("textarea", {
|
||
defaultValue: val,
|
||
autoFocus: true,
|
||
onClick: (e) => e.stopPropagation(),
|
||
onBlur: (e) => { handleCellChange(investor.id, "customFields", { ...(investor.customFields || {}), [col.id]: e.target.value }); setEditingCell(null); }
|
||
}) : (val && val.length > 40 ? val.slice(0, 40) + "..." : val) :
|
||
col.type === "number" ?
|
||
isEd2 ? editInput(col.id, val, "num") : val :
|
||
isEd2 ? editInput(col.id, val, "text") : val
|
||
);
|
||
}),
|
||
// + Column button
|
||
React.createElement("td", {
|
||
style: { width: "30px", textAlign: "center", cursor: "pointer", color: "#6366f1" },
|
||
onClick: () => setShowCustomColumnModal(true)
|
||
}, "+"))
|
||
);
|
||
})
|
||
)
|
||
)
|
||
)
|
||
),
|
||
// Detail Panel
|
||
selectedInvestor && React.createElement(
|
||
"div",
|
||
{ className: "detail-panel open" },
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-header" },
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-title" },
|
||
React.createElement("h2", null, selectedInvestor.name),
|
||
React.createElement(
|
||
"span",
|
||
{
|
||
className: `badge ${selectedInvestor.type}`,
|
||
style: { backgroundColor: getTypeColor(selectedInvestor.type) }
|
||
},
|
||
selectedInvestor.type
|
||
)
|
||
),
|
||
React.createElement(
|
||
"button",
|
||
{
|
||
className: "close-button",
|
||
onClick: () => setSelectedInvestor(null)
|
||
},
|
||
"×"
|
||
)
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-body" },
|
||
// Contacts Section
|
||
React.createElement(
|
||
"div",
|
||
{ className: "contacts-section" },
|
||
React.createElement(
|
||
"div",
|
||
{ className: "contacts-header" },
|
||
React.createElement("div", { className: "detail-section-title" }, "Contacts"),
|
||
React.createElement(
|
||
"button",
|
||
{ className: "add-contact-button", onClick: handleAddContact },
|
||
"+ Add Contact"
|
||
)
|
||
),
|
||
selectedInvestor.contacts.map((contact, idx) =>
|
||
React.createElement(
|
||
"div",
|
||
{ key: idx, className: "contact-card" },
|
||
React.createElement(
|
||
"div",
|
||
{ className: "contact-info" },
|
||
React.createElement("input", {
|
||
type: "text",
|
||
placeholder: "Contact name",
|
||
value: contact.name,
|
||
onChange: (e) => handleUpdateContact(idx, "name", e.target.value),
|
||
style: { className: "contact-name", marginBottom: "4px", display: "block", width: "100%", padding: "4px", background: "#0f172a", border: "1px solid #334155", borderRadius: "4px", color: "#e2e8f0" }
|
||
}),
|
||
React.createElement("input", {
|
||
type: "email",
|
||
placeholder: "email@example.com",
|
||
value: contact.email,
|
||
onChange: (e) => handleUpdateContact(idx, "email", e.target.value),
|
||
style: { className: "contact-email", marginBottom: "4px", display: "block", width: "100%", padding: "4px", background: "#0f172a", border: "1px solid #334155", borderRadius: "4px", color: "#e2e8f0" }
|
||
}),
|
||
React.createElement("input", {
|
||
type: "text",
|
||
placeholder: "Title (optional)",
|
||
value: contact.title,
|
||
onChange: (e) => handleUpdateContact(idx, "title", e.target.value),
|
||
style: { className: "contact-title", display: "block", width: "100%", padding: "4px", marginBottom: "4px", background: "#0f172a", border: "1px solid #334155", borderRadius: "4px", color: "#e2e8f0" }
|
||
}),
|
||
React.createElement(LocationInput, {
|
||
value: contact.location || "",
|
||
onChange: (val) => handleUpdateContact(idx, "location", val),
|
||
style: { marginBottom: "0" }
|
||
})
|
||
),
|
||
selectedInvestor.contacts.length > 1 && React.createElement(
|
||
"button",
|
||
{
|
||
className: "delete-contact-button",
|
||
onClick: () => handleDeleteContact(idx)
|
||
},
|
||
"×"
|
||
)
|
||
)
|
||
)
|
||
),
|
||
// Entity Info
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-section" },
|
||
React.createElement("div", { className: "detail-section-title" }, "Entity Info"),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-field" },
|
||
React.createElement("label", null, "Type"),
|
||
React.createElement(
|
||
"select",
|
||
{
|
||
value: selectedInvestor.type,
|
||
onChange: (e) => handleInvestorUpdate("type", e.target.value)
|
||
},
|
||
React.createElement("option", { value: "investor" }, "Investor"),
|
||
React.createElement("option", { value: "prospect" }, "Prospect"),
|
||
React.createElement("option", { value: "lead" }, "Lead")
|
||
)
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-field" },
|
||
React.createElement("label", null, "Lead"),
|
||
React.createElement(
|
||
"select",
|
||
{
|
||
value: selectedInvestor.lead,
|
||
onChange: (e) => handleInvestorUpdate("lead", e.target.value)
|
||
},
|
||
["Grant", "Matt", "JP"].map(name =>
|
||
React.createElement("option", { key: name, value: name }, name)
|
||
)
|
||
)
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-field" },
|
||
React.createElement("label", null, "Priority"),
|
||
React.createElement(
|
||
"select",
|
||
{
|
||
value: selectedInvestor.priority,
|
||
onChange: (e) => handleInvestorUpdate("priority", e.target.value)
|
||
},
|
||
React.createElement("option", { value: "high" }, "High"),
|
||
React.createElement("option", { value: "medium" }, "Medium"),
|
||
React.createElement("option", { value: "low" }, "Low")
|
||
)
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-field" },
|
||
React.createElement("label", null, "Status Flags"),
|
||
React.createElement(
|
||
"div",
|
||
{ style: { display: "flex", gap: "12px" } },
|
||
React.createElement(
|
||
"label",
|
||
{ style: { display: "flex", alignItems: "center", gap: "6px", cursor: "pointer" } },
|
||
React.createElement("input", {
|
||
type: "checkbox",
|
||
checked: selectedInvestor.followUp,
|
||
onChange: (e) => handleInvestorUpdate("followUp", e.target.checked)
|
||
}),
|
||
"Follow Up"
|
||
),
|
||
React.createElement(
|
||
"label",
|
||
{ style: { display: "flex", alignItems: "center", gap: "6px", cursor: "pointer" } },
|
||
React.createElement("input", {
|
||
type: "checkbox",
|
||
checked: selectedInvestor.graveyard,
|
||
onChange: (e) => handleInvestorUpdate("graveyard", e.target.checked)
|
||
}),
|
||
"Graveyard"
|
||
)
|
||
)
|
||
)
|
||
),
|
||
// Fund Commitments
|
||
// Custom Fields
|
||
customColumns.length > 0 && React.createElement(
|
||
"div",
|
||
{ className: "detail-section" },
|
||
React.createElement("div", { className: "detail-section-title" }, "Custom Fields"),
|
||
customColumns.map(col =>
|
||
React.createElement(
|
||
"div",
|
||
{ key: col.id, className: "detail-field" },
|
||
React.createElement("label", null, col.name),
|
||
col.type === "checkbox" ? React.createElement("input", {
|
||
type: "checkbox",
|
||
checked: selectedInvestor.customFields?.[col.id] || false,
|
||
onChange: (e) => handleInvestorUpdate("customFields", { ...(selectedInvestor.customFields || {}), [col.id]: e.target.checked })
|
||
}) : col.type === "date" ? React.createElement("input", {
|
||
type: "date",
|
||
value: selectedInvestor.customFields?.[col.id] || "",
|
||
onChange: (e) => handleInvestorUpdate("customFields", { ...(selectedInvestor.customFields || {}), [col.id]: e.target.value })
|
||
}) : col.type === "dropdown" ? React.createElement(
|
||
"select",
|
||
{
|
||
value: selectedInvestor.customFields?.[col.id] || "",
|
||
onChange: (e) => handleInvestorUpdate("customFields", { ...(selectedInvestor.customFields || {}), [col.id]: e.target.value })
|
||
},
|
||
React.createElement("option", { value: "" }, "—"),
|
||
(col.options || []).map(o => React.createElement("option", { key: o, value: o }, o))
|
||
) : col.type === "longText" ? React.createElement("textarea", {
|
||
value: selectedInvestor.customFields?.[col.id] || "",
|
||
onChange: (e) => handleInvestorUpdate("customFields", { ...(selectedInvestor.customFields || {}), [col.id]: e.target.value })
|
||
}) : React.createElement("input", {
|
||
type: col.type === "currency" || col.type === "number" ? "text" : "text",
|
||
inputMode: (col.type === "currency" || col.type === "number") ? "numeric" : undefined,
|
||
pattern: (col.type === "currency" || col.type === "number") ? "[0-9]*" : undefined,
|
||
value: selectedInvestor.customFields?.[col.id] || "",
|
||
onChange: (e) => handleInvestorUpdate("customFields", { ...(selectedInvestor.customFields || {}), [col.id]: e.target.value })
|
||
})
|
||
)
|
||
)
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-section" },
|
||
React.createElement("div", { className: "detail-section-title" }, "Fund Commitments"),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-field" },
|
||
React.createElement("label", null, "Fund I"),
|
||
React.createElement("input", {
|
||
type: "text",
|
||
inputMode: "numeric",
|
||
pattern: "[0-9]*",
|
||
value: selectedInvestor.fundI,
|
||
onChange: (e) => handleInvestorUpdate("fundI", e.target.value)
|
||
})
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-field" },
|
||
React.createElement("label", null, "Fund II"),
|
||
React.createElement("input", {
|
||
type: "text",
|
||
inputMode: "numeric",
|
||
pattern: "[0-9]*",
|
||
value: selectedInvestor.fundII,
|
||
onChange: (e) => handleInvestorUpdate("fundII", e.target.value)
|
||
})
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-field" },
|
||
React.createElement("label", null, "Fund III"),
|
||
React.createElement("input", {
|
||
type: "text",
|
||
inputMode: "numeric",
|
||
pattern: "[0-9]*",
|
||
value: selectedInvestor.fundIII,
|
||
onChange: (e) => handleInvestorUpdate("fundIII", e.target.value)
|
||
})
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-field" },
|
||
React.createElement("label", null, "Tactical"),
|
||
React.createElement("input", {
|
||
type: "text",
|
||
inputMode: "numeric",
|
||
pattern: "[0-9]*",
|
||
value: selectedInvestor.tactical,
|
||
onChange: (e) => handleInvestorUpdate("tactical", e.target.value)
|
||
})
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-field" },
|
||
React.createElement("label", null, "Pawn E4"),
|
||
React.createElement("input", {
|
||
type: "text",
|
||
inputMode: "numeric",
|
||
pattern: "[0-9]*",
|
||
value: selectedInvestor.pawnE4,
|
||
onChange: (e) => handleInvestorUpdate("pawnE4", e.target.value)
|
||
})
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-field" },
|
||
React.createElement("label", null, "Pawn F4"),
|
||
React.createElement("input", {
|
||
type: "text",
|
||
inputMode: "numeric",
|
||
pattern: "[0-9]*",
|
||
value: selectedInvestor.pawnF4,
|
||
onChange: (e) => handleInvestorUpdate("pawnF4", e.target.value)
|
||
})
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-field" },
|
||
React.createElement("label", null, "Ten31 Terahash"),
|
||
React.createElement("input", {
|
||
type: "text",
|
||
inputMode: "numeric",
|
||
pattern: "[0-9]*",
|
||
value: selectedInvestor.ten31Terahash,
|
||
onChange: (e) => handleInvestorUpdate("ten31Terahash", e.target.value)
|
||
})
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-field" },
|
||
React.createElement("label", null, "Sats & Stats"),
|
||
React.createElement("input", {
|
||
type: "text",
|
||
inputMode: "numeric",
|
||
pattern: "[0-9]*",
|
||
value: selectedInvestor.satsAndStats,
|
||
onChange: (e) => handleInvestorUpdate("satsAndStats", e.target.value)
|
||
})
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-field" },
|
||
React.createElement("label", null, "Join The Fold"),
|
||
React.createElement("input", {
|
||
type: "text",
|
||
inputMode: "numeric",
|
||
pattern: "[0-9]*",
|
||
value: selectedInvestor.joinTheFold,
|
||
onChange: (e) => handleInvestorUpdate("joinTheFold", e.target.value)
|
||
})
|
||
)
|
||
),
|
||
// Lead Source
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-section" },
|
||
React.createElement("div", { className: "detail-section-title" }, "Lead Source"),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-field" },
|
||
React.createElement("label", null, "Source"),
|
||
React.createElement(
|
||
"select",
|
||
{
|
||
value: selectedInvestor.leadSource,
|
||
onChange: (e) => handleInvestorUpdate("leadSource", e.target.value)
|
||
},
|
||
React.createElement("option", { value: "conference" }, "Conference"),
|
||
React.createElement("option", { value: "inbound" }, "Inbound"),
|
||
React.createElement("option", { value: "outbound" }, "Outbound"),
|
||
React.createElement("option", { value: "referral" }, "Referral"),
|
||
React.createElement("option", { value: "network" }, "Network"),
|
||
React.createElement("option", { value: "personal" }, "Personal"),
|
||
React.createElement("option", { value: "manual" }, "Manual")
|
||
)
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-field" },
|
||
React.createElement("label", null, "Manual Notes"),
|
||
React.createElement("input", {
|
||
type: "text",
|
||
value: selectedInvestor.manualLeadSource || "",
|
||
onChange: (e) => handleInvestorUpdate("manualLeadSource", e.target.value)
|
||
})
|
||
)
|
||
),
|
||
// Notes & Contact Info
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-section" },
|
||
React.createElement("div", { className: "detail-section-title" }, "Notes & Contact"),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-field" },
|
||
React.createElement("label", null, "Notes"),
|
||
React.createElement("textarea", {
|
||
value: selectedInvestor.notes || "",
|
||
onChange: (e) => handleInvestorUpdate("notes", e.target.value)
|
||
})
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "detail-field" },
|
||
React.createElement("label", null, "Last Contact"),
|
||
React.createElement("input", {
|
||
type: "text",
|
||
value: selectedInvestor.lastContact || "",
|
||
onChange: (e) => handleInvestorUpdate("lastContact", e.target.value)
|
||
})
|
||
),
|
||
React.createElement(
|
||
"button",
|
||
{
|
||
onClick: () => handleDeleteInvestor(selectedInvestor.id),
|
||
style: {
|
||
marginTop: "16px",
|
||
padding: "8px 16px",
|
||
background: "#ef4444",
|
||
color: "#fff",
|
||
border: "none",
|
||
borderRadius: "6px",
|
||
cursor: "pointer",
|
||
width: "100%"
|
||
}
|
||
},
|
||
"Delete Investor"
|
||
)
|
||
)
|
||
)
|
||
),
|
||
// Custom Column Modal
|
||
React.createElement(
|
||
"div",
|
||
{ className: "modal" + (showCustomColumnModal ? " open" : "") },
|
||
React.createElement(
|
||
"div",
|
||
{ className: "modal-content" },
|
||
React.createElement("div", { className: "modal-header" }, "Add Custom Column"),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "modal-field" },
|
||
React.createElement("label", null, "Column Name"),
|
||
React.createElement("input", {
|
||
type: "text",
|
||
value: newColumnName,
|
||
onChange: (e) => setNewColumnName(e.target.value),
|
||
placeholder: "e.g., Intro Source"
|
||
})
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "modal-field" },
|
||
React.createElement("label", null, "Data Type"),
|
||
React.createElement(
|
||
"select",
|
||
{
|
||
value: newColumnType,
|
||
onChange: (e) => setNewColumnType(e.target.value)
|
||
},
|
||
React.createElement("option", { value: "text" }, "Text"),
|
||
React.createElement("option", { value: "number" }, "Number"),
|
||
React.createElement("option", { value: "currency" }, "Currency"),
|
||
React.createElement("option", { value: "date" }, "Date"),
|
||
React.createElement("option", { value: "longText" }, "Long Text"),
|
||
React.createElement("option", { value: "checkbox" }, "Checkbox"),
|
||
React.createElement("option", { value: "dropdown" }, "Dropdown")
|
||
)
|
||
),
|
||
newColumnType === "dropdown" && React.createElement(
|
||
"div",
|
||
{ className: "modal-field" },
|
||
React.createElement("label", null, "Options (comma-separated)"),
|
||
React.createElement("input", {
|
||
type: "text",
|
||
value: newColumnOptions,
|
||
onChange: (e) => setNewColumnOptions(e.target.value),
|
||
placeholder: "Option 1, Option 2, Option 3"
|
||
})
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "modal-buttons" },
|
||
React.createElement(
|
||
"button",
|
||
{
|
||
className: "modal-button cancel",
|
||
onClick: () => {
|
||
setShowCustomColumnModal(false);
|
||
setNewColumnName("");
|
||
setNewColumnType("text");
|
||
setNewColumnOptions("");
|
||
}
|
||
},
|
||
"Cancel"
|
||
),
|
||
React.createElement(
|
||
"button",
|
||
{
|
||
className: "modal-button primary",
|
||
onClick: () => {
|
||
if (newColumnName.trim()) {
|
||
const colId = "custom_" + (Math.max(...customColumns.map(c => parseInt(c.id.replace("custom_", "")) || 0), 0) + 1);
|
||
const options = newColumnType === "dropdown" ? newColumnOptions.split(",").map(o => o.trim()).filter(o => o) : [];
|
||
setCustomColumns([...customColumns, { id: colId, name: newColumnName, type: newColumnType, options }]);
|
||
setInvestors(investors.map(inv => ({
|
||
...inv,
|
||
customFields: { ...(inv.customFields || {}), [colId]: newColumnType === "checkbox" ? false : newColumnType === "number" || newColumnType === "currency" ? 0 : "" }
|
||
})));
|
||
setShowCustomColumnModal(false);
|
||
setNewColumnName("");
|
||
setNewColumnType("text");
|
||
setNewColumnOptions("");
|
||
}
|
||
},
|
||
disabled: !newColumnName.trim()
|
||
},
|
||
"Create Column"
|
||
)
|
||
)
|
||
)
|
||
),
|
||
// Add Investor Modal
|
||
React.createElement(
|
||
"div",
|
||
{ className: `modal ${showAddModal ? "open" : ""}` },
|
||
React.createElement(
|
||
"div",
|
||
{ className: "modal-content" },
|
||
React.createElement("div", { className: "modal-header" }, "Add New Investor"),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "modal-field" },
|
||
React.createElement("label", null, "Entity Name"),
|
||
React.createElement("input", {
|
||
type: "text",
|
||
value: newInvestor.name,
|
||
onChange: (e) => setNewInvestor({ ...newInvestor, name: e.target.value })
|
||
})
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "modal-field" },
|
||
React.createElement("label", null, "Type"),
|
||
React.createElement(
|
||
"select",
|
||
{
|
||
value: newInvestor.type,
|
||
onChange: (e) => setNewInvestor({ ...newInvestor, type: e.target.value })
|
||
},
|
||
React.createElement("option", { value: "investor" }, "Investor"),
|
||
React.createElement("option", { value: "prospect" }, "Prospect"),
|
||
React.createElement("option", { value: "lead" }, "Lead")
|
||
)
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "modal-field" },
|
||
React.createElement("label", null, "Lead"),
|
||
React.createElement(
|
||
"select",
|
||
{
|
||
value: newInvestor.lead,
|
||
onChange: (e) => setNewInvestor({ ...newInvestor, lead: e.target.value })
|
||
},
|
||
["Grant", "Matt", "JP"].map(name =>
|
||
React.createElement("option", { key: name, value: name }, name)
|
||
)
|
||
)
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "modal-field" },
|
||
React.createElement("label", null, "Priority"),
|
||
React.createElement(
|
||
"select",
|
||
{
|
||
value: newInvestor.priority,
|
||
onChange: (e) => setNewInvestor({ ...newInvestor, priority: e.target.value })
|
||
},
|
||
React.createElement("option", { value: "high" }, "High"),
|
||
React.createElement("option", { value: "medium" }, "Medium"),
|
||
React.createElement("option", { value: "low" }, "Low")
|
||
)
|
||
),
|
||
React.createElement("div", null, React.createElement("div", { className: "detail-section-title", style: { marginBottom: "12px", marginTop: "16px" } }, "First Contact")),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "modal-field" },
|
||
React.createElement("label", null, "Name"),
|
||
React.createElement("input", {
|
||
type: "text",
|
||
value: newInvestor.contacts[0].name,
|
||
onChange: (e) => setNewInvestor({
|
||
...newInvestor,
|
||
contacts: [{ ...newInvestor.contacts[0], name: e.target.value }, ...newInvestor.contacts.slice(1)]
|
||
})
|
||
})
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "modal-field" },
|
||
React.createElement("label", null, "Email"),
|
||
React.createElement("input", {
|
||
type: "email",
|
||
value: newInvestor.contacts[0].email,
|
||
onChange: (e) => setNewInvestor({
|
||
...newInvestor,
|
||
contacts: [{ ...newInvestor.contacts[0], email: e.target.value }, ...newInvestor.contacts.slice(1)]
|
||
})
|
||
})
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "modal-field" },
|
||
React.createElement("label", null, "Title (optional)"),
|
||
React.createElement("input", {
|
||
type: "text",
|
||
value: newInvestor.contacts[0].title,
|
||
onChange: (e) => setNewInvestor({
|
||
...newInvestor,
|
||
contacts: [{ ...newInvestor.contacts[0], title: e.target.value }, ...newInvestor.contacts.slice(1)]
|
||
})
|
||
})
|
||
),
|
||
React.createElement(
|
||
"div",
|
||
{ className: "modal-buttons" },
|
||
React.createElement(
|
||
"button",
|
||
{
|
||
className: "modal-button cancel",
|
||
onClick: () => {
|
||
setShowAddModal(false);
|
||
setNewInvestor({
|
||
name: "",
|
||
type: "prospect",
|
||
lead: "Grant",
|
||
priority: "medium",
|
||
contacts: [{ name: "", email: "", title: "", location: "" }]
|
||
});
|
||
}
|
||
},
|
||
"Cancel"
|
||
),
|
||
React.createElement(
|
||
"button",
|
||
{
|
||
className: "modal-button primary",
|
||
onClick: handleAddInvestor,
|
||
disabled: !newInvestor.name || !newInvestor.contacts[0].email
|
||
},
|
||
"Add Investor"
|
||
)
|
||
)
|
||
)
|
||
),
|
||
// Contacts Tooltip
|
||
tooltipData && React.createElement(
|
||
"div",
|
||
{
|
||
className: "tooltip",
|
||
style: { left: tooltipData.x + "px", top: tooltipData.y + "px" }
|
||
},
|
||
tooltipData.contacts.map((contact, idx) =>
|
||
React.createElement(
|
||
"div",
|
||
{ key: idx, className: "tooltip-contact" },
|
||
React.createElement("div", { className: "tooltip-contact-name" }, contact.name),
|
||
React.createElement("div", { className: "tooltip-contact-email" }, contact.email),
|
||
contact.title && React.createElement("div", { className: "tooltip-contact-email" }, contact.title),
|
||
contact.location && React.createElement("div", { className: "tooltip-contact-email", style: { color: "#64748b" } }, contact.location)
|
||
)
|
||
)
|
||
),
|
||
// Context Menu
|
||
contextMenu && React.createElement(
|
||
"div",
|
||
{
|
||
className: "context-menu",
|
||
style: { left: contextMenu.x + "px", top: contextMenu.y + "px" },
|
||
onClick: (e) => e.stopPropagation()
|
||
},
|
||
React.createElement("div", {
|
||
className: "context-menu-item",
|
||
onClick: () => contextMenuAction("view")
|
||
}, "\u25B6", " View Details"),
|
||
React.createElement("div", {
|
||
className: "context-menu-item",
|
||
onClick: () => contextMenuAction("edit")
|
||
}, "\u270F", " Edit"),
|
||
React.createElement("div", { className: "context-menu-divider" }),
|
||
React.createElement("div", { className: "context-menu-label" }, "Assign To"),
|
||
React.createElement("div", {
|
||
className: "context-menu-item",
|
||
onClick: () => contextMenuAction("assignGrant")
|
||
}, React.createElement("span", { className: "context-menu-check" }, contextMenu.investor.lead === "Grant" ? "\u2713" : ""), " Grant"),
|
||
React.createElement("div", {
|
||
className: "context-menu-item",
|
||
onClick: () => contextMenuAction("assignMatt")
|
||
}, React.createElement("span", { className: "context-menu-check" }, contextMenu.investor.lead === "Matt" ? "\u2713" : ""), " Matt"),
|
||
React.createElement("div", {
|
||
className: "context-menu-item",
|
||
onClick: () => contextMenuAction("assignJP")
|
||
}, React.createElement("span", { className: "context-menu-check" }, contextMenu.investor.lead === "JP" ? "\u2713" : ""), " JP"),
|
||
React.createElement("div", { className: "context-menu-divider" }),
|
||
React.createElement("div", { className: "context-menu-label" }, "Priority"),
|
||
React.createElement("div", {
|
||
className: "context-menu-item",
|
||
onClick: () => contextMenuAction("priorityHigh")
|
||
}, React.createElement("span", { className: "context-menu-check" }, contextMenu.investor.priority === "high" ? "\u2713" : ""), " High"),
|
||
React.createElement("div", {
|
||
className: "context-menu-item",
|
||
onClick: () => contextMenuAction("priorityMedium")
|
||
}, React.createElement("span", { className: "context-menu-check" }, contextMenu.investor.priority === "medium" ? "\u2713" : ""), " Medium"),
|
||
React.createElement("div", {
|
||
className: "context-menu-item",
|
||
onClick: () => contextMenuAction("priorityLow")
|
||
}, React.createElement("span", { className: "context-menu-check" }, contextMenu.investor.priority === "low" ? "\u2713" : ""), " Low"),
|
||
React.createElement("div", { className: "context-menu-divider" }),
|
||
React.createElement("div", {
|
||
className: "context-menu-item",
|
||
onClick: () => contextMenuAction("followUp")
|
||
}, React.createElement("span", { className: "context-menu-check" }, contextMenu.investor.followUp ? "\u2713" : ""), contextMenu.investor.followUp ? " Remove from Follow-Up" : " Add to Follow-Up"),
|
||
React.createElement("div", {
|
||
className: "context-menu-item",
|
||
onClick: () => contextMenuAction("graveyard")
|
||
}, React.createElement("span", { className: "context-menu-check" }, contextMenu.investor.graveyard ? "\u2713" : ""), contextMenu.investor.graveyard ? " Restore from Graveyard" : " Move to Graveyard"),
|
||
React.createElement("div", { className: "context-menu-divider" }),
|
||
React.createElement("div", {
|
||
className: "context-menu-item",
|
||
onClick: () => contextMenuAction("duplicate")
|
||
}, "\u2398", " Duplicate"),
|
||
React.createElement("div", {
|
||
className: "context-menu-item danger",
|
||
onClick: () => contextMenuAction("delete")
|
||
}, "\u2716", " Delete")
|
||
)
|
||
);
|
||
}
|
||
|
||
// Render App
|
||
const root = ReactDOM.createRoot(document.getElementById("root"));
|
||
root.render(React.createElement(App));
|
||
</script>
|
||
</body>
|
||
</html>
|