Repurpose Communications tab as admin-only email-activity panel (v0.1.0:80)
The Communications tab is now an admin-only search over captured Gmail
(email_* tables), part of consolidating on the fundraising grid + email
capture as the canonical system of record.
- New GET /api/email/activity (admin-enforced server-side): filter by
investor / mailbox / direction with free-text search over subject,
snippet, and sender. Query logic in db.query_email_activity.
- Soft-delete honored on the per-mailbox sighting (emails carry no
deleted_at; deletion lives on email_account_messages).
- Direction decided at the email level (outbound if the sender is one of
our mailboxes), mirroring digest_builder.
- Graveyard investors are hidden from the filter dropdown (CRM-wide
graveyard=0 convention) but their email stays visible in the list and
findable by free-text search — this is an audit surface.
- Communications page rewritten to render the panel; the classic manual
"Log Communication" form is retired (the grid context menu remains the
manual-log path). Nav item + page are admin-only.
- Tests: email_integration/test_email_activity_panel.py (filters,
per-sighting soft-delete, roll-ups, graveyard handling, route 401/403);
full suite 22/22. Frontend render verified via a jsdom mount smoke test
plus the pinned classic-runtime Babel transform.
Code-only, no schema migration (version migrations are no-ops).
This commit is contained in:
+120
-282
@@ -4253,318 +4253,154 @@
|
||||
);
|
||||
};
|
||||
|
||||
const CommunicationsPage = ({ token, onShowToast }) => {
|
||||
const [communications, setCommunications] = useState([]);
|
||||
const [contacts, setContacts] = useState([]);
|
||||
const [investorNames, setInvestorNames] = useState([]);
|
||||
const CommunicationsPage = ({ token, user, onShowToast }) => {
|
||||
// Repurposed (v0.1.0:80): the Communications tab is now the admin-only
|
||||
// email-activity panel over the captured email_* tables. The classic
|
||||
// manual "Log Communication" surface was retired (grid is canonical).
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [type, setType] = useState('');
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [investorId, setInvestorId] = useState('');
|
||||
const [accountId, setAccountId] = useState('');
|
||||
const [direction, setDirection] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [formData, setFormData] = useState({ type: 'email', contact_id: '', investor_selection: '', investor_name_new: '', append_note: true });
|
||||
const [quickMapSearch, setQuickMapSearch] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
const NEW_INVESTOR_VALUE = '__new_investor__';
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
|
||||
const rankedContacts = useMemo(() => {
|
||||
const q = String(quickMapSearch || '').trim();
|
||||
if (!q) return contacts;
|
||||
return contacts
|
||||
.map((c, idx) => {
|
||||
const label = `${contactName(c)} ${c.organization_name || c.organization || ''}`.trim();
|
||||
const score = fuzzyScore(q, label);
|
||||
return { c, idx, score };
|
||||
})
|
||||
.filter((x) => x.score >= 0.45)
|
||||
.sort((a, b) => (b.score - a.score) || (a.idx - b.idx))
|
||||
.map((x) => x.c);
|
||||
}, [contacts, quickMapSearch]);
|
||||
|
||||
const rankedInvestors = useMemo(() => {
|
||||
const q = String(quickMapSearch || '').trim();
|
||||
if (!q) return investorNames;
|
||||
return investorNames
|
||||
.map((name, idx) => ({ name, idx, score: fuzzyScore(q, name) }))
|
||||
.filter((x) => x.score >= 0.45)
|
||||
.sort((a, b) => (b.score - a.score) || (a.idx - b.idx))
|
||||
.map((x) => x.name);
|
||||
}, [investorNames, quickMapSearch]);
|
||||
// Debounce the free-text box so each keystroke doesn't hit the server.
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchComms = async () => {
|
||||
if (!isAdmin) { setLoading(false); return undefined; }
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [commResult, contactResult, fundraisingResult] = await Promise.all([
|
||||
api(`/api/communications?type=${type}&search=${search}&limit=200`, {}, token),
|
||||
api('/api/contacts?limit=1000', {}, token),
|
||||
api('/api/fundraising/state', {}, token)
|
||||
]);
|
||||
setCommunications(commResult.data || []);
|
||||
setContacts(contactResult.data || []);
|
||||
const names = Array.from(new Set(
|
||||
((fundraisingResult?.data?.grid?.rows || [])
|
||||
.map((r) => String(r?.investor_name || '').trim())
|
||||
.filter(Boolean))
|
||||
)).sort((a, b) => a.localeCompare(b));
|
||||
setInvestorNames(names);
|
||||
const params = new URLSearchParams();
|
||||
if (investorId) params.set('investor_id', investorId);
|
||||
if (accountId) params.set('account_id', accountId);
|
||||
if (direction) params.set('direction', direction);
|
||||
if (debouncedSearch) params.set('q', debouncedSearch);
|
||||
params.set('limit', '200');
|
||||
const res = await api(`/api/email/activity?${params.toString()}`, {}, token);
|
||||
if (cancelled) return;
|
||||
setData(res);
|
||||
setDisabled(false);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
onShowToast(getErrorMessage(err, 'Failed to load communications'), 'error');
|
||||
if (cancelled) return;
|
||||
// Integration off on the server -> show the disabled state, not an error.
|
||||
if (err?.status === 503 || /disabl/i.test(err?.payload?.error || '')) {
|
||||
setDisabled(true);
|
||||
setData(null);
|
||||
} else {
|
||||
setError(getErrorMessage(err, 'Failed to load email activity'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchComms();
|
||||
}, [token, type, search, onShowToast]);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [token, isAdmin, investorId, accountId, direction, debouncedSearch]);
|
||||
|
||||
const handleAddComm = async (e) => {
|
||||
e.preventDefault();
|
||||
setFormError('');
|
||||
try {
|
||||
const selected = contacts.find((c) => c.id === formData.contact_id);
|
||||
if (!selected) {
|
||||
setFormError('Contact is required');
|
||||
return;
|
||||
}
|
||||
const selectedInvestor = String(formData.investor_selection || '').trim();
|
||||
const newInvestorInput = String(formData.investor_name_new || '').trim();
|
||||
const investorName = selectedInvestor === NEW_INVESTOR_VALUE ? newInvestorInput : selectedInvestor;
|
||||
if (!investorName) {
|
||||
setFormError('Investor mapping is required. Select an investor or create a new one.');
|
||||
return;
|
||||
}
|
||||
const fullName = contactName(selected || {});
|
||||
await api('/api/fundraising/log-communication', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
investor_name: investorName,
|
||||
create_investor_if_missing: true,
|
||||
contact: {
|
||||
name: fullName,
|
||||
email: selected?.email || '',
|
||||
title: selected?.title || ''
|
||||
},
|
||||
type: formData.type || 'note',
|
||||
subject: formData.subject || '',
|
||||
body: formData.body || '',
|
||||
outcome: formData.outcome || '',
|
||||
next_action: formData.next_action || '',
|
||||
next_action_date: formData.next_action_date || '',
|
||||
append_note: !!formData.append_note
|
||||
})
|
||||
}, token);
|
||||
setShowForm(false);
|
||||
setFormData({ type: 'email', contact_id: '', investor_selection: '', investor_name_new: '', append_note: true });
|
||||
setQuickMapSearch('');
|
||||
const [result, fundraisingResult] = await Promise.all([
|
||||
api(`/api/communications?type=${type}&search=${search}&limit=200`, {}, token),
|
||||
api('/api/fundraising/state', {}, token)
|
||||
]);
|
||||
setCommunications(result.data || []);
|
||||
const names = Array.from(new Set(
|
||||
((fundraisingResult?.data?.grid?.rows || [])
|
||||
.map((r) => String(r?.investor_name || '').trim())
|
||||
.filter(Boolean))
|
||||
)).sort((a, b) => a.localeCompare(b));
|
||||
setInvestorNames(names);
|
||||
onShowToast('Communication logged', 'success');
|
||||
} catch (err) {
|
||||
setFormError(getErrorMessage(err, 'Failed to log communication'));
|
||||
}
|
||||
};
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<h2 className="section-title" style={{ marginBottom: '20px' }}>Communications</h2>
|
||||
<div className="empty-state">Communications is an admin-only view (captured email activity).</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDeleteComm = async (id) => {
|
||||
try {
|
||||
await api(`/api/communications/${id}`, { method: 'DELETE' }, token);
|
||||
setCommunications((prev) => prev.filter((c) => c.id !== id));
|
||||
setConfirmDelete(null);
|
||||
onShowToast('Communication deleted', 'success');
|
||||
} catch (err) {
|
||||
onShowToast(getErrorMessage(err, 'Failed to delete communication'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const typeLabel = (v) => {
|
||||
if (v === 'email') return 'Email';
|
||||
if (v === 'call') return 'Call';
|
||||
if (v === 'meeting') return 'Meeting';
|
||||
if (v === 'text') return 'Text';
|
||||
return 'Note';
|
||||
};
|
||||
const emails = data?.emails || [];
|
||||
const accounts = data?.accounts || [];
|
||||
const investors = data?.investors || [];
|
||||
const hasFilter = !!(investorId || accountId || direction || debouncedSearch);
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<h2 className="section-title">Communications</h2>
|
||||
<button onClick={() => {
|
||||
setFormError('');
|
||||
setFormData({ type: 'email', contact_id: '', investor_selection: '', investor_name_new: '', append_note: true });
|
||||
setQuickMapSearch('');
|
||||
setShowForm(true);
|
||||
}}>+ Log Communication</button>
|
||||
<span className="form-help">Captured email activity. Logging & drafts live in the Fundraising Grid and Outreach.</span>
|
||||
</div>
|
||||
<div className="section">
|
||||
<div className="controls">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Search subject/body..."
|
||||
placeholder="Search subject, sender, snippet..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<select className="select-input" value={type} onChange={(e) => setType(e.target.value)}>
|
||||
<option value="">All Types</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="call">Call</option>
|
||||
<option value="meeting">Meeting</option>
|
||||
<option value="note">Note</option>
|
||||
<option value="text">Text</option>
|
||||
<select className="select-input" value={investorId} onChange={(e) => setInvestorId(e.target.value)}>
|
||||
<option value="">All investors</option>
|
||||
{investors.map((iv) => <option key={iv.id} value={iv.id}>{iv.name}</option>)}
|
||||
</select>
|
||||
<select className="select-input" value={accountId} onChange={(e) => setAccountId(e.target.value)}>
|
||||
<option value="">All mailboxes</option>
|
||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.email_address}</option>)}
|
||||
</select>
|
||||
<select className="select-input" value={direction} onChange={(e) => setDirection(e.target.value)}>
|
||||
<option value="">In & out</option>
|
||||
<option value="inbound">Received</option>
|
||||
<option value="outbound">Sent</option>
|
||||
</select>
|
||||
</div>
|
||||
{loading ? (
|
||||
<SkeletonBlock lines={8} />
|
||||
) : communications.length === 0 ? (
|
||||
<div className="empty-state">No communications</div>
|
||||
) : error ? (
|
||||
<div className="toast error" style={{ position: 'static' }}>{error}</div>
|
||||
) : disabled ? (
|
||||
<span className="index-job-pill idle"><span className="index-job-dot" /> Email integration is disabled — no Gmail service-account key on the server.</span>
|
||||
) : emails.length === 0 ? (
|
||||
<div className="empty-state">{hasFilter ? 'No email activity matches these filters.' : 'No captured email yet. Enroll mailboxes in Email Capture.'}</div>
|
||||
) : (
|
||||
<div className="timeline">
|
||||
{communications.map((comm) => (
|
||||
<div key={comm.id} className="timeline-item">
|
||||
<div className="timeline-marker"></div>
|
||||
<div className="timeline-content">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '8px' }}>
|
||||
<div className="timeline-header">{typeLabel(comm.type)} · {contactName(comm)}</div>
|
||||
<button className="button-danger" style={{ padding: '4px 8px', fontSize: '11px' }} onClick={() => setConfirmDelete(comm.id)}>Delete</button>
|
||||
<>
|
||||
<div className="form-help" style={{ marginBottom: '10px' }}>
|
||||
Showing {emails.length}{data?.truncated ? '+ (refine filters to see more)' : ''} email{emails.length === 1 ? '' : 's'}.
|
||||
</div>
|
||||
<div className="timeline">
|
||||
{emails.map((em) => {
|
||||
const sent = em.direction === 'outbound';
|
||||
const who = em.from_name ? `${em.from_name} <${em.from_email}>` : (em.from_email || 'Unknown sender');
|
||||
const tags = [...(em.investors || []).map((iv) => iv.name), ...(em.investor_labels || [])];
|
||||
return (
|
||||
<div key={em.id} className="timeline-item">
|
||||
<div className="timeline-marker"></div>
|
||||
<div className="timeline-content">
|
||||
<div className="timeline-header">
|
||||
<span style={{ color: sent ? '#7fd1a8' : '#7fb0e0' }}>{sent ? '↗ Sent' : '↘ Received'}</span>
|
||||
{' · '}{who}{em.has_attachments ? ' 📎' : ''}
|
||||
</div>
|
||||
<div className="timeline-meta">
|
||||
{formatDate(em.sent_at)}
|
||||
{(em.mailboxes || []).length > 0 && <span> · {em.mailboxes.join(', ')}</span>}
|
||||
</div>
|
||||
{em.subject && <div className="timeline-body" style={{ fontWeight: 600 }}>{em.subject}</div>}
|
||||
{em.snippet && <div className="timeline-body" style={{ marginTop: '4px', color: '#9fb0c2' }}>{em.snippet}</div>}
|
||||
{tags.length > 0 ? (
|
||||
<div style={{ marginTop: '6px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{tags.map((t) => (
|
||||
<span key={t} style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '10px', border: '1px solid #263548', background: '#0d1622', color: em.is_matched ? '#cfe0f2' : '#9fb0c2' }}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: '6px' }}>
|
||||
<span style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '10px', border: '1px dashed #4a3a2a', color: '#c0a070' }}>Unmatched</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="timeline-meta">{formatDate(comm.communication_date)}</div>
|
||||
{comm.subject && <div className="timeline-body">{comm.subject}</div>}
|
||||
{comm.body && <div className="timeline-body" style={{ marginTop: '4px', color: '#9fb0c2' }}>{comm.body}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{showForm && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<div className="modal-header">Log Communication</div>
|
||||
{formError && <div className="toast error" style={{ position: 'static', marginBottom: '16px' }}>{formError}</div>}
|
||||
<div className="form-help" style={{ marginBottom: '14px', padding: '10px', border: '1px solid #263548', borderRadius: '8px', background: '#0d1622' }}>
|
||||
This writes a communication record to the shared timeline, updates <strong>Last Communication Date</strong> on the fundraising row, and can append a one-line summary into <strong>Notes / Communication / Outreach</strong>.
|
||||
</div>
|
||||
<form onSubmit={handleAddComm}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Quick Find</label>
|
||||
<input
|
||||
type="text"
|
||||
className="text-input"
|
||||
placeholder="Search investor or contact (fuzzy)"
|
||||
value={quickMapSearch}
|
||||
onChange={(e) => setQuickMapSearch(e.target.value)}
|
||||
/>
|
||||
<div className="form-help">Use one search box to narrow both lists below. Selecting a contact auto-populates investor mapping when available.</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Contact *</label>
|
||||
<select className="select-input" value={formData.contact_id || ''} onChange={(e) => {
|
||||
const contactId = e.target.value;
|
||||
const selected = contacts.find((c) => c.id === contactId);
|
||||
const orgName = String(selected?.organization_name || selected?.organization || '').trim();
|
||||
setFormData((f) => {
|
||||
if (!orgName) return { ...f, contact_id: contactId };
|
||||
if (investorNames.includes(orgName)) {
|
||||
return { ...f, contact_id: contactId, investor_selection: orgName, investor_name_new: '' };
|
||||
}
|
||||
return { ...f, contact_id: contactId, investor_selection: NEW_INVESTOR_VALUE, investor_name_new: orgName };
|
||||
});
|
||||
}} required>
|
||||
<option value="">Select contact</option>
|
||||
{rankedContacts.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{contactName(c)}{(c.organization_name || c.organization) ? ` · ${c.organization_name || c.organization}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="form-help">Person this communication is tied to.</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Investor Mapping *</label>
|
||||
<select className="select-input" value={formData.investor_selection || ''} onChange={(e) => setFormData((f) => ({ ...f, investor_selection: e.target.value }))} required>
|
||||
<option value="">Select investor</option>
|
||||
{rankedInvestors.map((name) => <option key={name} value={name}>{name}</option>)}
|
||||
<option value={NEW_INVESTOR_VALUE}>+ Create new investor...</option>
|
||||
</select>
|
||||
{formData.investor_selection === NEW_INVESTOR_VALUE && (
|
||||
<input
|
||||
type="text"
|
||||
className="text-input"
|
||||
placeholder="New investor name"
|
||||
value={formData.investor_name_new || ''}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, investor_name_new: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<div className="form-help">Ensures this communication lands on the right fundraising row. New investor names are auto-created in Fundraising Grid.</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Type</label>
|
||||
<select className="select-input" value={formData.type || 'note'} onChange={(e) => setFormData((f) => ({ ...f, type: e.target.value }))}>
|
||||
<option value="email">Email</option>
|
||||
<option value="call">Call</option>
|
||||
<option value="meeting">Meeting</option>
|
||||
<option value="note">Note</option>
|
||||
<option value="text">Text</option>
|
||||
</select>
|
||||
<div className="form-help">Communication category for reporting and filtering.</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Summary</label>
|
||||
<input type="text" className="text-input" placeholder="Short summary (used in timeline and notes append)" value={formData.subject || ''} onChange={(e) => setFormData((f) => ({ ...f, subject: e.target.value }))} />
|
||||
<div className="form-help">This is not an email subject line. It is the headline summary shown in Communications.</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Details</label>
|
||||
<textarea className="text-input" rows="4" placeholder="Full detail, context, and key points" value={formData.body || ''} onChange={(e) => setFormData((f) => ({ ...f, body: e.target.value }))} />
|
||||
<div className="form-help">Saved in Communications history only (not appended to investor notes unless your summary references it).</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Outcome</label>
|
||||
<input type="text" className="text-input" value={formData.outcome || ''} onChange={(e) => setFormData((f) => ({ ...f, outcome: e.target.value }))} />
|
||||
<div className="form-help">Result of this touchpoint (for quick review later).</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Next Action</label>
|
||||
<input type="text" className="text-input" value={formData.next_action || ''} onChange={(e) => setFormData((f) => ({ ...f, next_action: e.target.value }))} />
|
||||
<div className="form-help">Explicit follow-up task (what should happen next).</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Next Action Date</label>
|
||||
<input type="date" className="text-input" value={formData.next_action_date || ''} onChange={(e) => setFormData((f) => ({ ...f, next_action_date: e.target.value }))} />
|
||||
<div className="form-help">Target date for the next action.</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
<input type="checkbox" checked={!!formData.append_note} onChange={(e) => setFormData((f) => ({ ...f, append_note: e.target.checked }))} />
|
||||
{' '}Append summary to Fundraising Grid notes
|
||||
</label>
|
||||
<div className="form-help">Adds one line to <strong>Notes / Communication / Outreach</strong>: date + type + contact + summary.</div>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="button-secondary" onClick={() => setShowForm(false)}>Cancel</button>
|
||||
<button type="submit">Log</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{confirmDelete && (
|
||||
<ConfirmDialog
|
||||
title="Delete Communication"
|
||||
message="Are you sure?"
|
||||
onConfirm={() => handleDeleteComm(confirmDelete)}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10785,9 +10621,11 @@
|
||||
<button className={`nav-item ${page === 'pipeline' ? 'active' : ''}`} onClick={() => setPage('pipeline')}>
|
||||
<span className="nav-item-icon">↗</span> Pipeline
|
||||
</button>
|
||||
<button className={`nav-item ${page === 'communications' ? 'active' : ''}`} onClick={() => setPage('communications')}>
|
||||
<span className="nav-item-icon">◌</span> Communications
|
||||
</button>
|
||||
{user?.role === 'admin' && (
|
||||
<button className={`nav-item ${page === 'communications' ? 'active' : ''}`} onClick={() => setPage('communications')}>
|
||||
<span className="nav-item-icon">◌</span> Communications
|
||||
</button>
|
||||
)}
|
||||
<button className={`nav-item ${page === 'thesis' ? 'active' : ''}`} onClick={() => setPage('thesis')}>
|
||||
<span className="nav-item-icon">§</span> Thesis
|
||||
</button>
|
||||
@@ -10862,7 +10700,7 @@
|
||||
{page === 'dashboard' && <DashboardPage token={token} />}
|
||||
{page === 'contacts' && <ContactsPage token={token} onShowToast={showToast} />}
|
||||
{page === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} />}
|
||||
{page === 'communications' && <CommunicationsPage token={token} onShowToast={showToast} />}
|
||||
{page === 'communications' && <CommunicationsPage token={token} user={user} onShowToast={showToast} />}
|
||||
{page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />}
|
||||
{page === 'thesis-workshop' && <ThesisWorkshopPage token={token} user={user} onShowToast={showToast} />}
|
||||
{page === 'outreach' && <OutreachPage token={token} user={user} onShowToast={showToast} />}
|
||||
|
||||
Reference in New Issue
Block a user