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:
+154
-485
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user