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:
+128
-40
@@ -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; }
|
||||
|
||||
/* A–Z 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 (A–Z)', short: 'A–Z' },
|
||||
{ id: 'name-desc', label: 'Name (Z–A)', short: 'Z–A' },
|
||||
{ id: 'recent', label: 'Recently contacted', short: 'Recent' },
|
||||
{ id: 'name-asc', pill: 'A–Z', label: 'Name', hint: 'A → Z' },
|
||||
{ id: 'name-desc', pill: 'Z–A', label: 'Name', hint: 'Z → A' },
|
||||
{ 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 A–Z. 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]);
|
||||
|
||||
// A–Z letter groups only in name-sort modes; "recently contacted" is a flat list.
|
||||
// A–Z 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(); }}>
|
||||
|
||||
Reference in New Issue
Block a user