Add reminders & follow-ups (W1) (v0.1.0:92)
First-class reminders tied to the fundraising grid — foundation of the agreed reminders -> NL-search -> bot-mutations plan (keep LP data off third-party LLMs). - reminders table (migration 0006; logical FK to fundraising_investors.id + denormalized name), CRUD at /api/reminders (soft-delete; open/done/snoozed/ cancelled; assignee; source; source_row_id resolution) - read-only derived reminder_status grid column (overdue/due_soon/open), filterable; orphan reconciler cancels reminders when an investor leaves the grid - Reminders page, Dashboard "Reminders Due" card, daily-digest reminders section - per-investor last_activity_at recency rollup (shared block for the W2 NL query) - tests: test_reminders.py + digest reminders test (31/31 green, render-smoke green)
This commit is contained in:
+447
-1
@@ -3308,6 +3308,7 @@
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [dueReminders, setDueReminders] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDashboard = async () => {
|
||||
@@ -3324,6 +3325,20 @@
|
||||
fetchDashboard();
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
// Reminders due now (overdue + due today) — open reminders with a past/today date.
|
||||
const fetchDue = async () => {
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const res = await api(`/api/reminders?status=open&due_before=${today}`, {}, token);
|
||||
const list = Array.isArray(res?.data) ? res.data : [];
|
||||
list.sort((a, b) => String(a.due_date || '').localeCompare(String(b.due_date || '')));
|
||||
setDueReminders(list);
|
||||
} catch (_) { /* non-fatal: dashboard still renders */ }
|
||||
};
|
||||
fetchDue();
|
||||
}, [token]);
|
||||
|
||||
if (loading) return <div style={{ padding: '20px' }}><SkeletonBlock lines={7} /></div>;
|
||||
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
|
||||
if (!data) return <div className="empty-state">No data</div>;
|
||||
@@ -3364,6 +3379,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dueReminders.length > 0 && (
|
||||
<div className="section">
|
||||
<div className="section-title">Reminders Due ({dueReminders.length})</div>
|
||||
<div className="timeline">
|
||||
{dueReminders.slice(0, 10).map((r) => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const overdue = String(r.due_date || '').slice(0, 10) < today;
|
||||
return (
|
||||
<div key={r.id} className="timeline-item">
|
||||
<div className="timeline-marker"></div>
|
||||
<div className="timeline-content">
|
||||
<div className="timeline-header">
|
||||
{r.investor_name ? `${r.investor_name} — ` : ''}{r.title}
|
||||
</div>
|
||||
<div className="timeline-meta" style={{ color: overdue ? '#e06c6c' : '#e0b341' }}>
|
||||
{overdue ? 'Overdue' : 'Due today'}: {formatDate(r.due_date)}
|
||||
{r.assignee_name ? ` · ${r.assignee_name}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="section">
|
||||
<div className="section-title">Pipeline by Stage</div>
|
||||
<div className="pipeline-summary">
|
||||
@@ -3421,6 +3462,235 @@
|
||||
);
|
||||
};
|
||||
|
||||
const RemindersPage = ({ token, onShowToast, user }) => {
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('active');
|
||||
const [onlyMine, setOnlyMine] = useState(false);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createForm, setCreateForm] = useState({ title: '', due_date: '', details: '', investor_name: '', assignee_id: '' });
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [editForm, setEditForm] = useState({ title: '', due_date: '', details: '', status: 'open', assignee_id: '' });
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (statusFilter && statusFilter !== 'all') {
|
||||
if (statusFilter === 'overdue') params.set('overdue', '1');
|
||||
else params.set('status', statusFilter);
|
||||
}
|
||||
if (onlyMine) params.set('assignee', 'me');
|
||||
const res = await api(`/api/reminders?${params.toString()}`, {}, token);
|
||||
setItems(Array.isArray(res?.data) ? res.data : []);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Failed to load reminders'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [token, statusFilter, onlyMine]);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try { const r = await api('/api/users', {}, token); setUsers(Array.isArray(r?.data) ? r.data : []); }
|
||||
catch (_) { /* assignee dropdown is optional */ }
|
||||
})();
|
||||
}, [token]);
|
||||
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
const soonStr = new Date(Date.now() + 7 * 864e5).toISOString().slice(0, 10);
|
||||
const urgency = (r) => {
|
||||
if (r.status !== 'open' || !r.due_date) return null;
|
||||
const due = String(r.due_date).slice(0, 10);
|
||||
if (due < todayStr) return 'overdue';
|
||||
if (due <= soonStr) return 'due_soon';
|
||||
return null;
|
||||
};
|
||||
|
||||
const patch = async (id, body, okMsg) => {
|
||||
try {
|
||||
await api(`/api/reminders/${id}`, { method: 'PATCH', body: JSON.stringify(body) }, token);
|
||||
if (okMsg) onShowToast(okMsg, 'success');
|
||||
load();
|
||||
} catch (err) { onShowToast(getErrorMessage(err, 'Update failed'), 'error'); }
|
||||
};
|
||||
const del = async (id) => {
|
||||
try {
|
||||
await api(`/api/reminders/${id}`, { method: 'DELETE' }, token);
|
||||
onShowToast('Reminder deleted', 'success');
|
||||
load();
|
||||
} catch (err) { onShowToast(getErrorMessage(err, 'Delete failed'), 'error'); }
|
||||
};
|
||||
const snooze = (r) => {
|
||||
// Keep it actionable: push the due date out so it reliably reappears. A
|
||||
// 'snoozed' status has no wake mechanism, so it would hide the reminder
|
||||
// permanently — that status is reserved for an explicit "mute" via Edit.
|
||||
const d = new Date(Date.now() + 7 * 864e5).toISOString().slice(0, 10);
|
||||
patch(r.id, { status: 'open', due_date: d }, 'Snoozed 7 days');
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
const title = (createForm.title || '').trim();
|
||||
if (!title) { onShowToast('A reminder needs a title', 'error'); return; }
|
||||
setCreating(true);
|
||||
try {
|
||||
await api('/api/reminders', { method: 'POST', body: JSON.stringify({
|
||||
title, due_date: createForm.due_date || '', details: createForm.details || '',
|
||||
investor_name: createForm.investor_name || '', assignee_id: createForm.assignee_id || '',
|
||||
}) }, token);
|
||||
onShowToast('Reminder created', 'success');
|
||||
setShowCreate(false);
|
||||
setCreateForm({ title: '', due_date: '', details: '', investor_name: '', assignee_id: '' });
|
||||
load();
|
||||
} catch (err) { onShowToast(getErrorMessage(err, 'Create failed'), 'error'); }
|
||||
finally { setCreating(false); }
|
||||
};
|
||||
|
||||
const openEdit = (r) => {
|
||||
setEditing(r);
|
||||
setEditForm({ title: r.title || '', due_date: (r.due_date || '').slice(0, 10),
|
||||
details: r.details || '', status: r.status || 'open', assignee_id: r.assignee_id || '' });
|
||||
};
|
||||
const submitEdit = async () => {
|
||||
if (!editing) return;
|
||||
const title = (editForm.title || '').trim();
|
||||
if (!title) { onShowToast('A reminder needs a title', 'error'); return; }
|
||||
setSavingEdit(true);
|
||||
try {
|
||||
await api(`/api/reminders/${editing.id}`, { method: 'PATCH', body: JSON.stringify({
|
||||
title, due_date: editForm.due_date || '', details: editForm.details || '',
|
||||
status: editForm.status, assignee_id: editForm.assignee_id || '',
|
||||
}) }, token);
|
||||
onShowToast('Reminder updated', 'success');
|
||||
setEditing(null);
|
||||
load();
|
||||
} catch (err) { onShowToast(getErrorMessage(err, 'Update failed'), 'error'); }
|
||||
finally { setSavingEdit(false); }
|
||||
};
|
||||
|
||||
const badgeColor = { open: '#7fb0d3', snoozed: '#b08fd3', done: '#7fd3a3', cancelled: '#70859b' };
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px', flexWrap: 'wrap', gap: '10px' }}>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>Reminders</h2>
|
||||
<button type="button" onClick={() => setShowCreate(true)}>+ New reminder</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', marginBottom: '16px', flexWrap: 'wrap' }}>
|
||||
<select className="select-input" style={{ maxWidth: '200px' }} value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="active">Active (open + snoozed)</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="overdue">Overdue</option>
|
||||
<option value="done">Done</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
<label style={{ fontSize: '13px', color: '#8ea2b7', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<input type="checkbox" checked={onlyMine} onChange={(e) => setOnlyMine(e.target.checked)} /> Only mine
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{loading ? <SkeletonBlock lines={6} />
|
||||
: error ? <div className="toast error" style={{ position: 'static' }}>{error}</div>
|
||||
: items.length === 0 ? <div className="empty-state">No reminders</div>
|
||||
: (
|
||||
<div>
|
||||
{items.map((r) => {
|
||||
const u = urgency(r);
|
||||
const dueColor = u === 'overdue' ? '#e06c6c' : u === 'due_soon' ? '#e0b341' : '#8ea2b7';
|
||||
return (
|
||||
<div key={r.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '12px', padding: '12px 14px', border: '1px solid #263548', borderRadius: '8px', marginBottom: '8px', background: '#0d1622' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: '14px', marginBottom: '3px' }}>
|
||||
{r.title}
|
||||
<span style={{ marginLeft: '8px', fontSize: '11px', textTransform: 'uppercase', color: badgeColor[r.status] || '#70859b' }}>{r.status}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#8ea2b7' }}>
|
||||
{r.investor_name ? <span>{r.investor_name} · </span> : null}
|
||||
<span style={{ color: dueColor }}>{r.due_date ? `Due ${formatDate(r.due_date)}` : 'No due date'}</span>
|
||||
{r.assignee_name ? <span> · {r.assignee_name}</span> : null}
|
||||
{r.last_activity_at ? <span> · last activity {formatDate(r.last_activity_at)}</span> : null}
|
||||
</div>
|
||||
{r.details ? <div style={{ fontSize: '12px', color: '#a9bcd0', marginTop: '4px' }}>{r.details}</div> : null}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', flexShrink: 0, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
{(r.status === 'open' || r.status === 'snoozed') && <button type="button" className="button-secondary" style={{ padding: '4px 9px', fontSize: '12px' }} onClick={() => patch(r.id, { status: 'done' }, 'Marked done')}>Done</button>}
|
||||
{r.status === 'open' && <button type="button" className="button-secondary" style={{ padding: '4px 9px', fontSize: '12px' }} onClick={() => snooze(r)}>Snooze 7d</button>}
|
||||
<button type="button" className="button-secondary" style={{ padding: '4px 9px', fontSize: '12px' }} onClick={() => openEdit(r)}>Edit</button>
|
||||
<button type="button" className="button-secondary" style={{ padding: '4px 9px', fontSize: '12px', color: '#e06c6c' }} onClick={() => del(r.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<div className="modal-header">New reminder</div>
|
||||
<div className="form-group"><label className="form-label">Title</label>
|
||||
<input className="text-input" value={createForm.title} onChange={(e) => setCreateForm((f) => ({ ...f, title: e.target.value }))} placeholder="What needs doing?" /></div>
|
||||
<div className="form-group"><label className="form-label">Due date</label>
|
||||
<input type="date" className="text-input" value={createForm.due_date} onChange={(e) => setCreateForm((f) => ({ ...f, due_date: e.target.value }))} /></div>
|
||||
<div className="form-group"><label className="form-label">Investor (optional)</label>
|
||||
<input className="text-input" value={createForm.investor_name} onChange={(e) => setCreateForm((f) => ({ ...f, investor_name: e.target.value }))} placeholder="Name only, or leave blank for a team task" />
|
||||
<div className="form-help">To link a reminder to a specific investor row (and its grid chip), set it from the Fundraising Grid's <strong>Reminder</strong> column. A name typed here is a free-text label only.</div></div>
|
||||
<div className="form-group"><label className="form-label">Assignee (optional)</label>
|
||||
<select className="select-input" value={createForm.assignee_id} onChange={(e) => setCreateForm((f) => ({ ...f, assignee_id: e.target.value }))}>
|
||||
<option value="">Unassigned (team)</option>
|
||||
{users.map((u) => <option key={u.id} value={u.id}>{u.full_name || u.username}</option>)}
|
||||
</select></div>
|
||||
<div className="form-group"><label className="form-label">Details (optional)</label>
|
||||
<textarea className="text-input" rows="2" value={createForm.details} onChange={(e) => setCreateForm((f) => ({ ...f, details: e.target.value }))} /></div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="button-secondary" onClick={() => setShowCreate(false)}>Cancel</button>
|
||||
<button type="button" onClick={submitCreate} disabled={creating}>{creating ? <Spinner /> : 'Create'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<div className="modal-header">Edit reminder</div>
|
||||
<div className="form-group"><label className="form-label">Title</label>
|
||||
<input className="text-input" value={editForm.title} onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))} /></div>
|
||||
<div className="form-group"><label className="form-label">Due date</label>
|
||||
<input type="date" className="text-input" value={editForm.due_date} onChange={(e) => setEditForm((f) => ({ ...f, due_date: e.target.value }))} /></div>
|
||||
<div className="form-group"><label className="form-label">Status</label>
|
||||
<select className="select-input" value={editForm.status} onChange={(e) => setEditForm((f) => ({ ...f, status: e.target.value }))}>
|
||||
<option value="open">Open</option>
|
||||
<option value="snoozed">Snoozed</option>
|
||||
<option value="done">Done</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select></div>
|
||||
<div className="form-group"><label className="form-label">Assignee</label>
|
||||
<select className="select-input" value={editForm.assignee_id} onChange={(e) => setEditForm((f) => ({ ...f, assignee_id: e.target.value }))}>
|
||||
<option value="">Unassigned (team)</option>
|
||||
{users.map((u) => <option key={u.id} value={u.id}>{u.full_name || u.username}</option>)}
|
||||
</select></div>
|
||||
<div className="form-group"><label className="form-label">Details</label>
|
||||
<textarea className="text-input" rows="2" value={editForm.details} onChange={(e) => setEditForm((f) => ({ ...f, details: e.target.value }))} /></div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="button-secondary" onClick={() => setEditing(null)}>Cancel</button>
|
||||
<button type="button" onClick={submitEdit} disabled={savingEdit}>{savingEdit ? <Spinner /> : 'Save'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ContactsPage = ({ token, onShowToast }) => {
|
||||
const CONTACTS_PAGE_SIZE = 100;
|
||||
const [contacts, setContacts] = useState([]);
|
||||
@@ -4846,6 +5116,12 @@
|
||||
const [logCommContext, setLogCommContext] = useState(null);
|
||||
const [logCommForm, setLogCommForm] = useState({ type: 'note', subject: '', body: '', outcome: '', next_action: '', next_action_date: '', append_note: true });
|
||||
const [logCommSubmitting, setLogCommSubmitting] = useState(false);
|
||||
const [showReminderModal, setShowReminderModal] = useState(false);
|
||||
const [reminderContext, setReminderContext] = useState(null); // { rowId, investorName }
|
||||
const [reminderList, setReminderList] = useState([]);
|
||||
const [reminderLoading, setReminderLoading] = useState(false);
|
||||
const [reminderForm, setReminderForm] = useState({ title: '', due_date: '', details: '' });
|
||||
const [reminderSubmitting, setReminderSubmitting] = useState(false);
|
||||
const [showContactCardModal, setShowContactCardModal] = useState(false);
|
||||
const [contactCardContext, setContactCardContext] = useState(null);
|
||||
const [contactCardLoading, setContactCardLoading] = useState(false);
|
||||
@@ -4950,6 +5226,17 @@
|
||||
else cols.push(col);
|
||||
changed = true;
|
||||
}
|
||||
// Reminder status: read-only chip whose VALUE is server-computed on read from
|
||||
// the reminders table (overdue/due_soon/open/'' empty). A saved view can filter
|
||||
// on it to drive the follow-up view off real reminders, not the binary checkbox.
|
||||
const hasReminderStatus = cols.some((c) => c.id === 'reminder_status');
|
||||
if (!hasReminderStatus) {
|
||||
const fu = cols.findIndex((c) => c.id === 'follow_up');
|
||||
const col = { id: 'reminder_status', label: 'Reminder', type: 'text', readOnly: true, width: 130 };
|
||||
if (fu >= 0) cols.splice(fu + 1, 0, col);
|
||||
else cols.push(col);
|
||||
changed = true;
|
||||
}
|
||||
const rowsIn = Array.isArray(incomingRows) ? incomingRows : defaultRows;
|
||||
const rowsOut = rowsIn.map((r) => {
|
||||
const next = { ...r };
|
||||
@@ -4976,7 +5263,7 @@
|
||||
// autosave + version bump. Strip them at every snapshot / persist boundary.
|
||||
const stripComputedRows = (rs) => (Array.isArray(rs) ? rs.map((r) => {
|
||||
if (!r || typeof r !== 'object') return r;
|
||||
const { pipeline, pipeline_stage, ...rest } = r;
|
||||
const { pipeline, pipeline_stage, reminder_status, ...rest } = r;
|
||||
return rest;
|
||||
}) : rs);
|
||||
|
||||
@@ -5498,6 +5785,85 @@
|
||||
setShowLogCommModal(true);
|
||||
};
|
||||
|
||||
// Mirror the server's reminder_status_by_source_row: the most-urgent OPEN reminder
|
||||
// (overdue > due_soon > open). Lets us refresh the grid chip without a full re-hydrate.
|
||||
const computeReminderChip = (reminders) => {
|
||||
const open = (reminders || []).filter((r) => r.status === 'open');
|
||||
if (!open.length) return '';
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const soon = new Date(Date.now() + 7 * 864e5).toISOString().slice(0, 10);
|
||||
let best = '';
|
||||
for (const r of open) {
|
||||
const due = String(r.due_date || '').slice(0, 10);
|
||||
const s = (due && due < today) ? 'overdue' : (due && due <= soon) ? 'due_soon' : 'open';
|
||||
if (s === 'overdue') return 'overdue';
|
||||
if (s === 'due_soon') best = 'due_soon';
|
||||
else if (!best) best = 'open';
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
const loadReminders = async (rowId) => {
|
||||
setReminderLoading(true);
|
||||
try {
|
||||
const res = await api(`/api/reminders?source_row_id=${encodeURIComponent(rowId)}&status=active`, {}, token);
|
||||
const list = Array.isArray(res?.data) ? res.data : [];
|
||||
setReminderList(list);
|
||||
setRows((prev) => prev.map((r) => (r.id === rowId ? { ...r, reminder_status: computeReminderChip(list) } : r)));
|
||||
return list;
|
||||
} catch (err) {
|
||||
onShowToast(getErrorMessage(err, 'Failed to load reminders'), 'error');
|
||||
return [];
|
||||
} finally {
|
||||
setReminderLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openReminderModal = async (row) => {
|
||||
if (!row) return;
|
||||
setReminderContext({ rowId: row.id, investorName: row.investor_name || '' });
|
||||
setReminderForm({ title: '', due_date: '', details: '' });
|
||||
setReminderList([]);
|
||||
setShowReminderModal(true);
|
||||
await loadReminders(row.id);
|
||||
};
|
||||
|
||||
const submitReminder = async () => {
|
||||
if (!reminderContext?.rowId) return;
|
||||
const title = (reminderForm.title || '').trim();
|
||||
if (!title) { onShowToast('A reminder needs a title', 'error'); return; }
|
||||
setReminderSubmitting(true);
|
||||
try {
|
||||
await api('/api/reminders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
source_row_id: reminderContext.rowId,
|
||||
investor_name: reminderContext.investorName || '',
|
||||
title,
|
||||
due_date: reminderForm.due_date || '',
|
||||
details: reminderForm.details || '',
|
||||
}),
|
||||
}, token);
|
||||
onShowToast('Reminder set', 'success');
|
||||
setReminderForm({ title: '', due_date: '', details: '' });
|
||||
await loadReminders(reminderContext.rowId);
|
||||
} catch (err) {
|
||||
onShowToast(getErrorMessage(err, 'Failed to set reminder'), 'error');
|
||||
} finally {
|
||||
setReminderSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const completeReminder = async (reminderId) => {
|
||||
if (!reminderContext?.rowId) return;
|
||||
try {
|
||||
await api(`/api/reminders/${reminderId}`, { method: 'PATCH', body: JSON.stringify({ status: 'done' }) }, token);
|
||||
await loadReminders(reminderContext.rowId);
|
||||
} catch (err) {
|
||||
onShowToast(getErrorMessage(err, 'Failed to update reminder'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const openContactCardModal = async (row, contact = null) => {
|
||||
if (!row || !contact) return;
|
||||
const norm = (v) => String(v || '').trim().toLowerCase();
|
||||
@@ -6347,6 +6713,27 @@
|
||||
if (!stage) return <span style={{ color: '#70859b' }}>—</span>;
|
||||
return <span style={{ textTransform: 'capitalize' }}>{stage.replace(/_/g, ' ')}</span>;
|
||||
}
|
||||
if (col.id === 'reminder_status') {
|
||||
const rs = String(row.reminder_status || '');
|
||||
const meta = {
|
||||
overdue: { label: '⏰ Overdue', color: '#e06c6c', border: '#7a3030' },
|
||||
due_soon: { label: '⏰ Due soon', color: '#e0b341', border: '#7a6320' },
|
||||
open: { label: '⏰ Open', color: '#7fb0d3', border: '#2f5170' },
|
||||
}[rs];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="button-secondary"
|
||||
style={{ padding: '5px 10px', fontSize: '12px',
|
||||
color: meta ? meta.color : undefined,
|
||||
borderColor: meta ? meta.border : undefined }}
|
||||
title={rs ? 'View / manage reminders for this investor' : 'Set a reminder for this investor'}
|
||||
onClick={(e) => { e.stopPropagation(); openReminderModal(row); }}
|
||||
>
|
||||
{meta ? meta.label : '+ Reminder'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (col.type === 'action' || col.id === 'log_action') {
|
||||
return (
|
||||
<button
|
||||
@@ -7205,6 +7592,60 @@
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showReminderModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<div className="modal-header">Reminders</div>
|
||||
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '12px' }}>
|
||||
{reminderContext?.investorName || 'Investor'}
|
||||
</div>
|
||||
<div className="form-help" style={{ marginBottom: '14px', padding: '10px', border: '1px solid #263548', borderRadius: '8px', background: '#0d1622' }}>
|
||||
Set a follow-up with a due date. Open reminders drive the <strong>Reminder</strong> column and the daily digest. Manage all reminders on the Reminders page.
|
||||
</div>
|
||||
|
||||
{reminderLoading ? (
|
||||
<div style={{ padding: '4px 0 12px' }}><Spinner /></div>
|
||||
) : reminderList.length > 0 ? (
|
||||
<div className="form-group">
|
||||
<label className="form-label">Open reminders</label>
|
||||
{reminderList.map((r) => (
|
||||
<div key={r.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '10px', padding: '8px 10px', border: '1px solid #263548', borderRadius: '8px', marginBottom: '6px' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: '13px' }}>{r.title}</div>
|
||||
<div style={{ fontSize: '11px', color: '#8ea2b7' }}>
|
||||
{r.due_date ? `Due ${formatDate(r.due_date)}` : 'No due date'}{r.status === 'snoozed' ? ' · snoozed' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="button-secondary" style={{ padding: '4px 9px', fontSize: '12px' }} onClick={() => completeReminder(r.id)}>Done</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state" style={{ marginBottom: '12px' }}>No open reminders</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">New reminder</label>
|
||||
<input className="text-input" placeholder="What needs doing? (e.g. Send Fund III deck)" value={reminderForm.title} onChange={(e) => setReminderForm((f) => ({ ...f, title: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Due date</label>
|
||||
<input type="date" className="text-input" value={reminderForm.due_date} onChange={(e) => setReminderForm((f) => ({ ...f, due_date: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Details (optional)</label>
|
||||
<textarea className="text-input" rows="2" value={reminderForm.details} onChange={(e) => setReminderForm((f) => ({ ...f, details: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="button-secondary" onClick={() => { setShowReminderModal(false); setReminderContext(null); }}>Close</button>
|
||||
<button type="button" onClick={submitReminder} disabled={reminderSubmitting}>
|
||||
{reminderSubmitting ? <Spinner /> : 'Add reminder'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showContactCardModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
@@ -10887,6 +11328,9 @@
|
||||
<button className={`nav-item ${page === 'pipeline' ? 'active' : ''}`} onClick={() => setPage('pipeline')}>
|
||||
<span className="nav-item-icon">↗</span> Pipeline
|
||||
</button>
|
||||
<button className={`nav-item ${page === 'reminders' ? 'active' : ''}`} onClick={() => setPage('reminders')}>
|
||||
<span className="nav-item-icon">⏰</span> Reminders
|
||||
</button>
|
||||
{user?.role === 'admin' && (
|
||||
<button className={`nav-item ${page === 'communications' ? 'active' : ''}`} onClick={() => setPage('communications')}>
|
||||
<span className="nav-item-icon">◌</span> Communications
|
||||
@@ -10933,6 +11377,7 @@
|
||||
{page === 'dashboard' && 'Dashboard'}
|
||||
{page === 'contacts' && 'Contacts'}
|
||||
{page === 'pipeline' && 'Pipeline'}
|
||||
{page === 'reminders' && 'Reminders'}
|
||||
{page === 'communications' && 'Communications'}
|
||||
{page === 'thesis' && 'Thesis'}
|
||||
{page === 'thesis-workshop' && 'Thesis Workshop'}
|
||||
@@ -10966,6 +11411,7 @@
|
||||
{page === 'dashboard' && <DashboardPage token={token} />}
|
||||
{page === 'contacts' && <ContactsPage token={token} onShowToast={showToast} />}
|
||||
{page === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} />}
|
||||
{page === 'reminders' && <RemindersPage token={token} user={user} 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} />}
|
||||
|
||||
Reference in New Issue
Block a user