Mobile Phase 8d: sort controls across Grid, Pipeline, Contacts

Add a shared SortPill + SortSheet (label+hint option rows) and per-surface sort
tables:
- Grid: Name / Pipeline stage / Committed / Last contact / Priority, applied in
  the displayed memo (name is the tiebreak; staleness ranks longest-since-contact
  first, no-activity treated as most stale; committed uses the fund rollup).
- Pipeline: Name / Amount / Last activity / Priority, sorted within each stage.
  "Last activity" uses opp.updated_at as a recency proxy until the Pipeline card
  wires true last-contact recency (8f).
- Contacts: drop the investor/prospect type tabs (the prospect type is unused);
  add a Priority sort alongside Name A-Z/Z-A and Last contact.

contact_grid_signals() now also surfaces the linked investor's priority flag,
injected on both contact read paths (same derive-on-read contract as committed /
pipeline_stage), powering the Contacts Priority sort. Extended
test_contacts_grid_signals.py covers it; 39/39 backend green.
This commit is contained in:
Keysat
2026-06-19 22:06:14 -05:00
parent 93ac0c240f
commit 42c169559c
4 changed files with 165 additions and 56 deletions
+128 -40
View File
@@ -2198,11 +2198,27 @@
.mobile-seg-tab.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-light); }
.mobile-sortbar { display: flex; justify-content: space-between; align-items: center; }
.mobile-count { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-muted); }
.mobile-sort-btn {
background: transparent; border: none; color: var(--text-muted);
font-size: 13px; font-family: inherit; cursor: pointer; padding: 6px 2px;
display: inline-flex; align-items: center; gap: 6px;
/* Shared sort control (8d): a mono uppercase pill (dc GridApp:72) + a label+hint option sheet. */
.sort-pill {
flex: none; display: inline-flex; align-items: center; gap: 6px;
height: 30px; padding: 0 12px; border-radius: 999px;
border: 1px solid var(--border); background: var(--bg-input); color: var(--text-secondary);
font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 600;
letter-spacing: 0.04em; text-transform: uppercase; cursor: pointer;
}
.sort-pill:active { background: var(--bg-hover); }
.sort-list { display: flex; flex-direction: column; gap: 8px; }
.sort-row {
width: 100%; text-align: left; cursor: pointer; font-family: inherit;
display: flex; align-items: center; justify-content: space-between; gap: 10px;
min-height: 52px; padding: 0 15px; border-radius: 10px;
border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary);
}
.sort-row.active { border-color: var(--border-strong); background: var(--bg-panel-elevated); }
.sort-row-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.sort-row-label { font-size: 15px; font-weight: 500; color: var(--text-primary); }
.sort-row-hint { font-family: 'IBM Plex Mono', monospace; font-size: 11px; color: var(--text-subtle); }
.sort-row-check { flex: none; color: var(--accent); font-size: 15px; }
/* AZ directory: sticky letter headers over a card list. */
.az-header {
@@ -4226,6 +4242,52 @@
);
};
// Shared sort control (8d, dc GridApp:72 / PipelineApp:64) — a mono uppercase pill with a
// sort glyph + the active sort's short label, opening a sort sheet. Reused by Grid / Pipeline
// / Contacts. Sort-option lists carry {id, pill (short label), label + hint (sheet rows)}.
const SortPill = ({ label, onClick }) => (
<button type="button" className="sort-pill" onClick={onClick} aria-label="Sort">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h11M3 12h7M3 18h4" /><path d="M18 8v9m0 0 3-3m-3 3-3-3" />
</svg>
{label}
</button>
);
const SortSheet = ({ open, onClose, title, options, value, onPick }) => (
<BottomSheet open={open} onClose={onClose} title={title}>
<div className="sort-list">
{options.map((o) => (
<button key={o.id} type="button" className={`sort-row ${value === o.id ? 'active' : ''}`}
onClick={() => { onPick(o.id); onClose(); }}>
<span className="sort-row-main">
<span className="sort-row-label">{o.label}</span>
{o.hint && <span className="sort-row-hint">{o.hint}</span>}
</span>
{value === o.id && <span className="sort-row-check"></span>}
</button>
))}
</div>
</BottomSheet>
);
const sortPillLabel = (options, value) => ((options.find((o) => o.id === value) || options[0] || {}).pill || '');
// Grid sort keys (dc GridApp:632 opts + sortList). Comparators live in the surface.
const GRID_SORTS = [
{ id: 'name', pill: 'Name', label: 'Name', hint: 'A → Z' },
{ id: 'stage', pill: 'Stage', label: 'Pipeline stage', hint: 'Lead → Commitment' },
{ id: 'committed', pill: 'Committed', label: 'Committed', hint: 'Most first' },
{ id: 'staleness', pill: 'Staleness', label: 'Last contact', hint: 'Most stale first' },
{ id: 'priority', pill: 'Priority', label: 'Priority', hint: 'Flagged first' },
];
// Pipeline sorts within a stage (dc PipelineApp:580). "Staleness" uses the opp's updated_at as
// an activity proxy until the real last-contact recency lands on the Pipeline card (8f).
const PIPELINE_SORTS = [
{ id: 'name', pill: 'Name', label: 'Name', hint: 'A → Z' },
{ id: 'amount', pill: 'Amount', label: 'Committed', hint: 'Most first' },
{ id: 'staleness', pill: 'Staleness', label: 'Last activity', hint: 'Most stale first' },
{ id: 'priority', pill: 'Priority', label: 'Priority', hint: 'Flagged first' },
];
const LoginPage = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -5279,10 +5341,13 @@
// Phase 2 of the mobile-first redesign — the lowest-risk surface, validating the
// list→detail→sheet pattern + the shared BottomSheet/useIsMobile before the Grid (Phase 3).
// Contacts is READ-ONLY on mobile (BRIEF §3b): create/edit live on the Grid, never here.
// Contacts sorts (8d). dc Contacts was search-only; the Priority sort is a Grant-decided
// enhancement (the type tabs were dropped). `priority` rides the grid signal the API injects.
const SORT_OPTIONS = [
{ id: 'name-asc', label: 'Name (AZ)', short: 'AZ' },
{ id: 'name-desc', label: 'Name (ZA)', short: 'ZA' },
{ id: 'recent', label: 'Recently contacted', short: 'Recent' },
{ id: 'name-asc', pill: 'AZ', label: 'Name', hint: 'AZ' },
{ id: 'name-desc', pill: 'ZA', label: 'Name', hint: 'ZA' },
{ id: 'recent', pill: 'Recent', label: 'Last contact', hint: 'Recent first' },
{ id: 'priority', pill: 'Priority', label: 'Priority', hint: 'Flagged first' },
];
// Contacts detail — a drag-dismiss bottom sheet (8b / dc ContactsApp:118-179). Identity +
@@ -5398,7 +5463,6 @@
const [contacts, setContacts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [tab, setTab] = useState('all');
const [search, setSearch] = useState('');
const [sort, setSort] = useState('name-asc');
const [sortOpen, setSortOpen] = useState(false);
@@ -5431,8 +5495,6 @@
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
const list = contacts.filter((c) => {
if (tab === 'investors' && c.contact_type !== 'investor') return false;
if (tab === 'prospects' && c.contact_type !== 'prospect') return false;
if (!q) return true;
const org = c.organization || c.organization_name || '';
return displayName(c).toLowerCase().includes(q)
@@ -5441,16 +5503,20 @@
});
if (sort === 'recent') {
list.sort((a, b) => lastContactTs(b) - lastContactTs(a));
} else if (sort === 'priority') {
// Flagged (grid investor priority) first, then AZ. c.priority: boolean (API-injected).
list.sort((a, b) => (b.priority ? 1 : 0) - (a.priority ? 1 : 0)
|| sortBasis(a).localeCompare(sortBasis(b), undefined, { sensitivity: 'base' }));
} else {
const dir = sort === 'name-desc' ? -1 : 1;
list.sort((a, b) => dir * sortBasis(a).localeCompare(sortBasis(b), undefined, { sensitivity: 'base' }));
}
return list;
}, [contacts, tab, search, sort]);
}, [contacts, search, sort]);
// AZ letter groups only in name-sort modes; "recently contacted" is a flat list.
// AZ letter groups only in name-sort modes; recent / priority are flat lists.
const groups = useMemo(() => {
if (sort === 'recent') return null;
if (sort === 'recent' || sort === 'priority') return null;
const m = new Map();
for (const c of filtered) {
let letter = (sortBasis(c).charAt(0) || '#').toUpperCase();
@@ -5486,8 +5552,6 @@
);
};
const sortShort = (SORT_OPTIONS.find((o) => o.id === sort) || SORT_OPTIONS[0]).short;
return (
<div className="mobile-screen">
<div className="mobile-caption">Read-only directory — people are added and edited from the Fundraising Grid.</div>
@@ -5499,16 +5563,9 @@
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="mobile-seg">
{['all', 'investors', 'prospects'].map((t) => (
<button key={t} className={`mobile-seg-tab ${tab === t ? 'active' : ''}`} onClick={() => setTab(t)}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
<div className="mobile-sortbar">
<span className="mobile-count">{filtered.length} {filtered.length === 1 ? 'contact' : 'contacts'}</span>
<button className="mobile-sort-btn" onClick={() => setSortOpen(true)}>⇅ {sortShort}</button>
<SortPill label={sortPillLabel(SORT_OPTIONS, sort)} onClick={() => setSortOpen(true)} />
</div>
</div>
@@ -5529,18 +5586,8 @@
filtered.map(renderCard)
)}
<BottomSheet open={sortOpen} onClose={() => setSortOpen(false)} title="Sort">
{SORT_OPTIONS.map((o) => (
<button
key={o.id}
className={`sheet-option ${sort === o.id ? 'active' : ''}`}
onClick={() => { setSort(o.id); setSortOpen(false); }}
>
<span>{o.label}</span>
{sort === o.id && <span className="sheet-option-check"></span>}
</button>
))}
</BottomSheet>
<SortSheet open={sortOpen} onClose={() => setSortOpen(false)} title="Sort contacts"
options={SORT_OPTIONS} value={sort} onPick={setSort} />
{selected && (
<MobileContactDetail
@@ -6110,6 +6157,8 @@
const [logOpen, setLogOpen] = useState(false);
const [contactDetail, setContactDetail] = useState(null); // /api/contacts/{contact_id} for the open opp
const [busy, setBusy] = useState(false);
const [sortKey, setSortKey] = useState('name'); // PIPELINE_SORTS — applied within each stage
const [sortOpen, setSortOpen] = useState(false);
const swipeRef = useRef(null);
const stages = PIPELINE_STAGES;
@@ -6134,8 +6183,20 @@
const out = {};
stages.forEach((s) => { out[s] = []; });
opportunities.forEach((o) => { if (out[o.stage]) out[o.stage].push(o); });
// Sort within each stage by the active key (dc PipelineApp sortCards). Name is the
// tiebreak. "staleness" uses updated_at (oldest activity first) as the recency proxy
// until the Pipeline card wires true last-contact recency (8f); missing date = stalest.
const byName = (a, b) => String(a.name || '').localeCompare(String(b.name || ''), undefined, { sensitivity: 'base' });
const updatedTs = (o) => { const t = o.updated_at ? new Date(o.updated_at).getTime() : NaN; return isNaN(t) ? -Infinity : t; };
const cmp = {
name: byName,
amount: (a, b) => ((Number(b.expected_amount) || 0) - (Number(a.expected_amount) || 0)) || byName(a, b),
staleness: (a, b) => (updatedTs(a) - updatedTs(b)) || byName(a, b), // older ts first = most stale first
priority: (a, b) => ((b.priority === 'high' ? 1 : 0) - (a.priority === 'high' ? 1 : 0)) || byName(a, b), // opp.priority: 'high'|'medium'|'low'
};
stages.forEach((s) => { out[s].sort(cmp[sortKey] || byName); });
return out;
}, [opportunities]);
}, [opportunities, sortKey]);
const stageTotals = useMemo(() => stages.map((s) =>
byStage[s].reduce((sum, o) => sum + (Number(o.expected_amount) || 0), 0)), [byStage]);
@@ -6245,6 +6306,10 @@
<div className="empty-state">No deals in the pipeline yet. Add them from the Fundraising Grid — open an investor and tap “Add to pipeline.”</div>
) : (
<>
<div className="mobile-sortbar" style={{ marginBottom: '12px' }}>
<span className="mobile-count">{opportunities.length} {opportunities.length === 1 ? 'deal' : 'deals'}</span>
<SortPill label={sortPillLabel(PIPELINE_SORTS, sortKey)} onClick={() => setSortOpen(true)} />
</div>
<div className="pipeline-seg" role="tablist" aria-label="Pipeline stages">
{stages.map((s, i) => (
<button
@@ -6345,6 +6410,9 @@
</BottomSheet>
);
})()}
<SortSheet open={sortOpen} onClose={() => setSortOpen(false)} title="Sort within stage"
options={PIPELINE_SORTS} value={sortKey} onPick={setSortKey} />
</div>
);
};
@@ -9759,7 +9827,8 @@
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState(null);
const [sheet, setSheet] = useState(null); // 'view' | 'create' | 'note' | 'stage' | 'reminder'
const [sheet, setSheet] = useState(null); // 'view' | 'create' | 'note' | 'stage' | 'reminder' | 'sort'
const [sortKey, setSortKey] = useState('name'); // GRID_SORTS
const [busy, setBusy] = useState(false);
const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '' });
const [reminderForm, setReminderForm] = useState({ title: '', due_date: '', details: '' });
@@ -9817,9 +9886,24 @@
const contactText = (r.contacts || []).map((c) => `${c.name || ''} ${c.email || ''} ${c.city || ''} ${c.state || ''} ${c.country || ''}`).join(' ');
return `${r.investor_name || ''} ${r.notes || ''} ${contactText}`.toLowerCase().includes(q);
});
return [...searched].sort((a, b) => String(a.investor_name || '')
.localeCompare(String(b.investor_name || ''), undefined, { sensitivity: 'base' }));
}, [rows, activeViewObj, columns, fundColumnIds, search]);
// Sort by the active key (dc GridApp sortList). Name is the tiebreak for every
// non-name key. Staleness ranks longest-since-contact first; rows with no recorded
// activity sort as most stale (they most need a touch). Committed uses the fund rollup.
const byName = (a, b) => String(a.investor_name || '')
.localeCompare(String(b.investor_name || ''), undefined, { sensitivity: 'base' });
const staleDays = (r) => { const d = daysSince(r.last_activity_at); return d == null ? Number.MAX_SAFE_INTEGER : d; };
const cmp = {
name: byName,
stage: (a, b) => {
const oi = (r) => (r.pipeline_stage ? PIPELINE_STAGES.indexOf(r.pipeline_stage) : 99);
return (oi(a) - oi(b)) || byName(a, b);
},
committed: (a, b) => (gridRollup(b, fundColumnIds) - gridRollup(a, fundColumnIds)) || byName(a, b),
staleness: (a, b) => (staleDays(b) - staleDays(a)) || byName(a, b), // larger days first = most stale first
priority: (a, b) => ((b.priority ? 1 : 0) - (a.priority ? 1 : 0)) || byName(a, b), // row.priority: boolean
};
return [...searched].sort(cmp[sortKey] || byName);
}, [rows, activeViewObj, columns, fundColumnIds, search, sortKey]);
const selectedRow = useMemo(() => rows.find((r) => r.id === selectedId) || null, [rows, selectedId]);
const closeSheet = () => setSheet(null);
@@ -10018,6 +10102,7 @@
<input className="mobile-search" type="text" placeholder="Search investors…" value={search} onChange={(e) => setSearch(e.target.value)} />
<div className="mobile-sortbar">
<span className="mobile-count">{displayed.length} {displayed.length === 1 ? 'investor' : 'investors'}</span>
<SortPill label={sortPillLabel(GRID_SORTS, sortKey)} onClick={() => setSheet('sort')} />
</div>
</div>
@@ -10031,6 +10116,9 @@
displayed.map(renderCard)
)}
<SortSheet open={sheet === 'sort'} onClose={closeSheet} title="Sort investors"
options={GRID_SORTS} value={sortKey} onPick={setSortKey} />
<BottomSheet open={sheet === 'view'} onClose={closeSheet} title="Views">
{views.map((v) => (
<button key={v.id} className={`sheet-option ${v.id === activeView ? 'active' : ''}`} onClick={() => { setActiveView(v.id); closeSheet(); }}>