Remove Instructions/Feedback + lp_profiles; sync retry, purge, mobile fixes (v0.1.0:104)

Removals (net -570 lines):
- Delete the Instructions and Feedback (feature_requests) pages + backend.
- Retire lp_profiles + investor_type across server, ingest, and seeds; migration
  0008 drops both empty tables (a sanctioned one-off exception to
  never-hard-delete). 0001's lp_profiles ALTER is removed so a fresh DB doesn't
  break the migration chain (live DBs already applied it).

Fixes:
- Email sync: a transient timeout no longer terminally parks a mailbox; the
  scheduler retries 'retrying' each cycle and re-includes errored accounts on an
  hourly backoff, so stuck mailboxes self-heal.
- Mobile Contacts: page through the full directory (server caps 500/page) -- one
  fetch silently truncated at 720, hiding people from the list and from search.
- Mobile email review: clock icon to set a reminder inline; approval cards show
  date/time.

New:
- Admin-only purge of soft-deleted rows (Settings -> Admin; type-to-confirm,
  refuses any row still linked to live data).

Tests: 45/45 (adds test_sync_ready + test_purge_soft_deleted). Reviewer pass
applied (NULL reminders.contact_id on contact purge). Bumped to v0.1.0:104.
This commit is contained in:
Keysat
2026-06-20 20:06:11 -05:00
parent 985cba3c81
commit 1564c087bf
21 changed files with 629 additions and 694 deletions
+154 -485
View File
@@ -2612,6 +2612,13 @@
.bell-summary { font-size: 13px; color: var(--text-secondary); line-height: 1.45; margin: 0 0 14px; padding: 10px 12px; background: var(--bg-input); border: 1px solid var(--border); border-radius: 8px; }
.bell-back { width: 100%; margin-top: 10px; background: transparent; border: none; color: var(--accent-light); font-size: 14px; font-family: inherit; cursor: pointer; padding: 8px; }
.bell-back:disabled { opacity: 0.5; cursor: default; }
.bell-review-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.bell-reminder-btn {
flex: none; width: 34px; height: 34px; border-radius: 50%; padding: 0; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
background: var(--bg-input); border: 1px solid var(--border); color: var(--text-secondary);
}
.bell-reminder-btn:active { background: var(--bg-hover); }
.quicklog-hint { font-size: 13px; color: var(--text-subtle); line-height: 1.5; margin: 0 0 12px; }
.quicklog-pool { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.quicklog-empty { font-size: 13px; color: var(--text-subtle); padding: 16px 4px; }
@@ -2993,17 +3000,6 @@
return '-';
};
const loadFeatureRequests = () => {
try {
const raw = localStorage.getItem('venture_crm_feature_requests');
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (_) {
return [];
}
};
const FUNDRAISING_GRID_STORAGE_KEY = 'venture_crm_fundraising_grid_v1';
const FUNDRAISING_VIEWS_STORAGE_KEY = 'venture_crm_fundraising_views_v1';
const FUNDRAISING_VERSION_STORAGE_KEY = 'venture_crm_fundraising_version_v1';
@@ -3134,27 +3130,13 @@
{ id: 'm-3002', contact_id: 'c-1003', contact_name: 'David Martinez', type: 'call', subject: 'Follow-up call', body: 'Requested portfolio details.', communication_date: '2026-02-10T10:00:00Z', outcome: 'positive', next_action: 'Send updated deck', next_action_date: '2026-02-20' },
{ id: 'm-3003', contact_id: 'c-1004', contact_name: 'Jennifer Taylor', type: 'email', subject: 'DDQ package', body: 'Sent due diligence package.', communication_date: '2026-02-11T09:00:00Z', outcome: 'neutral', next_action: 'Schedule IC call', next_action_date: '2026-02-22' }
],
lp_profiles: [
{ id: 'lp-4001', contact_id: 'c-1001', contact_name: 'James Chen', organization: 'Sovereign Wealth Holdings', commitment_amount: 25000000, funded_amount: 25000000, fund_name: 'Fund I', legal_docs_signed: true, wire_received: true, k1_sent: true },
{ id: 'lp-4002', contact_id: 'c-1002', contact_name: 'Sarah Williams', organization: 'Pacific Capital Partners', commitment_amount: 15000000, funded_amount: 15000000, fund_name: 'Fund I', legal_docs_signed: true, wire_received: true, k1_sent: false }
],
tags: [
{ id: 't-5001', name: 'High Priority', color: '#ef4444' },
{ id: 't-5002', name: 'Fund II Prospect', color: 'var(--accent)' }
],
feature_requests: loadFeatureRequests(),
audit_log: []
};
const persistFeatureRequests = () => {
if (!MOCK_MODE) return;
try {
localStorage.setItem('venture_crm_feature_requests', JSON.stringify(mockDb.feature_requests || []));
} catch (_) {
// no-op
}
};
const loadMockFundraisingGrid = () => {
try {
const raw = localStorage.getItem(FUNDRAISING_GRID_STORAGE_KEY);
@@ -3222,15 +3204,6 @@
};
});
mockDb.lp_profiles = mockDb.lp_profiles.map((lp) => {
const c = mockDb.contacts.find((x) => x.id === lp.contact_id);
return {
...lp,
contact_name: contactName(c),
organization: c?.organization_name || c?.organization || lp.organization || ''
};
});
mockDb.communications = mockDb.communications.map((m) => {
const c = mockDb.contacts.find((x) => x.id === m.contact_id);
return { ...m, contact_name: contactName(c) };
@@ -3260,7 +3233,6 @@
const metrics = {
total_lps: mockDb.contacts.filter((c) => c.contact_type === 'investor').length,
total_prospects: mockDb.contacts.filter((c) => c.contact_type === 'prospect').length,
total_committed: mockDb.lp_profiles.reduce((s, lp) => s + (lp.commitment_amount || 0), 0),
pipeline_value: mockDb.opportunities.filter((o) => o.stage !== 'commitment').reduce((s, o) => s + (o.expected_amount || 0), 0),
active_opportunities: mockDb.opportunities.filter((o) => o.stage !== 'commitment').length,
comms_this_month: mockDb.communications.length
@@ -3314,8 +3286,7 @@
if (!item) throw new Error('Contact not found');
const opportunities = mockDb.opportunities.filter((o) => o.contact_id === id);
const communications = mockDb.communications.filter((m) => m.contact_id === id);
const lp = mockDb.lp_profiles.find((lpRow) => lpRow.contact_id === id) || null;
return makeResult({ data: clone({ ...item, opportunities, communications, lp_profile: lp }) });
return makeResult({ data: clone({ ...item, opportunities, communications }) });
}
if (/^\/api\/contacts\/[^/]+$/.test(path) && method === 'DELETE') {
@@ -3323,7 +3294,6 @@
mockDb.contacts = mockDb.contacts.filter((c) => c.id !== id);
mockDb.opportunities = mockDb.opportunities.filter((o) => o.contact_id !== id);
mockDb.communications = mockDb.communications.filter((m) => m.contact_id !== id);
mockDb.lp_profiles = mockDb.lp_profiles.filter((lp) => lp.contact_id !== id);
return makeResult({ message: 'Contact deleted' });
}
@@ -3468,38 +3438,6 @@
return makeResult({ message: 'Communication deleted' });
}
if (path === '/api/lp-profiles' && method === 'GET') {
const search = (params.get('search') || '').toLowerCase();
let rows = [...mockDb.lp_profiles];
if (search) rows = rows.filter((r) => `${r.contact_name || ''} ${r.organization || ''}`.toLowerCase().includes(search));
return makeResult({ data: clone(rows), total: rows.length });
}
if (path === '/api/lp-profiles' && method === 'POST') {
const c = mockDb.contacts.find((x) => x.id === body.contact_id);
if (!c) throw new Error('Valid contact is required');
const item = {
id: `lp-${Date.now()}`,
contact_id: body.contact_id,
contact_name: contactName(c),
organization: c.organization_name || c.organization || '',
commitment_amount: Number(body.commitment_amount) || 0,
funded_amount: Number(body.funded_amount) || 0,
fund_name: body.fund_name || '',
legal_docs_signed: !!body.legal_docs_signed,
wire_received: !!body.wire_received,
k1_sent: !!body.k1_sent
};
mockDb.lp_profiles.unshift(item);
return makeResult({ data: clone(item) }, 201);
}
if (/^\/api\/lp-profiles\/[^/]+$/.test(path) && method === 'DELETE') {
const id = path.split('/').pop();
mockDb.lp_profiles = mockDb.lp_profiles.filter((lp) => lp.id !== id);
return makeResult({ message: 'LP deleted' });
}
if (path === '/api/import/csv' && method === 'POST') {
const rows = Array.isArray(body.data) ? body.data : [];
return makeResult({ data: { created: rows.length, updated: 0, skipped: 0, errors: [] }, dry_run: !!body.dry_run });
@@ -3547,54 +3485,6 @@
return makeResult({ data: { presence: [], locks: [], lock_conflict: null } });
}
if (path === '/api/feature-requests' && method === 'GET') {
const status = params.get('status') || '';
const search = (params.get('search') || '').toLowerCase();
let rows = [...mockDb.feature_requests];
if (status) rows = rows.filter((r) => r.status === status);
if (search) {
rows = rows.filter((r) => `${r.title || ''} ${r.description || ''} ${r.requested_by || ''} ${r.category || ''}`.toLowerCase().includes(search));
}
rows.sort((a, b) => (a.created_at > b.created_at ? -1 : 1));
return makeResult({ data: clone(rows), total: rows.length });
}
if (path === '/api/feature-requests' && method === 'POST') {
if (!body.title || !body.title.trim()) throw new Error('Title is required');
const item = {
id: `fr-${Date.now()}`,
title: body.title.trim(),
description: (body.description || '').trim(),
category: body.category || 'other',
priority: body.priority || 'medium',
status: 'new',
requested_by: (body.requested_by || 'Unknown').trim(),
page: body.page || '',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
mockDb.feature_requests.unshift(item);
persistFeatureRequests();
return makeResult({ data: clone(item) }, 201);
}
if (/^\/api\/feature-requests\/[^/]+$/.test(path) && method === 'PATCH') {
const id = path.split('/').pop();
const allowed = ['status', 'priority', 'category', 'page'];
mockDb.feature_requests = mockDb.feature_requests.map((r) => {
if (r.id !== id) return r;
const next = { ...r, updated_at: new Date().toISOString() };
allowed.forEach((field) => {
if (Object.prototype.hasOwnProperty.call(body, field)) next[field] = body[field];
});
return next;
});
persistFeatureRequests();
const item = mockDb.feature_requests.find((r) => r.id === id);
if (!item) throw new Error('Feature request not found');
return makeResult({ data: clone(item) });
}
throw new Error(`Mock endpoint not implemented: ${method} ${path}`);
};
@@ -5945,10 +5835,23 @@
(async () => {
try {
setLoading(true);
// One fetch of the full directory (server cap 500); tab + search + sort are
// applied client-side so switching is instant and needs no refetch.
const r = await api('/api/contacts?sort=last_name&order=asc&limit=500', {}, token);
if (!cancelled) { setContacts(r.data || []); setError(''); }
// Page through the WHOLE directory (the server caps each page at 500 — a single
// fetch silently truncated at 720 contacts, hiding everyone past ~"Pol" from the
// list AND from client-side search). Accumulate pages until the full set is in.
// tab + search + sort stay client-side so switching needs no refetch.
const PAGE = 500;
let all = [];
let offset = 0;
for (;;) {
const r = await api(`/api/contacts?sort=last_name&order=asc&limit=${PAGE}&offset=${offset}`, {}, token);
if (cancelled) return;
const batch = r.data || [];
all = all.concat(batch);
offset += PAGE;
// Stop on a short/empty page or once we've gathered the reported total.
if (batch.length < PAGE || all.length >= Number(r.total || 0)) break;
}
if (!cancelled) { setContacts(all); setError(''); }
} catch (err) {
if (!cancelled) setError(getErrorMessage(err, 'Failed to load contacts'));
} finally {
@@ -7294,284 +7197,6 @@
);
};
const FeatureRequestsPage = ({ token, onShowToast, user }) => {
const [requests, setRequests] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [showForm, setShowForm] = useState(false);
const [formError, setFormError] = useState('');
const [formData, setFormData] = useState({
title: '',
description: '',
category: 'ui_ux',
priority: 'medium',
page: '',
requested_by: user?.full_name || user?.username || ''
});
const fetchRequests = useCallback(async () => {
try {
setLoading(true);
const result = await api(`/api/feature-requests?status=${statusFilter}&search=${encodeURIComponent(search)}`, {}, token);
setRequests(result.data || []);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to load feature requests'), 'error');
} finally {
setLoading(false);
}
}, [statusFilter, search, token, onShowToast]);
useEffect(() => {
fetchRequests();
}, [fetchRequests]);
const handleSubmit = async (e) => {
e.preventDefault();
setFormError('');
try {
await api('/api/feature-requests', {
method: 'POST',
body: JSON.stringify(formData)
}, token);
setShowForm(false);
setFormData({
title: '',
description: '',
category: 'ui_ux',
priority: 'medium',
page: '',
requested_by: user?.full_name || user?.username || ''
});
await fetchRequests();
onShowToast('Feature request submitted', 'success');
} catch (err) {
setFormError(getErrorMessage(err, 'Failed to submit request'));
}
};
const handleStatusChange = async (id, status) => {
try {
await api(`/api/feature-requests/${id}`, {
method: 'PATCH',
body: JSON.stringify({ status })
}, token);
setRequests((prev) => prev.map((r) => (r.id === id ? { ...r, status } : r)));
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to update status'), 'error');
}
};
const handlePriorityChange = async (id, priority) => {
try {
await api(`/api/feature-requests/${id}`, {
method: 'PATCH',
body: JSON.stringify({ priority })
}, token);
setRequests((prev) => prev.map((r) => (r.id === id ? { ...r, priority } : r)));
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to update priority'), 'error');
}
};
const counts = useMemo(() => ({
total: requests.length,
newCount: requests.filter((r) => r.status === 'new').length,
planned: requests.filter((r) => r.status === 'planned').length,
done: requests.filter((r) => r.status === 'done').length
}), [requests]);
return (
<div className="page-container">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h2 className="section-title">Feature Requests</h2>
<button onClick={() => setShowForm(true)}>+ Submit Feedback</button>
</div>
<div className="kpi-grid">
<div className="kpi-card">
<div className="kpi-label">Total Requests</div>
<div className="kpi-value">{counts.total}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">New</div>
<div className="kpi-value">{counts.newCount}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Planned</div>
<div className="kpi-value">{counts.planned}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Done</div>
<div className="kpi-value">{counts.done}</div>
</div>
</div>
<div className="section">
<div className="controls">
<input
type="text"
className="search-input"
placeholder="Search requests..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select
className="select-input"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="">All Statuses</option>
<option value="new">New</option>
<option value="planned">Planned</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
<option value="wont_do">Won't Do</option>
</select>
</div>
{loading ? (
<SkeletonBlock lines={6} />
) : requests.length === 0 ? (
<div className="empty-state">No feature requests yet</div>
) : (
<table className="table">
<thead>
<tr>
<th>Title</th>
<th>Requested By</th>
<th>Category</th>
<th>Priority</th>
<th>Status</th>
<th>Submitted</th>
</tr>
</thead>
<tbody>
{requests.map((r) => (
<tr key={r.id}>
<td>
<div style={{ fontWeight: 600 }}>{r.title}</div>
{r.description && <div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' }}>{r.description}</div>}
</td>
<td>{r.requested_by || '-'}</td>
<td>{(r.category || 'other').replace('_', ' ')}</td>
<td>
<select
className="select-input"
style={{ minWidth: '120px' }}
value={r.priority || 'medium'}
onChange={(e) => handlePriorityChange(r.id, e.target.value)}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</td>
<td>
<select
className="select-input"
style={{ minWidth: '140px' }}
value={r.status || 'new'}
onChange={(e) => handleStatusChange(r.id, e.target.value)}
>
<option value="new">New</option>
<option value="planned">Planned</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
<option value="wont_do">Won't Do</option>
</select>
</td>
<td>{formatDateLong(r.created_at)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{showForm && (
<div className="modal-overlay">
<div className="modal">
<div className="modal-header">Submit Feature Request</div>
{formError && <div className="toast error" style={{ position: 'static', marginBottom: '16px' }}>{formError}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Title *</label>
<input
type="text"
className="text-input"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
/>
</div>
<div className="form-group">
<label className="form-label">Description</label>
<textarea
className="text-input"
rows="4"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label">Category</label>
<select
className="select-input"
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
>
<option value="ui_ux">UI / UX</option>
<option value="workflow">Workflow</option>
<option value="reporting">Reporting</option>
<option value="integrations">Integrations</option>
<option value="bugs">Bug</option>
<option value="other">Other</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Priority</label>
<select
className="select-input"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Page / Area</label>
<input
type="text"
className="text-input"
placeholder="e.g., Pipeline, LP Tracker"
value={formData.page}
onChange={(e) => setFormData({ ...formData, page: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label">Requested By</label>
<input
type="text"
className="text-input"
value={formData.requested_by}
onChange={(e) => setFormData({ ...formData, requested_by: e.target.value })}
/>
</div>
<div className="form-actions">
<button type="button" className="button-secondary" onClick={() => setShowForm(false)}>Cancel</button>
<button type="submit">Submit</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
// Desktop Fundraising Grid (the spreadsheet + autosave). Unchanged; rendered on >768px via
// the FundraisingGridPage switch at the end of this component. Mobile (<768px) renders the
// lean MobileFundraisingGrid instead — which never whole-grid PUTs (BRIEF §3a).
@@ -10999,79 +10624,77 @@
return isMobile ? <MobileFundraisingGrid {...props} /> : <DesktopFundraisingGridPage {...props} />;
};
const InstructionsPage = () => {
// Admin maintenance: permanently delete soft-deleted rows (dummy/test data). Deliberate,
// type-to-confirm exception to never-hard-delete; the server refuses any row that still
// links to live data, so this can only ever remove already-deleted records.
const PurgeDeletedData = ({ token, onShowToast }) => {
const [groups, setGroups] = useState(null);
const [loading, setLoading] = useState(false);
const [confirmKey, setConfirmKey] = useState(''); // `${table}:${id}` currently confirming
const [confirmText, setConfirmText] = useState('');
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const r = await api('/api/admin/soft-deleted', {}, token);
setGroups(r.groups || {});
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to load deleted data'), 'error');
} finally { setLoading(false); }
}, [token, onShowToast]);
useEffect(() => { load(); }, [load]);
const purge = async (table, id) => {
setBusy(true);
try {
await api('/api/admin/soft-deleted/purge', { method: 'POST', body: JSON.stringify({ table, id }) }, token);
onShowToast('Permanently deleted', 'success');
setConfirmKey(''); setConfirmText('');
await load();
} catch (err) {
onShowToast(getErrorMessage(err, 'Could not purge'), 'error');
} finally { setBusy(false); }
};
const LABELS = { contacts: 'Contacts', organizations: 'Organizations', opportunities: 'Opportunities', communications: 'Communications' };
const total = groups ? Object.values(groups).reduce((s, a) => s + (a ? a.length : 0), 0) : 0;
return (
<div className="page-container">
<h2 className="section-title">Instructions</h2>
<div className="section">
<div className="section-title">Purpose</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '14px', lineHeight: 1.6 }}>
Use Fundraising Grid as the master list of investor relationships, then use Contacts, Communications, and Pipeline as deeper operating layers when a relationship becomes active.
</div>
</div>
<div className="section">
<div className="section-title">Daily Workflow</div>
<ol style={{ marginLeft: '20px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
<li>Capture new leads in Fundraising Grid first.</li>
<li>Add or verify contacts on that row (name, email, title, location).</li>
<li>Set Lead owner and relevant flags (Priority, Follow up).</li>
<li>Log communications after each meaningful touchpoint.</li>
<li>Use Next Action and Next Action Date for commitments and reminders.</li>
</ol>
</div>
<div className="section">
<div className="section-title">How To Add New Leads</div>
<ol style={{ marginLeft: '20px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
<li>Create a new row in Fundraising Grid.</li>
<li>Fill Investor Name and at least one contact.</li>
<li>Add context in Notes / Communication / Outreach.</li>
<li>Assign Lead and mark Priority only if truly high-attention.</li>
<li>Use Follow up for active near-term tracking views.</li>
</ol>
</div>
<div className="section">
<div className="section-title">Communication Logging Best Practices</div>
<ol style={{ marginLeft: '20px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
<li>Log communication from Fundraising Grid via contact chip or row right-click.</li>
<li>Always set type and a concise subject/body.</li>
<li>Use Outcome for what happened; use Next Action for what will happen.</li>
<li>If you want timeline text in grid notes, keep “Append note” checked.</li>
<li>Use Communications page to audit and manage all logged interactions.</li>
</ol>
</div>
<div className="section">
<div className="section-title">Priority vs Pipeline</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '14px', lineHeight: 1.7 }}>
Priority is a relationship-level attention flag in the Fundraising Grid. Pipeline is for specific active opportunities with stage/probability/amount tracking. Keep Priority broad and Pipeline selective.
</div>
</div>
<div className="section">
<div className="section-title">When An Opportunity Is Concrete</div>
<ol style={{ marginLeft: '20px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
<li>Right-click the investor row in Fundraising Grid.</li>
<li>Select <strong>Create Pipeline Opportunity</strong>.</li>
<li>Pick contact, stage, expected amount, and probability.</li>
<li>Track progress in Pipeline while keeping relationship notes in Fundraising Grid.</li>
<li>Continue logging communications so follow-ups and timelines stay current.</li>
</ol>
</div>
<div className="section">
<div className="section-title">Data Flow</div>
<ol style={{ marginLeft: '20px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
<li>Fundraising Grid saves to a master fundraising state and relational fundraising tables.</li>
<li>Contacts in Fundraising Grid sync bi-directionally with the Contacts database.</li>
<li>Logging communication creates a communications record and updates fundraising row dates.</li>
<li>Notes Last Modified and Last Communication Date update automatically from activity.</li>
<li>Saved Views filter the same shared master dataset, they do not duplicate records.</li>
</ol>
<div style={{ marginBottom: '20px', borderBottom: '1px solid var(--border)', paddingBottom: '16px' }}>
<div style={{ fontWeight: 600, marginBottom: '8px', color: 'var(--danger-text)' }}>Purge Deleted Data</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '10px' }}>
Permanently remove soft-deleted rows (e.g. dummy/test records). This cannot be undone. A purge only ever touches already-deleted rows and refuses any that still link to live data.
</div>
<button type="button" className="button-secondary" onClick={load} disabled={loading} style={{ marginBottom: '12px' }}>
{loading ? <Spinner /> : 'Refresh'}
</button>
{groups && total === 0 && <div style={{ fontSize: '13px', color: 'var(--text-muted)' }}>No soft-deleted rows.</div>}
{groups && Object.keys(LABELS).map((tbl) => (
(groups[tbl] && groups[tbl].length > 0) ? (
<div key={tbl} style={{ marginBottom: '14px' }}>
<div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: '6px' }}>{LABELS[tbl]} ({groups[tbl].length})</div>
{groups[tbl].map((row) => {
const key = `${tbl}:${row.id}`;
const confirming = confirmKey === key;
return (
<div key={row.id} style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 0', borderTop: '1px solid var(--border)', flexWrap: 'wrap' }}>
<span style={{ flex: 1, minWidth: '140px', fontSize: '13px', color: 'var(--text-primary)' }}>{row.label}</span>
{confirming ? (
<>
<input type="text" className="text-input" placeholder="Type DELETE" value={confirmText} onChange={(e) => setConfirmText(e.target.value)} style={{ width: '110px' }} />
<button type="button" className="button-danger" disabled={busy || confirmText.trim().toUpperCase() !== 'DELETE'} onClick={() => purge(tbl, row.id)}>{busy ? '…' : 'Delete'}</button>
<button type="button" className="button-secondary" disabled={busy} onClick={() => { setConfirmKey(''); setConfirmText(''); }}>Cancel</button>
</>
) : (
<button type="button" className="button-secondary" onClick={() => { setConfirmKey(key); setConfirmText(''); }}>Delete permanently</button>
)}
</div>
);
})}
</div>
) : null
))}
</div>
);
};
@@ -11573,7 +11196,7 @@
onShowToast(`Type exactly "${phrase}" to continue`, 'error');
return;
}
const confirmed = window.confirm('This will permanently clear contacts, organizations, pipeline, communications, feature requests, and reset the fundraising grid. Continue?');
const confirmed = window.confirm('This will permanently clear contacts, organizations, pipeline, communications, and reset the fundraising grid. Continue?');
if (!confirmed) return;
setResetAllDataLoading(true);
try {
@@ -12058,7 +11681,7 @@
<div style={{ marginBottom: '20px', borderBottom: '1px solid var(--border)', paddingBottom: '16px' }}>
<div style={{ fontWeight: 600, marginBottom: '8px', color: 'var(--danger-text)' }}>Danger Zone: Reset All Data</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '10px' }}>
Clears all CRM records (contacts, organizations, opportunities, communications, feature requests) and resets fundraising grid to empty defaults.
Clears all CRM records (contacts, organizations, opportunities, communications) and resets fundraising grid to empty defaults.
</div>
<input
type="text"
@@ -12073,6 +11696,8 @@
</button>
</div>
<PurgeDeletedData token={token} onShowToast={onShowToast} />
<div>
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Fundraising State Ops</div>
<div style={{ marginBottom: '12px', padding: '10px', border: '1px solid var(--border)', borderRadius: '8px' }}>
@@ -14469,6 +14094,9 @@
const [noteDraft, setNoteDraft] = useState('');
const [busy, setBusy] = useState(false);
const busyRef = useRef(false); // synchronous in-flight guard (setBusy is async — a fast double-tap could double-POST)
const [reminderOpen, setReminderOpen] = useState(false); // #C — inline reminder from a review log
const [reminderForm, setReminderForm] = useState({ title: '', due_date: '' });
const [reminderBusy, setReminderBusy] = useState(false);
const load = useCallback(async () => {
try {
@@ -14488,7 +14116,7 @@
const count = proposals ? proposals.length : 0;
const openSheet = () => { setSelected(null); setOpen(true); load(); };
const closeSheet = () => { setOpen(false); setSelected(null); };
const closeSheet = () => { setOpen(false); setSelected(null); setReminderOpen(false); };
const openReview = (p) => { setSelected(p); setNoteDraft(p.proposed_note || ''); };
const decide = async (decision) => {
@@ -14506,6 +14134,30 @@
finally { setBusy(false); busyRef.current = false; }
};
// #C — set a reminder inline from a review log (only when the proposal matched a real
// investor; the reminders POST resolves investor_id → name/grid-row server-side).
const openReminder = () => {
const subj = (selected && selected.email_subject) || '';
setReminderForm({ title: subj ? `Follow up: ${subj}` : '', due_date: reminderDefaultDue() });
setReminderOpen(true);
};
const submitReminder = async () => {
const p = selected; if (!p || !p.investor_id || reminderBusy) return;
const title = (reminderForm.title || '').trim();
if (!title) { onShowToast('A reminder needs a title', 'error'); return; }
if (!reminderForm.due_date) { onShowToast('A reminder needs a due date', 'error'); return; }
setReminderBusy(true);
try {
await api('/api/reminders', { method: 'POST', body: JSON.stringify({
investor_id: p.investor_id, investor_name: p.investor_name || '',
title, due_date: reminderForm.due_date, details: '',
}) }, token);
onShowToast('Reminder set', 'success');
setReminderOpen(false);
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to set reminder'), 'error'); }
finally { setReminderBusy(false); }
};
const dirLabel = (d) => (d === 'sent' ? 'Sent' : 'Received');
return (
<>
@@ -14519,7 +14171,16 @@
<BottomSheet open={open} onClose={closeSheet} title={selected ? 'Review log' : 'Email approvals'}>
{selected ? (
<>
<div className="sheet-subcaption">{selected.investor_name || 'Unmatched investor'}</div>
<div className="bell-review-head">
<div className="sheet-subcaption">{selected.investor_name || 'Unmatched investor'}</div>
{selected.investor_id && (
<button className="bell-reminder-btn" type="button" onClick={openReminder} aria-label="Set a reminder" title="Set a reminder">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="13" r="8" /><path d="M12 10v3l1.5 1.5" /><path d="M5 3 2 6" /><path d="m22 6-3-3" />
</svg>
</button>
)}
</div>
<div className="bell-meta">{dirLabel(selected.direction)}{selected.email_date ? ` · ${formatDateLong(selected.email_date)}` : ''}</div>
{selected.email_subject && <div className="bell-card-subject" style={{ marginBottom: '10px' }}>{selected.email_subject}</div>}
{selected.summary && <div className="bell-summary">{selected.summary}</div>}
@@ -14544,6 +14205,7 @@
<span className="bell-card-name">{p.investor_name || 'Unmatched investor'}</span>
<span className="bell-card-dir">{dirLabel(p.direction)}</span>
</div>
{p.email_date && <div className="bell-meta">{formatDateLong(p.email_date)}</div>}
<div className="bell-card-subject">{p.email_subject || '(no subject)'}</div>
<div className="bell-card-note">{p.summary || p.proposed_note || ''}</div>
</button>
@@ -14551,6 +14213,23 @@
</>
)}
</BottomSheet>
<BottomSheet open={reminderOpen} onClose={() => setReminderOpen(false)} title="Set a reminder" stacked>
{selected && (
<>
<div className="sheet-subcaption">{selected.investor_name || ''}</div>
<div className="bell-meta">Reminder for this investor</div>
<div className="sheet-field">
<label className="sheet-field-label">Reminder</label>
<input className="sheet-input" value={reminderForm.title} onChange={(e) => setReminderForm((f) => ({ ...f, title: e.target.value }))} placeholder="What to follow up on" />
</div>
<div className="sheet-field">
<label className="sheet-field-label">Due date</label>
<input className="sheet-input" type="date" value={reminderForm.due_date} onChange={(e) => setReminderForm((f) => ({ ...f, due_date: e.target.value }))} />
</div>
<button className="sheet-submit" disabled={reminderBusy || !reminderForm.title.trim() || !reminderForm.due_date} onClick={submitReminder}>{reminderBusy ? 'Saving…' : 'Set reminder'}</button>
</>
)}
</BottomSheet>
</>
);
};
@@ -15116,12 +14795,6 @@
<span className="nav-item-icon"></span> Email Capture
</button>
)}
<button className={`nav-item ${page === 'feature-requests' ? 'active' : ''}`} onClick={() => setPage('feature-requests')}>
<span className="nav-item-icon"></span> Feedback
</button>
<button className={`nav-item ${page === 'instructions' ? 'active' : ''}`} onClick={() => setPage('instructions')}>
<span className="nav-item-icon"></span> Instructions
</button>
<button className={`nav-item ${page === 'settings' ? 'active' : ''}`} onClick={() => setPage('settings')}>
<span className="nav-item-icon"></span> Settings
</button>
@@ -15149,8 +14822,6 @@
{page === 'outreach' && 'Outreach'}
{page === 'system-status' && 'System Status'}
{page === 'email-capture' && 'Email Capture'}
{page === 'feature-requests' && 'Feature Requests'}
{page === 'instructions' && 'Instructions'}
{page === 'settings' && 'Settings'}
</div>
</div>
@@ -15206,8 +14877,6 @@
{page === 'outreach' && <OutreachPage token={token} user={user} onShowToast={showToast} />}
{page === 'system-status' && <SystemStatusPage token={token} user={user} onShowToast={showToast} />}
{page === 'email-capture' && <EmailCapturePage token={token} user={user} onShowToast={showToast} />}
{page === 'feature-requests' && <FeatureRequestsPage token={token} onShowToast={showToast} user={user} />}
{page === 'instructions' && <InstructionsPage />}
{page === 'settings' && (
<SettingsPage
token={token}