Add mobile email-approval bell (#6) (v0.1.0:102)
An admin-only bell in the mobile top bar (left of the camera) surfaces the
SAME pending email-capture proposals the web "Email Capture" panel and the
Matrix review room decide — a third surface over the existing endpoints, no
new backend.
- Count badge (iPhone-style) from a 45s poll of GET /api/activity/proposals;
dim when there are none.
- Tap → card list of proposals → tap one → review screen (investor name,
direction/date, subject, summary, editable proposed note) → Approve & log to
grid (POST .../{id}/approve {note}) or Reject (POST .../{id}/dismiss).
- Bidirectional sync is automatic: an app decision flips the proposal status
and the bot's poll redacts the Matrix thread; a Matrix/web decision drops the
proposal from the pending list the bell polls, clearing the badge.
- No LLM round-trip (edit-then-approve, like the web panel). Mobile-gated
(isMobile && admin) so the hidden desktop top bar never polls the endpoint.
Frontend-only; no schema, migration, or dependency change.
This commit is contained in:
@@ -2586,6 +2586,31 @@
|
||||
as a ~1px dot). Explicit CSS dimensions + flex:none fix it — the same reason the working
|
||||
.bottom-tab-icon (sized box) and .sort-pill (flex:none + text) icons render. */
|
||||
.quicklog-btn svg { width: 18px; height: 18px; flex: none; }
|
||||
/* #6 — email-approval bell: dim at rest, accent + a red count badge when alerts are waiting. */
|
||||
.bell-btn { position: relative; color: var(--text-subtle); }
|
||||
.bell-btn.has-alerts { color: var(--accent-light); }
|
||||
.bell-badge {
|
||||
position: absolute; top: -3px; right: -3px;
|
||||
min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box; line-height: 1;
|
||||
border-radius: 999px; background: var(--danger-soft); color: #fff; /* always white on the red dot — theme-stable */
|
||||
font-family: 'IBM Plex Mono', monospace; font-size: 10px; font-weight: 700;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 1.5px solid var(--bg-panel-elevated);
|
||||
}
|
||||
.bell-card {
|
||||
width: 100%; text-align: left; cursor: pointer; font-family: inherit;
|
||||
background: var(--bg-input); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 12px 13px; margin-bottom: 8px; display: flex; flex-direction: column; gap: 5px; color: var(--text-primary);
|
||||
}
|
||||
.bell-card:active { background: var(--bg-hover); }
|
||||
.bell-card-row1 { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.bell-card-name { font-size: 15px; font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bell-card-dir { flex: none; font-family: 'IBM Plex Mono', monospace; font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); }
|
||||
.bell-card-subject { font-size: 13px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bell-card-note { font-size: 12px; color: var(--text-muted); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.bell-meta { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-subtle); margin: -4px 0 10px; }
|
||||
.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; }
|
||||
.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; }
|
||||
@@ -14417,6 +14442,105 @@
|
||||
img.src = url;
|
||||
});
|
||||
|
||||
// #6 — email-capture approval bell (mobile top bar, admin-only). The web "Email Capture"
|
||||
// panel + the Matrix review room both decide the SAME pending proposals; this is a third
|
||||
// surface over the SAME endpoints (GET /api/activity/proposals + POST .../{id}/approve|dismiss),
|
||||
// so sync is automatic: deciding here flips the proposal status and the bot's poll redacts the
|
||||
// Matrix thread; a Matrix/web decision drops the proposal from the pending list our poll reads,
|
||||
// clearing the badge. No new endpoint, no LLM round-trip (edit-then-approve, like the web panel).
|
||||
const MobileEmailBell = ({ token, onShowToast }) => {
|
||||
const [proposals, setProposals] = useState(null); // null = not yet loaded
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selected, setSelected] = useState(null); // the proposal being reviewed
|
||||
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 load = useCallback(async () => {
|
||||
try {
|
||||
const r = await api('/api/activity/proposals', {}, token);
|
||||
setProposals(Array.isArray(r.proposals) ? r.proposals : []);
|
||||
} catch (_) { /* leave the last good count; a transient failure shouldn't blank the bell */ }
|
||||
}, [token]);
|
||||
|
||||
// Poll while mounted so the badge reflects new captures AND decisions made elsewhere
|
||||
// (a proposal approved/dismissed in the Matrix room or the web panel drops out of the
|
||||
// pending list on the next poll, clearing the alert here — the "vice versa" sync).
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 45000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
const count = proposals ? proposals.length : 0;
|
||||
const openSheet = () => { setSelected(null); setOpen(true); load(); };
|
||||
const closeSheet = () => { setOpen(false); setSelected(null); };
|
||||
const openReview = (p) => { setSelected(p); setNoteDraft(p.proposed_note || ''); };
|
||||
|
||||
const decide = async (decision) => {
|
||||
const p = selected; if (!p || busyRef.current) return;
|
||||
if (decision === 'approve' && !noteDraft.trim()) { onShowToast('The note is empty', 'error'); return; }
|
||||
busyRef.current = true; setBusy(true);
|
||||
try {
|
||||
const body = JSON.stringify(decision === 'approve' ? { note: noteDraft } : {});
|
||||
await api(`/api/activity/proposals/${p.id}/${decision}`, { method: 'POST', body }, token);
|
||||
onShowToast(decision === 'approve' ? 'Logged to the grid' : 'Dismissed', 'success');
|
||||
setProposals((ps) => (ps || []).filter((x) => x.id !== p.id));
|
||||
setSelected(null);
|
||||
load(); // reconcile with the server (also catches anything decided in parallel)
|
||||
} catch (err) { onShowToast(getErrorMessage(err, 'Action failed'), 'error'); }
|
||||
finally { setBusy(false); busyRef.current = false; }
|
||||
};
|
||||
|
||||
const dirLabel = (d) => (d === 'sent' ? 'Sent' : 'Received');
|
||||
return (
|
||||
<>
|
||||
<button className={`quicklog-btn bell-btn${count > 0 ? ' has-alerts' : ''}`} onClick={openSheet}
|
||||
aria-label={count ? `Email approvals (${count})` : 'Email approvals'} title="Email approvals">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
</svg>
|
||||
{count > 0 && <span className="bell-badge">{count > 99 ? '99+' : count}</span>}
|
||||
</button>
|
||||
<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-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>}
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Proposed note — edit before approving, or reject</label>
|
||||
<textarea className="sheet-textarea" value={noteDraft} onChange={(e) => setNoteDraft(e.target.value)} rows={6} />
|
||||
</div>
|
||||
<button className="sheet-submit" disabled={busy || !noteDraft.trim()} onClick={() => decide('approve')}>{busy ? 'Saving…' : 'Approve & log to grid'}</button>
|
||||
<button className="sheet-remove" disabled={busy} onClick={() => decide('dismiss')}>Reject</button>
|
||||
<button className="bell-back" type="button" disabled={busy} onClick={() => setSelected(null)}>‹ Back to list</button>
|
||||
</>
|
||||
) : proposals == null ? (
|
||||
<div className="quicklog-empty">Loading…</div>
|
||||
) : proposals.length === 0 ? (
|
||||
<div className="quicklog-empty">No emails waiting to be logged.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="quicklog-hint">Each is a proposed note from captured email. Approve to log it to the grid, or reject.</div>
|
||||
{proposals.map((p) => (
|
||||
<button key={p.id} className="bell-card" onClick={() => openReview(p)}>
|
||||
<div className="bell-card-row1">
|
||||
<span className="bell-card-name">{p.investor_name || 'Unmatched investor'}</span>
|
||||
<span className="bell-card-dir">{dirLabel(p.direction)}</span>
|
||||
</div>
|
||||
<div className="bell-card-subject">{p.email_subject || '(no subject)'}</div>
|
||||
<div className="bell-card-note">{p.summary || p.proposed_note || ''}</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</BottomSheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileCardCapture = ({ token, onShowToast }) => {
|
||||
const fileRef = useRef(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -14718,6 +14842,7 @@
|
||||
const { token, user, logout } = useAuth();
|
||||
const [page, setPage] = useState('fundraising-grid');
|
||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false); // mobile top-bar account popover
|
||||
const isMobile = useIsMobile(); // gate mobile-only top-bar widgets that poll (the email bell)
|
||||
// P6 light theme — single source of truth; the pre-paint boot script (head) already
|
||||
// set documentElement.dataset.theme from localStorage. Dark stays the default.
|
||||
const [theme, setTheme] = useState(() => document.documentElement.dataset.theme === 'light' ? 'light' : 'dark');
|
||||
@@ -15019,6 +15144,9 @@
|
||||
{user?.full_name || user?.username}{MOCK_MODE ? ' · Mock Mode' : ''}
|
||||
</div>
|
||||
<div className="mobile-only" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{/* Email-approval bell — admin-only (the approve/dismiss endpoints are
|
||||
require_admin); mobile-gated so it doesn't poll from the hidden desktop top bar. */}
|
||||
{isMobile && user?.role === 'admin' && <MobileEmailBell token={token} onShowToast={showToast} />}
|
||||
<MobileCardCapture token={token} onShowToast={showToast} />
|
||||
<MobileQuickLog token={token} onShowToast={showToast} />
|
||||
<ThemeToggle theme={theme} onToggle={toggleTheme} variant="icon" />
|
||||
|
||||
Reference in New Issue
Block a user