Mobile Phase 8a+8b: re-author Grid/Contacts cards + Contacts/Pipeline detail bottom sheets

8a — Grid card: existing-LP earmark corner-triangle (replaces left-border), right-side
PRIORITY pill (replaces the rejected star), 4-stage chip, zero-commit dim; detail star ->
"Existing LP" pill. Contacts card: two-letter avatar initials + existing-LP ring + stage pill
+ recency; disposition badge dropped. New backend contact_grid_signals() injects derived
read-only committed/pipeline_stage on GET /api/contacts and /api/contacts/{id} (existing-LP
ring + stage pill); read-only directory, so no strip-point. DESIGN.md §4/§8 reconciled.

8b — Contacts and Pipeline detail surfaces converted from full-screen to drag-dismiss bottom
sheets matching the .dc.html anatomy: Contacts gets an email-copy pill, Log/Email actions, and
an Organization card; Pipeline gets stat tiles, an inline move-stage list, and a notes timeline
+ Log sheet. Both log via POST /api/communications; BottomSheet gains a `stacked` prop to layer
the Log sheet over a detail. Reviewer fixes: cancelled-flag fetch guards (stale-response race),
keyed single-contact signals query, multi-investor dedup test.

All deploy-pending (no s9pk built); not device-tested. 38/38 backend tests green.
This commit is contained in:
Keysat
2026-06-19 21:17:26 -05:00
parent 60d67f6b7d
commit e57b154a6d
5 changed files with 731 additions and 180 deletions
+450 -168
View File
@@ -2106,6 +2106,9 @@
transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bottom-sheet.open { transform: translateY(0); }
/* `stacked` raises a sheet opened OVER another sheet (e.g. the Log sheet over a detail). */
.sheet-scrim.stacked { z-index: 310; }
.bottom-sheet.stacked { z-index: 311; }
.sheet-handle {
width: 38px; height: 4px; border-radius: 2px;
background: var(--border-strong);
@@ -2219,24 +2222,32 @@
box-shadow: 0 14px 26px rgba(2,12,24,0.28), inset 0 1px 0 #ffffff07;
}
.contact-card:active { border-color: var(--border-strong); }
/* Avatar = elevated disc + two-letter mono initials; existing-LP gets an accent ring
(dc ContactsApp:74 — bg var(--elev), border 1px/1.5px var(--accent)). */
.mobile-avatar {
flex: none; width: 38px; height: 38px; border-radius: 50%;
flex: none; width: 40px; height: 40px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
background: var(--accent-soft); color: var(--accent-light);
font-weight: 600; font-size: 15px;
background: var(--bg-panel-elevated); color: var(--accent-light);
border: 1px solid var(--border);
font-family: 'IBM Plex Mono', monospace; font-weight: 600; font-size: 13px;
}
.mobile-avatar.lg { width: 52px; height: 52px; font-size: 20px; }
.contact-card-main { flex: 1; min-width: 0; }
.mobile-avatar.ring { border: 1.5px solid var(--accent); } /* existing LP (committed > 0) */
.mobile-avatar.lg { width: 52px; height: 52px; font-size: 16px; }
.contact-card-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.contact-card-name {
display: block; font-size: var(--mobile-font-card-title); font-weight: 600;
font-size: var(--mobile-font-body); font-weight: 600; line-height: 1.2;
color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.contact-card-sub {
display: block; font-size: 13px; color: var(--text-muted); margin-top: 2px;
.contact-card-sub { display: flex; align-items: center; gap: 8px; min-width: 0; }
.contact-card-org {
font-size: 13px; color: var(--text-muted); min-width: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.contact-card-meta { flex: none; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.contact-card-date { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-subtle); }
.contact-card-recency {
flex: none; font-family: 'IBM Plex Mono', monospace; font-size: 11px; color: var(--text-subtle);
}
.contact-card-recency.recency-aging { color: var(--recency-aging); }
.contact-card-recency.recency-stale { color: var(--recency-stale); }
/* Sort sheet rows (the BottomSheet's first consumer — read-only). */
.sheet-option {
@@ -2311,24 +2322,41 @@
}
.grid-card {
position: relative; display: block; width: 100%; text-align: left; color: inherit;
position: relative; overflow: hidden; display: block; width: 100%; text-align: left; color: inherit;
background: var(--bg-panel); border: 1px solid var(--border);
border-radius: var(--mobile-card-radius);
padding: 12px 14px; margin-bottom: var(--mobile-card-gap); cursor: pointer;
box-shadow: 0 14px 26px rgba(2,12,24,0.28), inset 0 1px 0 #ffffff07;
}
.grid-card:active { border-color: var(--border-strong); }
.grid-card.existing { border-left: 3px solid var(--accent); } /* Existing-Investor = left accent edge */
.grid-card.muted { opacity: 0.55; } /* graveyard rows */
.grid-card-priority { position: absolute; top: 11px; right: 13px; color: var(--badge-priority-text); font-size: 14px; line-height: 1; }
.grid-card-name {
font-size: var(--mobile-font-card-title); font-weight: 600; color: var(--text-primary);
padding-right: 22px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
/* Existing-LP earmark — quiet accent corner-triangle (top-left). Reusable across cards
(Grid now, Pipeline 8f). Clipped to the card's rounded corner by .grid-card overflow:hidden. */
.lp-earmark {
position: absolute; top: 0; left: 0; width: 0; height: 0;
border-top: 18px solid var(--accent); border-right: 18px solid transparent;
}
.grid-card-meta { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
/* PRIORITY text pill — amber, mono, uppercase (dc GridApp:99-101). Reusable (Grid + Pipeline). */
.priority-pill {
flex: none; font-family: 'IBM Plex Mono', monospace; font-size: 10px; font-weight: 600;
letter-spacing: 0.06em; text-transform: uppercase; padding: 3px 7px;
border-radius: 4px; background: var(--badge-priority-bg); color: var(--badge-priority-text);
}
/* "Existing LP" pill — accent-tinted, for detail chip rows (dc GridApp:192-194). */
.lp-pill {
flex: none; font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 600;
letter-spacing: 0.05em; text-transform: uppercase; padding: 3px 8px;
border-radius: 4px; background: var(--accent-soft); color: var(--accent-light);
}
.grid-card-row1 { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.grid-card-name {
flex: 1; min-width: 0; font-size: var(--mobile-font-card-title); font-weight: 600;
color: var(--text-primary); line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.grid-card-row2 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 8px; }
.grid-card-amount { font-family: 'IBM Plex Mono', monospace; font-size: var(--mobile-font-body); font-weight: 600; color: var(--money); flex: none; }
.grid-card-amount.zero { color: var(--text-subtle); }
.grid-card-recency { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-subtle); margin-left: auto; flex: none; white-space: nowrap; }
.grid-card-recency { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-subtle); margin-top: 8px; white-space: nowrap; }
.grid-card-recency.recency-aging { color: var(--recency-aging); }
.grid-card-recency.recency-stale { color: var(--recency-stale); }
.stage-chip {
@@ -2342,9 +2370,110 @@
.stage-chip--engaged { background: var(--chip-engaged-bg); color: var(--chip-engaged-text); border-color: var(--chip-engaged-border); }
.stage-chip--diligence { background: var(--chip-diligence-bg); color: var(--chip-diligence-text); border-color: var(--chip-diligence-border); }
.stage-chip--commitment { background: var(--chip-commitment-bg); color: var(--chip-commitment-text); border-color: var(--chip-commitment-border); }
.stage-chip--sm { font-size: 9px; padding: 2px 7px; flex: none; } /* compact pill for the Contacts card (dc ContactsApp:79) */
/* ─── Phase 8b — detail bottom-sheets (Contacts + Pipeline) ──────────────────────
The detail surfaces render the dc anatomy inside the shared <BottomSheet> primitive
(handle + scrim + drag-dismiss); these classes style the sheet contents. */
.lp-tri { /* inline existing-LP triangle (org card) — the .lp-earmark sibling for non-absolute use */
flex: none; width: 0; height: 0;
border-top: 13px solid var(--accent); border-right: 13px solid transparent;
}
.dsheet-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.dsheet-title { font-size: 19px; font-weight: 600; color: var(--text-primary); line-height: 1.2; }
.dsheet-sub { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
.dsheet-chips { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 7px; }
.dsheet-last { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-subtle); }
.dsheet-last.recency-aging { color: var(--recency-aging); }
.dsheet-last.recency-stale { color: var(--recency-stale); }
.dsheet-avatar-row { display: flex; align-items: center; gap: 13px; }
.sheet-close {
flex: none; background: none; border: none; color: var(--text-muted);
font-size: 22px; line-height: 1; cursor: pointer; padding: 0 4px;
}
/* email-copy pill (Contacts detail) */
.email-pill {
width: 100%; text-align: left; cursor: pointer; margin-top: 14px;
background: var(--bg-input); border: 1px solid var(--border); border-radius: 10px;
padding: 12px 14px; display: flex; align-items: center; justify-content: space-between; gap: 10px;
color: var(--text-primary); font-family: inherit;
}
.email-pill-label { font-family: 'IBM Plex Mono', monospace; font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); display: block; }
.email-pill-addr { font-family: 'IBM Plex Mono', monospace; font-size: 14px; color: var(--accent-light); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; margin-top: 3px; }
.email-pill-copy { flex: none; font-family: 'IBM Plex Mono', monospace; font-size: 11px; color: var(--text-subtle); }
/* detail action buttons (Log / Email) */
.detail-actions { display: flex; gap: 10px; margin-top: 12px; }
.detail-btn {
height: 46px; border-radius: 8px; font-size: 14px; font-weight: 600;
font-family: inherit; cursor: pointer;
}
.detail-btn--primary { flex: 2; border: none; background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%); color: #fff; }
.detail-btn--secondary { flex: 1; border: 1px solid var(--border-strong); background: var(--bg-panel-elevated); color: var(--text-secondary); font-weight: 500; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; }
.detail-btn:disabled { opacity: 0.5; cursor: default; }
/* stat tiles (Pipeline detail) */
.stat-tiles { display: flex; gap: 10px; margin: 6px 0 4px; }
.stat-tile { flex: 1; min-width: 0; background: var(--bg-input); border: 1px solid var(--border); border-radius: 10px; padding: 11px 13px; display: flex; flex-direction: column; gap: 4px; }
.stat-tile-label { font-family: 'IBM Plex Mono', monospace; font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); }
.stat-tile-value { font-family: 'IBM Plex Mono', monospace; font-size: 15px; font-weight: 600; color: var(--money); }
.stat-tile-value.zero { color: var(--text-subtle); }
.stat-tile-value.text { font-family: inherit; font-weight: 400; font-size: 14px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* organization card (Contacts detail) */
.org-card { background: var(--bg-input); border: 1px solid var(--border); border-radius: 10px; padding: 13px 14px; display: flex; flex-direction: column; gap: 11px; }
.org-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.org-card-name { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: 15px; font-weight: 600; color: var(--text-primary); }
.org-card-name span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.org-card-stats { display: flex; align-items: flex-start; justify-content: space-between; }
.org-stat { display: flex; flex-direction: column; gap: 3px; }
.org-stat.right { text-align: right; }
.org-stat-label { font-family: 'IBM Plex Mono', monospace; font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); }
.org-stat-value { font-family: 'IBM Plex Mono', monospace; font-size: 15px; font-weight: 600; color: var(--money); }
.org-stat-value.zero { color: var(--text-subtle); }
.org-stat-value.recency-aging { color: var(--recency-aging); }
.org-stat-value.recency-stale { color: var(--recency-stale); }
.org-card-note { border-top: 1px solid var(--border); padding-top: 10px; display: flex; align-items: center; gap: 8px; min-width: 0; }
.org-card-note-summary { font-size: 13px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.org-open-link { align-self: flex-start; background: none; border: none; padding: 0; cursor: pointer; color: var(--accent-light); font-size: 13px; font-family: inherit; }
/* inline move-stage list (Pipeline detail, P5) */
.move-stage-list { display: flex; flex-direction: column; gap: 8px; }
.move-stage-row {
width: 100%; cursor: pointer; height: 46px; border-radius: 8px;
display: flex; align-items: center; justify-content: space-between; padding: 0 14px;
border: 1px solid var(--border); background: var(--bg-input); font-family: inherit;
}
.move-stage-row.active { border-color: var(--border-strong); background: var(--bg-panel-elevated); }
.move-stage-check { color: var(--accent); font-size: 15px; width: 16px; flex: none; }
/* notes / communication timeline (dot-and-line rail) */
.note-timeline { display: flex; flex-direction: column; }
.note-entry { display: flex; gap: 11px; padding-bottom: 14px; }
.note-rail { flex: none; display: flex; flex-direction: column; align-items: center; gap: 4px; }
.note-dot { width: 9px; height: 9px; border-radius: 999px; background: var(--accent); margin-top: 4px; }
.note-line { flex: 1; width: 1px; background: var(--border); }
.note-entry:last-child .note-line { display: none; }
.note-content { flex: 1; min-width: 0; }
.note-head { display: flex; align-items: center; gap: 8px; }
.note-tag { font-family: 'IBM Plex Mono', monospace; font-size: 10px; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; padding: 2px 6px; border-radius: 4px; background: var(--chip-default-bg); color: var(--chip-default-text); }
.note-tag--email { background: var(--chip-engaged-bg); color: var(--chip-engaged-text); }
.note-tag--call { background: var(--chip-commitment-bg); color: var(--chip-commitment-text); }
.note-tag--meeting { background: var(--badge-priority-bg); color: var(--badge-priority-text); }
.note-date { font-family: 'IBM Plex Mono', monospace; font-size: 11px; color: var(--text-subtle); }
.note-summary { font-size: 14px; color: var(--text-secondary); margin-top: 6px; line-height: 1.45; }
.note-empty { font-size: 13px; color: var(--text-subtle); padding-bottom: 6px; }
.sheet-section-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 18px 0 9px; }
.sheet-log-btn { background: var(--bg-panel-elevated); border: 1px solid var(--border); border-radius: 6px; padding: 7px 12px; cursor: pointer; color: var(--accent-light); font-size: 13px; font-family: inherit; min-height: 36px; }
.sheet-subcaption { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-subtle); margin: -4px 0 14px; }
.sheet-footnote { font-size: 12px; color: var(--text-subtle); margin-top: 14px; line-height: 1.45; }
/* log-type chooser (Log sheet) */
.log-type-row { display: flex; gap: 8px; }
.log-type-btn {
flex: 1; height: 42px; border-radius: 7px; cursor: pointer;
font-family: 'IBM Plex Mono', monospace; font-size: 12px; font-weight: 600;
letter-spacing: 0.04em; text-transform: uppercase;
border: 1px solid var(--border); background: var(--bg-input); color: var(--text-muted);
}
.log-type-btn.active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-light); }
/* Full-screen detail: read-only sections + edit-entry buttons. */
.fs-detail-star { color: var(--accent); font-size: 18px; margin-right: 8px; }
.fs-detail-chips { flex: none; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.fs-action-row { display: flex; gap: 8px; flex-wrap: wrap; }
.fs-action-btn {
flex: 1; min-width: 140px; min-height: var(--mobile-touch-target);
@@ -3811,7 +3940,7 @@
// Drag-to-dismiss bottom sheet — replaces the centered modal + right slide-over on mobile
// (DESIGN §4/§8). Styling is the Phase-1 .bottom-sheet/.sheet-scrim/.sheet-handle CSS; this
// adds the mount/enter-exit animation, scrim/Escape dismiss, and pointer drag-down close.
const BottomSheet = ({ open, onClose, title, children }) => {
const BottomSheet = ({ open, onClose, title, children, stacked }) => {
const [mounted, setMounted] = useState(open);
const [shown, setShown] = useState(false);
const [dragY, setDragY] = useState(0);
@@ -3859,8 +3988,8 @@
return (
<>
<div className={`sheet-scrim ${shown ? 'open' : ''}`} onClick={onClose} />
<div className={`bottom-sheet ${shown ? 'open' : ''}`} style={sheetStyle} role="dialog" aria-modal="true">
<div className={`sheet-scrim ${stacked ? 'stacked' : ''} ${shown ? 'open' : ''}`} onClick={onClose} />
<div className={`bottom-sheet ${stacked ? 'stacked' : ''} ${shown ? 'open' : ''}`} style={sheetStyle} role="dialog" aria-modal="true">
<div
className="sheet-grab"
onPointerDown={onPointerDown}
@@ -3903,10 +4032,6 @@
);
};
const contactTypeBadgeClass = (type) => ({
investor: 'badge-investor', prospect: 'badge-prospect',
advisor: 'badge-advisor', other: 'badge-other'
}[type] || 'badge-other');
/* ─── Shared grid helpers (Phase 3 — mobile Fundraising Grid) ─────────────────────
Pure functions so the mobile card list filters rows the SAME way the desktop grid's
@@ -3974,17 +4099,103 @@
};
// Pipeline-stage chip — colors via .stage-chip--{stage} CSS vars (DESIGN §2), mono-uppercase on mobile.
const StageChip = ({ stage }) => {
// `sm` renders the compact variant used on the Contacts card (dc ContactsApp:79).
const StageChip = ({ stage, sm }) => {
const s = String(stage || '');
if (!s) return null;
// Colors live in .stage-chip--{stage} (theme-bound CSS vars) so chips flip with [data-theme].
return (
<span className={`stage-chip stage-chip--${s}`}>
<span className={`stage-chip stage-chip--${s}${sm ? ' stage-chip--sm' : ''}`}>
{pipelineStageLabel(s)}
</span>
);
};
// Existing-LP earmark — a quiet accent corner-triangle (top-left), the locked existing-investor
// signal on cards (dc GridApp:89-91). Reusable: the Grid card now, the Pipeline card (8f). Render
// only when the investor has committed capital (existing_investor / committed > 0).
const EarmarkCorner = ({ inline }) => (
<span className={inline ? 'lp-tri' : 'lp-earmark'} title="Existing investor" aria-hidden="true" />
);
// Two-letter initials (first + last name; fall back to the email) for the Contacts avatar.
const contactInitials = (c) => {
const f = String(c.first_name || '').trim();
const l = String(c.last_name || '').trim();
const fromName = ((f[0] || '') + (l[0] || '')).toUpperCase();
if (fromName) return fromName;
return (String(c.email || '').trim().slice(0, 2) || '?').toUpperCase();
};
// Days-based recency class for surfaces that have a last-contact date but no server-injected
// `staleness` (the Contacts directory). Mirrors the grid's thresholds (aging >=10, stale >=30).
const recencyClassForDays = (days) => (days == null ? '' : days >= 30 ? 'recency-stale' : days >= 10 ? 'recency-aging' : '');
// Short last-contact phrase for detail sheets ("Last contact 3d ago" / "No recorded contact").
const recencyText = (iso) => {
const days = daysSince(iso);
if (days == null) return 'No recorded contact';
return days <= 0 ? 'today' : formatAgeShort(days) + ' ago';
};
// Communication-type tag color (Email/Call/Meeting → themed chip vars; everything else neutral).
const noteTagClass = (type) => {
const t = String(type || '').toLowerCase();
return t === 'email' ? 'note-tag--email' : t === 'call' ? 'note-tag--call' : t === 'meeting' ? 'note-tag--meeting' : '';
};
// Notes/communication timeline — dot-and-line rail with a type tag + date + summary per entry
// (dc PipelineApp:247-266). Read-only display; the "+ Log" action lives on the host sheet.
const NoteTimeline = ({ comms }) => {
if (!comms || comms.length === 0) return <div className="note-empty">No activity logged yet.</div>;
return (
<div className="note-timeline">
{comms.map((cm) => (
<div className="note-entry" key={cm.id}>
<div className="note-rail"><span className="note-dot" /><span className="note-line" /></div>
<div className="note-content">
<div className="note-head">
<span className={`note-tag ${noteTagClass(cm.type)}`}>{cm.type || 'note'}</span>
<span className="note-date">{formatDate(cm.communication_date)}</span>
</div>
<div className="note-summary">{cm.subject || cm.body || '—'}</div>
</div>
</div>
))}
</div>
);
};
// Log-communication sheet — type chooser + summary + details (dc ContactsApp:182 / PipelineApp:274).
// Writes through onSubmit({type, subject, body}); the host owns the POST /api/communications call.
const LOG_TYPES = ['note', 'call', 'email', 'meeting'];
const LogCommunicationSheet = ({ open, onClose, onSubmit, busy, forLabel }) => {
const [type, setType] = useState('note');
const [summary, setSummary] = useState('');
const [details, setDetails] = useState('');
useEffect(() => { if (open) { setType('note'); setSummary(''); setDetails(''); } }, [open]);
const disabled = busy || !(summary.trim() || details.trim());
return (
<BottomSheet open={open} onClose={onClose} title="Log communication" stacked>
{forLabel && <div className="sheet-subcaption">{forLabel}</div>}
<label className="sheet-field-label">Type</label>
<div className="log-type-row">
{LOG_TYPES.map((t) => (
<button key={t} type="button" className={`log-type-btn ${type === t ? 'active' : ''}`} onClick={() => setType(t)}>{t}</button>
))}
</div>
<div className="sheet-field" style={{ marginTop: '16px' }}>
<label className="sheet-field-label">Summary</label>
<input className="sheet-input" value={summary} onChange={(e) => setSummary(e.target.value)} placeholder="Short headline" />
</div>
<div className="sheet-field">
<label className="sheet-field-label">Details</label>
<textarea className="sheet-textarea" value={details} onChange={(e) => setDetails(e.target.value)} placeholder="Full context kept in communications history" />
</div>
<button className="sheet-submit" onClick={() => onSubmit({ type, subject: summary.trim(), body: details.trim() })} disabled={disabled}>
{busy ? 'Logging…' : 'Log it'}
</button>
</BottomSheet>
);
};
const LoginPage = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -5044,101 +5255,116 @@
{ id: 'recent', label: 'Recently contacted', short: 'Recent' },
];
const MobileContactDetail = ({ contact, token, onClose, onShowToast }) => {
// Contacts detail — a drag-dismiss bottom sheet (8b / dc ContactsApp:118-179). Identity +
// an email-copy pill + Log/Email actions + an Organization card (earmark · stage · committed ·
// last-contact · last-note · open-in-Grid). committed/stage/last-contact come from the enriched
// list `contact`; the comms (for the last-note + the log refresh) come from /api/contacts/{id}.
const MobileContactDetail = ({ contact, token, onClose, onShowToast, onNavigate }) => {
const [details, setDetails] = useState(null);
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState(true);
const [logOpen, setLogOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [reload, setReload] = useState(0);
// `cancelled` drops a stale response (defensive — the parent remounts per selection);
// `reload` re-fetches the comms after a log write.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const r = await api(`/api/contacts/${contact.id}`, {}, token);
if (!cancelled) setDetails(r.data);
} catch (err) {
if (!cancelled) onShowToast(getErrorMessage(err, 'Failed to load contact'), 'error');
} finally {
if (!cancelled) setLoading(false);
}
} catch (err) { if (!cancelled) onShowToast(getErrorMessage(err, 'Failed to load contact'), 'error'); }
})();
return () => { cancelled = true; };
}, [contact.id, token]);
}, [contact.id, token, reload]);
const base = details || contact;
const name = `${base.first_name || ''} ${base.last_name || ''}`.trim() || base.email || 'Contact';
const initial = (base.last_name || base.first_name || name || '?').charAt(0).toUpperCase();
const org = base.organization || base.organization_name || '';
const type = base.contact_type || 'other';
const location = details
? ([details.city, details.state, details.country].filter(Boolean).join(', ') || details.location_query || '')
: '';
// Local open-state drives the slide-out before the parent unmounts us.
const close = () => { setOpen(false); setTimeout(onClose, 280); };
const name = `${contact.first_name || ''} ${contact.last_name || ''}`.trim() || contact.email || 'Contact';
const org = contact.organization || contact.organization_name || '';
const existing = Number(contact.committed || 0) > 0;
const stage = contact.pipeline_stage;
const recCls = recencyClassForDays(daysSince(contact.last_contact_date));
const lastNote = details && Array.isArray(details.communications) ? details.communications[0] : null;
const copyEmail = () => {
if (!contact.email) return;
try { if (navigator.clipboard) { navigator.clipboard.writeText(contact.email); onShowToast('Email copied', 'success'); } } catch (_) {}
};
const submitLog = async ({ type, subject, body }) => {
setBusy(true);
try {
await api('/api/communications', { method: 'POST', body: JSON.stringify({ contact_id: contact.id, type, subject, body }) }, token);
onShowToast('Communication logged', 'success');
setLogOpen(false);
setReload((n) => n + 1);
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to log communication'), 'error'); }
finally { setBusy(false); }
};
return (
<div className="fs-detail" role="dialog" aria-modal="true">
<div className="fs-detail-header">
<button className="fs-detail-back" onClick={onClose}> Contacts</button>
<BottomSheet open={open} onClose={close}>
<div className="dsheet-avatar-row">
<span className={`mobile-avatar lg${existing ? ' ring' : ''}`}>{contactInitials(contact)}</span>
<span style={{ minWidth: 0, flex: 1 }}>
<div className="dsheet-title">{name}</div>
<div className="dsheet-sub">{org || '—'}</div>
</span>
</div>
<div className="fs-detail-body">
<div className="fs-detail-id">
<span className="mobile-avatar lg">{initial}</span>
<span style={{ minWidth: 0, flex: 1 }}>
<div className="fs-detail-title">{name}</div>
<div className="fs-detail-subtitle">{org || '—'}</div>
{contact.email && (
<button className="email-pill" onClick={copyEmail}>
<span style={{ minWidth: 0 }}>
<span className="email-pill-label">Email</span>
<span className="email-pill-addr">{contact.email}</span>
</span>
<span className={`badge ${contactTypeBadgeClass(type)}`}>{type}</span>
</div>
<span className="email-pill-copy">copy</span>
</button>
)}
{loading ? <SkeletonBlock lines={6} /> : (
<>
<div className="fs-section">
<div className="fs-section-label">Contact</div>
<MobileDetailRow label="Email" value={base.email} mono copyable onShowToast={onShowToast} />
<MobileDetailRow label="Phone" value={base.phone} mono />
<MobileDetailRow label="Title" value={base.title} />
<MobileDetailRow label="Organization" value={org} />
<MobileDetailRow label="Lead Source" value={base.source} />
<MobileDetailRow label="LinkedIn" value={base.linkedin_url} />
<MobileDetailRow label="Location" value={location} />
</div>
{details && details.opportunities && details.opportunities.length > 0 && (
<div className="fs-section">
<div className="fs-section-label">Opportunities</div>
{details.opportunities.map((o) => (
<div className="fs-row" key={o.id}>
<span className="fs-row-label">{o.name}</span>
<span className="fs-row-value mono">{o.stage} · {formatCurrencyLong(o.expected_amount)}</span>
</div>
))}
</div>
)}
<div className="fs-section">
<div className="fs-section-label">Communication History</div>
{details && details.communications && details.communications.length > 0 ? (
<div className="timeline">
{details.communications.map((cm) => (
<div key={cm.id} className="timeline-item">
<div className="timeline-marker"></div>
<div className="timeline-content">
<div className="timeline-header">{cm.type}</div>
<div className="timeline-meta">{formatDate(cm.communication_date)}</div>
{cm.subject && <div className="timeline-body">{cm.subject}</div>}
</div>
</div>
))}
</div>
) : (
<div style={{ color: 'var(--text-subtle)', fontSize: '13px' }}>No communications logged.</div>
)}
</div>
</>
)}
<div className="detail-actions">
<button className="detail-btn detail-btn--primary" onClick={() => setLogOpen(true)}>Log communication</button>
{contact.email
? <a className="detail-btn detail-btn--secondary" href={`mailto:${contact.email}`}>Email</a>
: <button className="detail-btn detail-btn--secondary" disabled>Email</button>}
</div>
</div>
<div className="fs-section-label" style={{ margin: '20px 0 9px' }}>Organization</div>
<div className="org-card">
<div className="org-card-head">
<span className="org-card-name">
{existing && <EarmarkCorner inline />}
<span>{org || '—'}</span>
</span>
{stage && <StageChip stage={stage} sm />}
</div>
<div className="org-card-stats">
<span className="org-stat">
<span className="org-stat-label">Committed</span>
<span className={`org-stat-value${existing ? '' : ' zero'}`}>{formatMoneyMobile(contact.committed || 0)}</span>
</span>
<span className="org-stat right">
<span className="org-stat-label">Last contact</span>
<span className={`org-stat-value ${recCls}`}>{recencyText(contact.last_contact_date)}</span>
</span>
</div>
{lastNote && (
<div className="org-card-note">
<span className={`note-tag ${noteTagClass(lastNote.type)}`}>{lastNote.type || 'note'}</span>
<span className="org-card-note-summary">{lastNote.subject || lastNote.body || '—'}</span>
</div>
)}
{onNavigate && <button className="org-open-link" onClick={() => { onNavigate('fundraising-grid'); close(); }}>Open investor in Grid </button>}
</div>
<LogCommunicationSheet open={logOpen} onClose={() => setLogOpen(false)} onSubmit={submitLog} busy={busy} forLabel={name} />
</BottomSheet>
);
};
const MobileContactsPage = ({ token, onShowToast }) => {
const MobileContactsPage = ({ token, onShowToast, onNavigate }) => {
const [contacts, setContacts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -5207,19 +5433,25 @@
const renderCard = (c) => {
const org = c.organization || c.organization_name || '';
const type = c.contact_type || 'other';
const initial = (sortBasis(c).charAt(0) || displayName(c).charAt(0) || '?').toUpperCase();
// Existing-LP ring + stage pill come from the grid signals the API injects (committed,
// pipeline_stage); a pure classic contact (no grid link) has committed 0 / stage null.
const existing = Number(c.committed || 0) > 0;
const days = daysSince(c.last_contact_date);
return (
<button className="contact-card" key={c.id} onClick={() => setSelected(c)}>
<span className="mobile-avatar">{initial}</span>
<span className={`mobile-avatar${existing ? ' ring' : ''}`}>{contactInitials(c)}</span>
<span className="contact-card-main">
<span className="contact-card-name">{displayName(c)}</span>
<span className="contact-card-sub">{org || '—'}</span>
</span>
<span className="contact-card-meta">
<span className={`badge ${contactTypeBadgeClass(type)}`}>{type}</span>
{c.last_contact_date && <span className="contact-card-date">{formatDate(c.last_contact_date)}</span>}
<span className="contact-card-sub">
<span className="contact-card-org">{org || '—'}</span>
{c.pipeline_stage && <StageChip stage={c.pipeline_stage} sm />}
</span>
</span>
{c.last_contact_date && (
<span className={`contact-card-recency ${recencyClassForDays(days)}`}>
{days == null ? '' : formatAgeShort(days) + (days <= 0 ? '' : ' ago')}
</span>
)}
</button>
);
};
@@ -5286,6 +5518,7 @@
token={token}
onClose={() => setSelected(null)}
onShowToast={onShowToast}
onNavigate={onNavigate}
/>
)}
</div>
@@ -5843,7 +6076,9 @@
const [error, setError] = useState('');
const [activeStage, setActiveStage] = useState(0);
const [selectedId, setSelectedId] = useState(null);
const [sheetOpen, setSheetOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [logOpen, setLogOpen] = useState(false);
const [contactDetail, setContactDetail] = useState(null); // /api/contacts/{contact_id} for the open opp
const [busy, setBusy] = useState(false);
const swipeRef = useRef(null);
@@ -5877,6 +6112,27 @@
const selectedOpp = useMemo(() => opportunities.find((o) => o.id === selectedId) || null, [opportunities, selectedId]);
const openDetail = (oppId) => { setSelectedId(oppId); setContactDetail(null); setDetailOpen(true); };
const closeDetail = () => { setDetailOpen(false); setLogOpen(false); setTimeout(() => setSelectedId(null), 280); };
// Pull the linked contact's communications (+ committed) for the detail sheet's
// notes timeline / stat tiles. Non-fatal on failure — the opp fields still render.
// A `cancelled` guard drops a stale response so rapidly opening card A then B can't
// leave B's sheet showing A's data; oppReload re-runs it after a log write.
const oppContactId = selectedOpp && selectedOpp.contact_id;
const [oppReload, setOppReload] = useState(0);
useEffect(() => {
if (!oppContactId) { setContactDetail(null); return undefined; }
let cancelled = false;
(async () => {
try {
const r = await api(`/api/contacts/${oppContactId}`, {}, token);
if (!cancelled) setContactDetail(r.data);
} catch (_) { /* leave null; tiles fall back to opp fields */ }
})();
return () => { cancelled = true; };
}, [oppContactId, token, oppReload]);
const patchStage = async (oppId, stage) => {
setBusy(true);
try {
@@ -5890,6 +6146,23 @@
} finally { setBusy(false); }
};
// Log a communication against the open opp's contact (POST /api/communications), then
// refresh the timeline. Same write the Contacts detail uses; carries opportunity_id here.
const submitLog = async ({ type, subject, body }) => {
const opp = selectedOpp; if (!opp) return;
if (!opp.contact_id) { onShowToast('This deal has no linked contact to log against', 'error'); return; }
setBusy(true);
try {
await api('/api/communications', { method: 'POST', body: JSON.stringify({
contact_id: opp.contact_id, opportunity_id: opp.id, type, subject, body,
}) }, token);
onShowToast('Communication logged', 'success');
setLogOpen(false);
setOppReload((n) => n + 1);
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to log communication'), 'error'); }
finally { setBusy(false); }
};
// / on a card: advance/retreat one stage (kanban move without opening the detail).
const moveStage = async (opp, dir) => {
if (busy) return;
@@ -5919,7 +6192,7 @@
const sub = [contactName(opp), opp.organization_name].filter((x) => x && x !== '-').join(' · ');
return (
<div className="pipeline-card" key={opp.id}>
<button className="pipeline-card-tap" onClick={() => setSelectedId(opp.id)}>
<button className="pipeline-card-tap" onClick={() => openDetail(opp.id)}>
<div className="pipeline-card-name">{opp.name}</div>
{sub && <div className="pipeline-card-sub">{sub}</div>}
<div className={`pipeline-card-amount${amount > 0 ? '' : ' zero'}`}>{formatCurrencyLong(amount)}</div>
@@ -5982,58 +6255,64 @@
const prob = (Number(opp.probability) || 0) > 1
? `${opp.probability}%`
: `${Math.round((Number(opp.probability) || 0) * 100)}%`;
const committed = Number((contactDetail && contactDetail.committed) || 0);
const existing = committed > 0;
const comms = (contactDetail && contactDetail.communications) || [];
const lastTs = comms[0] && comms[0].communication_date;
const lastCls = recencyClassForDays(daysSince(lastTs));
const contactLine = contactName(opp) === '-' ? (opp.organization_name || '—') : contactName(opp);
const dealParts = [
opp.expected_amount ? `${formatCurrencyLong(opp.expected_amount)} expected · ${prob}` : '',
opp.fund_name, opp.owner_name,
opp.expected_close_date ? `close ${formatDateLong(opp.expected_close_date)}` : '',
].filter(Boolean).join(' · ');
return (
<div className="fs-detail" role="dialog" aria-modal="true">
<div className="fs-detail-header">
<button className="fs-detail-back" onClick={() => { setSelectedId(null); setSheetOpen(false); }}> Pipeline</button>
<BottomSheet open={detailOpen} onClose={closeDetail}>
<div className="dsheet-head">
<span style={{ minWidth: 0 }}>
<div className="dsheet-title">{opp.name}</div>
<div className="dsheet-chips">
{opp.priority === 'high' && <span className="priority-pill">Priority</span>}
{existing && <span className="lp-pill">Existing LP</span>}
<span className={`dsheet-last ${lastCls}`}>Last contact {recencyText(lastTs)}</span>
</div>
</span>
<button className="sheet-close" aria-label="Close" onClick={closeDetail}>×</button>
</div>
<div className="fs-detail-body">
<div className="fs-detail-id">
<span style={{ minWidth: 0, flex: 1 }}>
<div className="fs-detail-title">{opp.name}</div>
<div className="fs-detail-subtitle">{formatCurrencyLong(opp.expected_amount)} expected · {prob}</div>
</span>
{opp.priority === 'high' && <span className="badge" style={{ background: 'var(--badge-priority-bg)', color: 'var(--badge-priority-text)' }}>Priority</span>}
</div>
<div className="fs-section">
<div className="fs-section-label">Pipeline</div>
<div className="fs-row">
<span className="fs-row-label">Stage</span>
<span className="fs-row-value"><StageChip stage={opp.stage} /></span>
</div>
<div className="fs-action-row" style={{ marginTop: '10px' }}>
<button className="fs-action-btn" onClick={() => setSheetOpen(true)}>Change stage</button>
</div>
<div className="stat-tiles">
<div className="stat-tile">
<span className="stat-tile-label">Committed</span>
<span className={`stat-tile-value${existing ? '' : ' zero'}`}>{formatMoneyMobile(committed)}</span>
</div>
<div className="fs-section">
<div className="fs-section-label">Deal</div>
<MobileDetailRow label="Contact" value={contactName(opp) === '-' ? '' : contactName(opp)} />
<MobileDetailRow label="Organization" value={opp.organization_name} />
<MobileDetailRow label="Expected amount" value={formatCurrencyLong(opp.expected_amount)} mono />
<MobileDetailRow label="Probability" value={prob} mono />
<MobileDetailRow label="Fund" value={opp.fund_name} />
<MobileDetailRow label="Expected close" value={formatDateLong(opp.expected_close_date)} mono />
<MobileDetailRow label="Owner" value={opp.owner_name} />
<div style={{ fontSize: '12px', color: 'var(--text-subtle)', marginTop: '8px' }}>Amounts are read-only on mobile — edit on desktop.</div>
<div className="stat-tile">
<span className="stat-tile-label">Contacts</span>
<span className="stat-tile-value text">{contactLine}</span>
</div>
</div>
<BottomSheet open={sheetOpen} onClose={() => setSheetOpen(false)} title="Pipeline stage">
<div className="fs-section-label" style={{ margin: '18px 0 9px' }}>Move stage</div>
<div className="move-stage-list">
{stages.map((st) => (
<button
key={st}
className={`sheet-option ${opp.stage === st ? 'active' : ''}`}
disabled={busy}
onClick={async () => { if (await patchStage(opp.id, st)) setSheetOpen(false); }}
>
<span>{pipelineStageLabel(st)}</span>
{opp.stage === st && <span className="sheet-option-check"></span>}
<button key={st} className={`move-stage-row ${opp.stage === st ? 'active' : ''}`} disabled={busy}
onClick={async () => { await patchStage(opp.id, st); }}>
<StageChip stage={st} />
<span className="move-stage-check">{opp.stage === st ? '✓' : ''}</span>
</button>
))}
</BottomSheet>
</div>
</div>
<div className="sheet-section-head">
<span className="fs-section-label" style={{ margin: 0 }}>Notes / communication</span>
<button className="sheet-log-btn" onClick={() => setLogOpen(true)}>+ Log</button>
</div>
<NoteTimeline comms={comms} />
{dealParts && <div className="sheet-footnote">{dealParts}</div>}
<div className="sheet-footnote">Stage moves and logged communications both write the shared opportunities row — the same data the Grid edits. Amounts stay read-only on mobile.</div>
<LogCommunicationSheet open={logOpen} onClose={() => setLogOpen(false)} onSubmit={submitLog} busy={busy} forLabel={opp.name} />
</BottomSheet>
);
})()}
</div>
@@ -9656,17 +9935,20 @@
const committed = gridRollup(row, fundColumnIds);
const days = daysSince(row.last_activity_at);
const recencyCls = row.staleness === 'stale' ? 'recency-stale' : row.staleness === 'aging' ? 'recency-aging' : '';
const cls = `grid-card${row.existing_investor ? ' existing' : ''}${row.graveyard ? ' muted' : ''}`;
const cls = `grid-card${row.graveyard ? ' muted' : ''}`;
return (
<button className={cls} key={row.id} onClick={() => setSelectedId(row.id)}>
{row.priority && <span className="grid-card-priority" title="Priority"></span>}
<div className="grid-card-name">{row.investor_name || 'Unnamed investor'}</div>
<div className="grid-card-meta">
{row.existing_investor && <EarmarkCorner />}
<div className="grid-card-row1">
<span className="grid-card-name">{row.investor_name || 'Unnamed investor'}</span>
{row.priority && <span className="priority-pill">Priority</span>}
</div>
<div className="grid-card-row2">
<span className={`grid-card-amount${committed > 0 ? '' : ' zero'}`}>{formatMoneyMobile(committed)}</span>
{row.pipeline && <StageChip stage={row.pipeline_stage} />}
<span className={`grid-card-recency ${recencyCls}`}>
{days == null ? 'no activity' : formatAgeShort(days) + (days <= 0 ? '' : ' ago')}{row.staleness === 'stale' ? ' · stale' : ''}
</span>
</div>
<div className={`grid-card-recency ${recencyCls}`}>
{days == null ? 'no activity' : formatAgeShort(days) + (days <= 0 ? '' : ' ago')}{row.staleness === 'stale' ? ' · stale' : ''}
</div>
</button>
);
@@ -9750,13 +10032,13 @@
<div className="fs-detail-body">
<div className="fs-detail-id">
<span style={{ minWidth: 0, flex: 1 }}>
<div className="fs-detail-title">
{row.existing_investor && <span className="fs-detail-star" title="Existing investor"></span>}
{row.investor_name || 'Unnamed investor'}
</div>
<div className="fs-detail-title">{row.investor_name || 'Unnamed investor'}</div>
<div className="fs-detail-subtitle">{formatMoneyMobile(committed)} committed{row.lead ? ` · ${row.lead}` : ''}</div>
</span>
{row.priority && <span className="badge" style={{ background: 'var(--badge-priority-bg)', color: 'var(--badge-priority-text)' }}>Priority</span>}
<span className="fs-detail-chips">
{row.priority && <span className="priority-pill">Priority</span>}
{row.existing_investor && <span className="lp-pill">Existing LP</span>}
</span>
</div>
<div className="fs-section">
@@ -13594,7 +13876,7 @@
/>
)}
{page === 'dashboard' && <DashboardPage token={token} />}
{page === 'contacts' && <ContactsPage token={token} onShowToast={showToast} />}
{page === 'contacts' && <ContactsPage token={token} onShowToast={showToast} onNavigate={setPage} />}
{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} />}