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:
@@ -107,13 +107,14 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
|
|||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
_**Box live at v0.1.0:94**; `main` ahead by mobile Phases 0–7 + P3b + drag-reorder + **8a + 8b + 8c** — **all deploy-pending** (no s9pk built). **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign → Phase 8**, building to `design/phase8-conformance.md` (the 8a–8i spec, anchored on each `*.dc.html` DEFAULT `data-props` — NOT the `screenshots/` PNGs). **Plan (Grant, 2026-06-19): finish features first → then Grant device-tests + deploys** (nothing verified on a real phone). History: git log + `start9/0.4/startos/versions/`._
|
_**Box live at v0.1.0:94**; `main` ahead by mobile Phases 0–7 + P3b + drag-reorder + **8a + 8b + 8c + 8d** — **all deploy-pending** (no s9pk built). **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign → Phase 8**, building to `design/phase8-conformance.md` (the 8a–8i spec, anchored on each `*.dc.html` DEFAULT `data-props` — NOT the `screenshots/` PNGs). **Plan (Grant, 2026-06-19): finish features first → then Grant device-tests + deploys** (nothing verified on a real phone). History: git log + `start9/0.4/startos/versions/`._
|
||||||
|
|
||||||
- **Mobile redesign — 4 core surfaces built (Grid · Contacts · Pipeline · Reminders), each a rules-of-hooks-safe `useIsMobile()` → `Mobile*`/`Desktop*` pair (desktop untouched).** Foundation: bottom-tab bar + `:root` mobile vars; 4-stage enum; derived grid signals injected-on-GET/stripped-on-write at both points; mobile writes use **one-row endpoints only** (log-communication, pipeline link/stage, reminders, `update-row`) — never whole-grid PUT.
|
- **Mobile redesign — 4 core surfaces built (Grid · Contacts · Pipeline · Reminders), each a rules-of-hooks-safe `useIsMobile()` → `Mobile*`/`Desktop*` pair (desktop untouched).** Foundation: bottom-tab bar + `:root` mobile vars; 4-stage enum; derived grid signals injected-on-GET/stripped-on-write at both points; mobile writes use **one-row endpoints only** (log-communication, pipeline link/stage, reminders, `update-row`) — never whole-grid PUT.
|
||||||
- **Phase 8a — Grid + Contacts cards re-authored (this session).** Grid card: existing-LP **earmark** corner-triangle (replaces left-border), right-side **PRIORITY pill** (replaces ★), 4-stage chip, zero-commit dim; detail ★→"Existing LP" pill. Contacts card: two-letter avatar initials + existing-LP **ring** + stage pill + recency; disposition badge dropped. Backend: `contact_grid_signals()` injects derived read-only `committed`/`pipeline_stage` on the contacts read path (see Design convention). `DESIGN.md` §4/§8 reconciled.
|
- **Phase 8a — Grid + Contacts cards re-authored (this session).** Grid card: existing-LP **earmark** corner-triangle (replaces left-border), right-side **PRIORITY pill** (replaces ★), 4-stage chip, zero-commit dim; detail ★→"Existing LP" pill. Contacts card: two-letter avatar initials + existing-LP **ring** + stage pill + recency; disposition badge dropped. Backend: `contact_grid_signals()` injects derived read-only `committed`/`pipeline_stage` on the contacts read path (see Design convention). `DESIGN.md` §4/§8 reconciled.
|
||||||
- **Phase 8b — Contacts + Pipeline detail → drag-dismiss bottom sheets (this session).** Contacts: email-copy pill, Log/Email actions, Organization card (earmark·stage·committed·last-contact·last-note·Open-in-Grid). Pipeline: stat tiles, **inline move-stage list**, notes **timeline** + Log sheet. Both log via `POST /api/communications`; `<BottomSheet stacked>` layers the Log sheet over a detail. Reviewer pass applied: stale-fetch race guard (cancelled-flag effects + reload key), keyed single-contact signals query, dedup test.
|
- **Phase 8b — Contacts + Pipeline detail → drag-dismiss bottom sheets (this session).** Contacts: email-copy pill, Log/Email actions, Organization card (earmark·stage·committed·last-contact·last-note·Open-in-Grid). Pipeline: stat tiles, **inline move-stage list**, notes **timeline** + Log sheet. Both log via `POST /api/communications`; `<BottomSheet stacked>` layers the Log sheet over a detail. Reviewer pass applied: stale-fetch race guard (cancelled-flag effects + reload key), keyed single-contact signals query, dedup test.
|
||||||
- **Phase 8c — Grid-detail notes timeline + top-bar quick-log pencil (this session).** Grid detail (G6): the single `row.notes` blob → a `<NoteTimeline>` fed by a new investor-level read `GET /api/communications?source_row_id=<grid row id>` (backend filter maps row → `fundraising_investors.source_row_id` → `fundraising_contacts.contact_id` → comms, soft-delete-respecting; cancelled-flag fetch + `commsReload` after a log); note-logging switched to the shared `<LogCommunicationSheet>` (dropped `noteForm` + dead `.fs-note-log`). New `MobileQuickLog` (shell `.mobile-only` top-bar pencil, all 4 tabs): two-step sheet — pick investor (search + recent-first pool) → inline log form → one-row `/api/fundraising/log-communication`. Reviewer pass: `contact_id`/`source_row_id` kept mutually exclusive (`elif`), re-open guard, dropped the rejected ★ from the picker. Guarded by `test_grid_comm_timeline.py`.
|
- **Phase 8c — Grid-detail notes timeline + top-bar quick-log pencil (this session).** Grid detail (G6): the single `row.notes` blob → a `<NoteTimeline>` fed by a new investor-level read `GET /api/communications?source_row_id=<grid row id>` (backend filter maps row → `fundraising_investors.source_row_id` → `fundraising_contacts.contact_id` → comms, soft-delete-respecting; cancelled-flag fetch + `commsReload` after a log); note-logging switched to the shared `<LogCommunicationSheet>` (dropped `noteForm` + dead `.fs-note-log`). New `MobileQuickLog` (shell `.mobile-only` top-bar pencil, all 4 tabs): two-step sheet — pick investor (search + recent-first pool) → inline log form → one-row `/api/fundraising/log-communication`. Reviewer pass: `contact_id`/`source_row_id` kept mutually exclusive (`elif`), re-open guard, dropped the rejected ★ from the picker. Guarded by `test_grid_comm_timeline.py`.
|
||||||
|
- **Phase 8d — sort controls across Grid · Pipeline · Contacts (this session).** Shared `SortPill` + `SortSheet` (label+hint option rows) + per-surface sort tables: Grid (Name/Stage/Committed/Staleness/Priority), Pipeline (Name/Amount/Staleness/Priority, sorted **within each stage**; staleness = `opp.updated_at` proxy until 8f wires real recency), Contacts (**type tabs dropped** per Grant; Name A–Z/Z–A, Last contact, **Priority**). Backend: `contact_grid_signals()` now also injects the investor's `priority` flag on both contacts read paths (powers the Contacts Priority sort). Reviewer pass: type-annotated the boolean-vs-`'high'` priority comparators, `sortPillLabel` empty-guard. Guarded by extended `test_contacts_grid_signals.py` (priority signal + dedup).
|
||||||
- **Live (deployed):** W2 NL query (v94); W1 reminders (v93); grid Pipeline (v88); Matrix intake + Gmail capture (DWD) + daily digest; Thesis/Architect (dual-approval); outreach — all draft-only.
|
- **Live (deployed):** W2 NL query (v94); W1 reminders (v93); grid Pipeline (v88); Matrix intake + Gmail capture (DWD) + daily digest; Thesis/Architect (dual-approval); outreach — all draft-only.
|
||||||
- **Tests:** **39/39 backend green** (`python3 backend/run_tests.py`; +`test_grid_comm_timeline.py`), `py_compile` clean, render-smoke green; 8c surfaces (quick-log + grid timeline) interaction-verified via a throwaway 375px jsdom harness (deleted after).
|
- **Tests:** **39/39 backend green** (`python3 backend/run_tests.py`; +`test_grid_comm_timeline.py` for the 8c timeline filter, +priority assertions in `test_contacts_grid_signals.py`), `py_compile` clean; 8c+8d surfaces interaction-verified via throwaway 375px jsdom harnesses (deleted after).
|
||||||
- **Next — Phase 8, in order, build to `design/phase8-conformance.md`:** **8d** sort (Grid+Pipeline sort sheet; Contacts = drop type tabs + add Priority sort) → **8e** reminders (due-chip + Overdue/Today/This-week/Later buckets + dots + snooze sheet + investor picker) → **8f** Pipeline card (earmark/Priority/recency + horizontal-scroll stage pills + dots) → **8g** add-investor stage+priority → **8h** loose ends (incl. Grid detail G4/G5/G6 stage-card/reminder-card/timeline; "Open-in-Grid" deep-link-to-investor) → **8i** shell SVG icons + `·Ten31·` wordmark. **Skip Pipeline accordion** (Grant). **Then (after feature-complete):** deploy P0–P8 + P3b in one s9pk (**authorize + version-bump first**) and device-test light/dark on a phone.
|
- **Next — Phase 8, in order, build to `design/phase8-conformance.md`:** **8e** reminders (due-chip + Overdue/Today/This-week/Later buckets + dots + snooze sheet + investor picker) → **8f** Pipeline card (earmark/Priority/recency + horizontal-scroll stage pills + dots) → **8g** add-investor stage+priority → **8h** loose ends (incl. Grid detail G4/G5/G6 stage-card/reminder-card/timeline; "Open-in-Grid" deep-link-to-investor) → **8i** shell SVG icons + `·Ten31·` wordmark. **Skip Pipeline accordion** (Grant). **Then (after feature-complete):** deploy P0–P8 + P3b in one s9pk (**authorize + version-bump first**) and device-test light/dark on a phone.
|
||||||
- **Open / risks:** all mobile work + light theme **built but never deployed or device-tested** (smoke/jsdom only); `MobileDetailRow` now unused-but-retained (legacy-usage sweep); Pipeline detail "Committed" tile shows grid-committed not deal-expected (deal forecast in a footnote); W2 happy-path only; **Claude/Architect path unverified live on the box**; v2.0 reserve-asset spine **not canonical**; doc drift — `crm-overview.md`/`EVALUATION.md` still call `lp_profiles` live.
|
- **Open / risks:** all mobile work + light theme **built but never deployed or device-tested** (smoke/jsdom only); `MobileDetailRow` now unused-but-retained (legacy-usage sweep); Pipeline detail "Committed" tile shows grid-committed not deal-expected (deal forecast in a footnote); W2 happy-path only; **Claude/Architect path unverified live on the box**; v2.0 reserve-asset spine **not canonical**; doc drift — `crm-overview.md`/`EVALUATION.md` still call `lp_profiles` live.
|
||||||
|
|||||||
+13
-8
@@ -1876,11 +1876,12 @@ def existing_investor_by_source_row(conn):
|
|||||||
|
|
||||||
|
|
||||||
def contact_grid_signals(conn, contact_id=None):
|
def contact_grid_signals(conn, contact_id=None):
|
||||||
"""Return {contacts.id: {'committed': float, 'pipeline_stage': str|None}} for every classic
|
"""Return {contacts.id: {'committed': float, 'pipeline_stage': str|None, 'priority': bool}} for
|
||||||
contact linked to a fundraising-grid investor (via fundraising_contacts.contact_id, migration
|
every classic contact linked to a fundraising-grid investor (via fundraising_contacts.contact_id,
|
||||||
0004). Surfaces the canonical investor's committed rollup (total_invested → the mobile Contacts
|
migration 0004). Surfaces the canonical investor's committed rollup (total_invested → the mobile
|
||||||
card's existing-LP avatar ring, committed > 0, mirroring existing_investor_by_source_row) and its
|
Contacts card's existing-LP avatar ring, committed > 0, mirroring existing_investor_by_source_row),
|
||||||
live derived pipeline stage (→ the card's stage pill). Derived fresh on read like the grid's
|
its live derived pipeline stage (→ the card's stage pill), and its priority flag (→ the mobile
|
||||||
|
Contacts Priority sort, 8d). Derived fresh on read like the grid's
|
||||||
injected columns — never stored on the contact. A contact with no grid link gets nothing (a pure
|
injected columns — never stored on the contact. A contact with no grid link gets nothing (a pure
|
||||||
classic/legacy contact is not an investor). The grid relational tables are rebuilt from the blob
|
classic/legacy contact is not an investor). The grid relational tables are rebuilt from the blob
|
||||||
on each save (no soft-delete axis), so no deleted_at filter is needed on the join — same basis as
|
on each save (no soft-delete axis), so no deleted_at filter is needed on the join — same basis as
|
||||||
@@ -1895,7 +1896,8 @@ def contact_grid_signals(conn, contact_id=None):
|
|||||||
try:
|
try:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT fc.contact_id AS cid, fi.total_invested AS committed, fi.source_row_id AS srid
|
SELECT fc.contact_id AS cid, fi.total_invested AS committed, fi.source_row_id AS srid,
|
||||||
|
fi.priority AS priority
|
||||||
FROM fundraising_contacts fc
|
FROM fundraising_contacts fc
|
||||||
JOIN fundraising_investors fi ON fc.investor_id = fi.id
|
JOIN fundraising_investors fi ON fc.investor_id = fi.id
|
||||||
{where}
|
{where}
|
||||||
@@ -1912,9 +1914,10 @@ def contact_grid_signals(conn, contact_id=None):
|
|||||||
committed = float(r['committed'] or 0)
|
committed = float(r['committed'] or 0)
|
||||||
prev = out.get(cid)
|
prev = out.get(cid)
|
||||||
# A contact normally links to exactly one investor; if it links to several, keep the
|
# A contact normally links to exactly one investor; if it links to several, keep the
|
||||||
# highest-committed one (and that investor's stage) so the ring reflects the strongest signal.
|
# highest-committed one (its stage + priority) so the signals reflect the strongest link.
|
||||||
if prev is None or committed > prev['committed']:
|
if prev is None or committed > prev['committed']:
|
||||||
out[cid] = {'committed': committed, 'pipeline_stage': stage_by_srid.get(str(r['srid'] or ''))}
|
out[cid] = {'committed': committed, 'pipeline_stage': stage_by_srid.get(str(r['srid'] or '')),
|
||||||
|
'priority': bool(r['priority'])}
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -2722,6 +2725,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
sig = signals.get(str(c.get('id') or ''))
|
sig = signals.get(str(c.get('id') or ''))
|
||||||
c['committed'] = sig['committed'] if sig else 0
|
c['committed'] = sig['committed'] if sig else 0
|
||||||
c['pipeline_stage'] = sig['pipeline_stage'] if sig else None
|
c['pipeline_stage'] = sig['pipeline_stage'] if sig else None
|
||||||
|
c['priority'] = bool(sig['priority']) if sig else False
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
return self.send_json({
|
return self.send_json({
|
||||||
@@ -2765,6 +2769,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
sig = contact_grid_signals(conn, contact_id).get(contact_id)
|
sig = contact_grid_signals(conn, contact_id).get(contact_id)
|
||||||
result['committed'] = sig['committed'] if sig else 0
|
result['committed'] = sig['committed'] if sig else 0
|
||||||
result['pipeline_stage'] = sig['pipeline_stage'] if sig else None
|
result['pipeline_stage'] = sig['pipeline_stage'] if sig else None
|
||||||
|
result['priority'] = bool(sig['priority']) if sig else False
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
return self.send_json({"data": result})
|
return self.send_json({"data": result})
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ sourced from the fundraising grid (the canonical investor model), for the mobile
|
|||||||
mirroring existing_investor_by_source_row (committed capital, not graveyard);
|
mirroring existing_investor_by_source_row (committed capital, not graveyard);
|
||||||
- `pipeline_stage` -> that investor's live derived stage (drives the card's stage pill),
|
- `pipeline_stage` -> that investor's live derived stage (drives the card's stage pill),
|
||||||
or null when the investor isn't in the pipeline.
|
or null when the investor isn't in the pipeline.
|
||||||
A contact with no grid link (pure classic/legacy contact) gets committed 0 / stage null.
|
- `priority` -> that investor's priority flag (drives the mobile Contacts Priority sort, 8d).
|
||||||
|
A contact with no grid link (pure classic/legacy contact) gets committed 0 / stage null / priority false.
|
||||||
Signals are derived fresh on read and never stored on the contact. Synthetic data only.
|
Signals are derived fresh on read and never stored on the contact. Synthetic data only.
|
||||||
|
|
||||||
Run: cd backend && python3 test_contacts_grid_signals.py
|
Run: cd backend && python3 test_contacts_grid_signals.py
|
||||||
@@ -138,16 +139,27 @@ def main():
|
|||||||
check((vince or {}).get("pipeline_stage") is None,
|
check((vince or {}).get("pipeline_stage") is None,
|
||||||
f"Vince.pipeline_stage is None (no grid link) (got {(vince or {}).get('pipeline_stage')!r})")
|
f"Vince.pipeline_stage is None (no grid link) (got {(vince or {}).get('pipeline_stage')!r})")
|
||||||
|
|
||||||
|
# ── priority signal: flagged investor → contact's Priority-sort key (8d) ──
|
||||||
|
print("\n[priority: Contacts Priority sort driven by the investor's priority flag]")
|
||||||
|
check((jane or {}).get("priority") is True,
|
||||||
|
f"Jane.priority is True (Acme flagged) (got {(jane or {}).get('priority')!r})")
|
||||||
|
check((pat or {}).get("priority") is False,
|
||||||
|
f"Pat.priority is False (Beta not flagged) (got {(pat or {}).get('priority')!r})")
|
||||||
|
check((vince or {}).get("priority") is False,
|
||||||
|
f"Vince.priority is False (no grid link) (got {(vince or {}).get('priority')!r})")
|
||||||
|
|
||||||
# ── the get-by-id endpoint carries the same signals (mobile detail sheet, 8b) ──
|
# ── the get-by-id endpoint carries the same signals (mobile detail sheet, 8b) ──
|
||||||
print("\n[get-by-id: /api/contacts/{id} also injects committed + pipeline_stage]")
|
print("\n[get-by-id: /api/contacts/{id} also injects committed + pipeline_stage]")
|
||||||
st, d = _req(port, "GET", f"/api/contacts/{jane['id']}", token)
|
st, d = _req(port, "GET", f"/api/contacts/{jane['id']}", token)
|
||||||
detail = (d or {}).get("data") or {}
|
detail = (d or {}).get("data") or {}
|
||||||
check(st == 200 and detail.get("committed") == 250000 and detail.get("pipeline_stage") == "engaged",
|
check(st == 200 and detail.get("committed") == 250000 and detail.get("pipeline_stage") == "engaged"
|
||||||
f"detail carries committed/pipeline_stage (got committed={detail.get('committed')}, stage={detail.get('pipeline_stage')!r})")
|
and detail.get("priority") is True,
|
||||||
|
f"detail carries committed/pipeline_stage/priority (got committed={detail.get('committed')}, stage={detail.get('pipeline_stage')!r}, priority={detail.get('priority')!r})")
|
||||||
st, d = _req(port, "GET", f"/api/contacts/{vince['id']}", token)
|
st, d = _req(port, "GET", f"/api/contacts/{vince['id']}", token)
|
||||||
vdetail = (d or {}).get("data") or {}
|
vdetail = (d or {}).get("data") or {}
|
||||||
check(st == 200 and vdetail.get("committed") == 0 and vdetail.get("pipeline_stage") is None,
|
check(st == 200 and vdetail.get("committed") == 0 and vdetail.get("pipeline_stage") is None
|
||||||
f"unlinked contact detail has committed 0 / stage None (got {vdetail.get('committed')}, {vdetail.get('pipeline_stage')!r})")
|
and vdetail.get("priority") is False,
|
||||||
|
f"unlinked contact detail has committed 0 / stage None / priority False (got {vdetail.get('committed')}, {vdetail.get('pipeline_stage')!r}, {vdetail.get('priority')!r})")
|
||||||
|
|
||||||
# ── stage tracks the board: advancing the opp re-derives the contact's stage ──
|
# ── stage tracks the board: advancing the opp re-derives the contact's stage ──
|
||||||
print("\n[derived-live: advancing the board stage re-derives the contact's pill]")
|
print("\n[derived-live: advancing the board stage re-derives the contact's pill]")
|
||||||
@@ -178,6 +190,9 @@ def main():
|
|||||||
jd = (d or {}).get("data") or {}
|
jd = (d or {}).get("data") or {}
|
||||||
check(jd.get("committed") == 500000,
|
check(jd.get("committed") == 500000,
|
||||||
f"multi-linked contact exposes the higher committed (500000 > 250000) (got {jd.get('committed')})")
|
f"multi-linked contact exposes the higher committed (500000 > 250000) (got {jd.get('committed')})")
|
||||||
|
# The winning (higher-committed) link is Mega Fund LP, which is not flagged → priority follows it.
|
||||||
|
check(jd.get("priority") is False,
|
||||||
|
f"multi-linked contact's priority follows the higher-committed investor (Mega, unflagged) (got {jd.get('priority')!r})")
|
||||||
finally:
|
finally:
|
||||||
httpd.shutdown()
|
httpd.shutdown()
|
||||||
|
|
||||||
|
|||||||
+128
-40
@@ -2198,11 +2198,27 @@
|
|||||||
.mobile-seg-tab.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-light); }
|
.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-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-count { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-muted); }
|
||||||
.mobile-sort-btn {
|
/* Shared sort control (8d): a mono uppercase pill (dc GridApp:72) + a label+hint option sheet. */
|
||||||
background: transparent; border: none; color: var(--text-muted);
|
.sort-pill {
|
||||||
font-size: 13px; font-family: inherit; cursor: pointer; padding: 6px 2px;
|
flex: none; display: inline-flex; align-items: center; gap: 6px;
|
||||||
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. */
|
/* A–Z directory: sticky letter headers over a card list. */
|
||||||
.az-header {
|
.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 LoginPage = () => {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -5279,10 +5341,13 @@
|
|||||||
// Phase 2 of the mobile-first redesign — the lowest-risk surface, validating the
|
// 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).
|
// 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 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 = [
|
const SORT_OPTIONS = [
|
||||||
{ id: 'name-asc', label: 'Name (A–Z)', short: 'A–Z' },
|
{ id: 'name-asc', pill: 'A–Z', label: 'Name', hint: 'A → Z' },
|
||||||
{ id: 'name-desc', label: 'Name (Z–A)', short: 'Z–A' },
|
{ id: 'name-desc', pill: 'Z–A', label: 'Name', hint: 'Z → A' },
|
||||||
{ id: 'recent', label: 'Recently contacted', short: 'Recent' },
|
{ 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 +
|
// Contacts detail — a drag-dismiss bottom sheet (8b / dc ContactsApp:118-179). Identity +
|
||||||
@@ -5398,7 +5463,6 @@
|
|||||||
const [contacts, setContacts] = useState([]);
|
const [contacts, setContacts] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [tab, setTab] = useState('all');
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [sort, setSort] = useState('name-asc');
|
const [sort, setSort] = useState('name-asc');
|
||||||
const [sortOpen, setSortOpen] = useState(false);
|
const [sortOpen, setSortOpen] = useState(false);
|
||||||
@@ -5431,8 +5495,6 @@
|
|||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
const list = contacts.filter((c) => {
|
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;
|
if (!q) return true;
|
||||||
const org = c.organization || c.organization_name || '';
|
const org = c.organization || c.organization_name || '';
|
||||||
return displayName(c).toLowerCase().includes(q)
|
return displayName(c).toLowerCase().includes(q)
|
||||||
@@ -5441,16 +5503,20 @@
|
|||||||
});
|
});
|
||||||
if (sort === 'recent') {
|
if (sort === 'recent') {
|
||||||
list.sort((a, b) => lastContactTs(b) - lastContactTs(a));
|
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 {
|
} else {
|
||||||
const dir = sort === 'name-desc' ? -1 : 1;
|
const dir = sort === 'name-desc' ? -1 : 1;
|
||||||
list.sort((a, b) => dir * sortBasis(a).localeCompare(sortBasis(b), undefined, { sensitivity: 'base' }));
|
list.sort((a, b) => dir * sortBasis(a).localeCompare(sortBasis(b), undefined, { sensitivity: 'base' }));
|
||||||
}
|
}
|
||||||
return list;
|
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(() => {
|
const groups = useMemo(() => {
|
||||||
if (sort === 'recent') return null;
|
if (sort === 'recent' || sort === 'priority') return null;
|
||||||
const m = new Map();
|
const m = new Map();
|
||||||
for (const c of filtered) {
|
for (const c of filtered) {
|
||||||
let letter = (sortBasis(c).charAt(0) || '#').toUpperCase();
|
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 (
|
return (
|
||||||
<div className="mobile-screen">
|
<div className="mobile-screen">
|
||||||
<div className="mobile-caption">Read-only directory — people are added and edited from the Fundraising Grid.</div>
|
<div className="mobile-caption">Read-only directory — people are added and edited from the Fundraising Grid.</div>
|
||||||
@@ -5499,16 +5563,9 @@
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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">
|
<div className="mobile-sortbar">
|
||||||
<span className="mobile-count">{filtered.length} {filtered.length === 1 ? 'contact' : 'contacts'}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -5529,18 +5586,8 @@
|
|||||||
filtered.map(renderCard)
|
filtered.map(renderCard)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<BottomSheet open={sortOpen} onClose={() => setSortOpen(false)} title="Sort">
|
<SortSheet open={sortOpen} onClose={() => setSortOpen(false)} title="Sort contacts"
|
||||||
{SORT_OPTIONS.map((o) => (
|
options={SORT_OPTIONS} value={sort} onPick={setSort} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{selected && (
|
{selected && (
|
||||||
<MobileContactDetail
|
<MobileContactDetail
|
||||||
@@ -6110,6 +6157,8 @@
|
|||||||
const [logOpen, setLogOpen] = useState(false);
|
const [logOpen, setLogOpen] = useState(false);
|
||||||
const [contactDetail, setContactDetail] = useState(null); // /api/contacts/{contact_id} for the open opp
|
const [contactDetail, setContactDetail] = useState(null); // /api/contacts/{contact_id} for the open opp
|
||||||
const [busy, setBusy] = useState(false);
|
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 swipeRef = useRef(null);
|
||||||
|
|
||||||
const stages = PIPELINE_STAGES;
|
const stages = PIPELINE_STAGES;
|
||||||
@@ -6134,8 +6183,20 @@
|
|||||||
const out = {};
|
const out = {};
|
||||||
stages.forEach((s) => { out[s] = []; });
|
stages.forEach((s) => { out[s] = []; });
|
||||||
opportunities.forEach((o) => { if (out[o.stage]) out[o.stage].push(o); });
|
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;
|
return out;
|
||||||
}, [opportunities]);
|
}, [opportunities, sortKey]);
|
||||||
|
|
||||||
const stageTotals = useMemo(() => stages.map((s) =>
|
const stageTotals = useMemo(() => stages.map((s) =>
|
||||||
byStage[s].reduce((sum, o) => sum + (Number(o.expected_amount) || 0), 0)), [byStage]);
|
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="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">
|
<div className="pipeline-seg" role="tablist" aria-label="Pipeline stages">
|
||||||
{stages.map((s, i) => (
|
{stages.map((s, i) => (
|
||||||
<button
|
<button
|
||||||
@@ -6345,6 +6410,9 @@
|
|||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
<SortSheet open={sortOpen} onClose={() => setSortOpen(false)} title="Sort within stage"
|
||||||
|
options={PIPELINE_SORTS} value={sortKey} onPick={setSortKey} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -9759,7 +9827,8 @@
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedId, setSelectedId] = useState(null);
|
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 [busy, setBusy] = useState(false);
|
||||||
const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '' });
|
const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '' });
|
||||||
const [reminderForm, setReminderForm] = useState({ title: '', due_date: '', details: '' });
|
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(' ');
|
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 `${r.investor_name || ''} ${r.notes || ''} ${contactText}`.toLowerCase().includes(q);
|
||||||
});
|
});
|
||||||
return [...searched].sort((a, b) => String(a.investor_name || '')
|
// Sort by the active key (dc GridApp sortList). Name is the tiebreak for every
|
||||||
.localeCompare(String(b.investor_name || ''), undefined, { sensitivity: 'base' }));
|
// non-name key. Staleness ranks longest-since-contact first; rows with no recorded
|
||||||
}, [rows, activeViewObj, columns, fundColumnIds, search]);
|
// 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 selectedRow = useMemo(() => rows.find((r) => r.id === selectedId) || null, [rows, selectedId]);
|
||||||
const closeSheet = () => setSheet(null);
|
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)} />
|
<input className="mobile-search" type="text" placeholder="Search investors…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
<div className="mobile-sortbar">
|
<div className="mobile-sortbar">
|
||||||
<span className="mobile-count">{displayed.length} {displayed.length === 1 ? 'investor' : 'investors'}</span>
|
<span className="mobile-count">{displayed.length} {displayed.length === 1 ? 'investor' : 'investors'}</span>
|
||||||
|
<SortPill label={sortPillLabel(GRID_SORTS, sortKey)} onClick={() => setSheet('sort')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -10031,6 +10116,9 @@
|
|||||||
displayed.map(renderCard)
|
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">
|
<BottomSheet open={sheet === 'view'} onClose={closeSheet} title="Views">
|
||||||
{views.map((v) => (
|
{views.map((v) => (
|
||||||
<button key={v.id} className={`sheet-option ${v.id === activeView ? 'active' : ''}`} onClick={() => { setActiveView(v.id); closeSheet(); }}>
|
<button key={v.id} className={`sheet-option ${v.id === activeView ? 'active' : ''}`} onClick={() => { setActiveView(v.id); closeSheet(); }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user