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
+9 -9
View File
@@ -75,7 +75,7 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
- **Env:** secrets in `.env` (gitignored); names in `.env.example`. Verified names: `ANTHROPIC_API_KEY`, `SPARK_CONTROL_URL`, `SPARK_CONTROL_VERIFY_TLS`, `QDRANT_URL`, `X_API_KEY`, `CRM_DB_PATH`, `CRM_DEV_DB_PATH`. Also used: `CRM_SECRET_KEY` (beta/prod), `CRM_HOST`/`CRM_PORT`, `CRM_DATA_DIR`; digest mailer: `CRM_DIGEST_SENDER` (DWD impersonation sender) + `SMTP_HOST`/`SMTP_PORT`/`SMTP_SECURITY`/`SMTP_FROM`/`SMTP_USERNAME`/`SMTP_PASSWORD` (SMTP fallback); daily digest (Phase B): `CRM_DIGEST_ENABLED` + `CRM_DIGEST_SEND_HOUR` **only seed the first-boot default** — the live control is the DB policy (`app_settings.digest_policy`, set in Settings → Admin). - **Env:** secrets in `.env` (gitignored); names in `.env.example`. Verified names: `ANTHROPIC_API_KEY`, `SPARK_CONTROL_URL`, `SPARK_CONTROL_VERIFY_TLS`, `QDRANT_URL`, `X_API_KEY`, `CRM_DB_PATH`, `CRM_DEV_DB_PATH`. Also used: `CRM_SECRET_KEY` (beta/prod), `CRM_HOST`/`CRM_PORT`, `CRM_DATA_DIR`; digest mailer: `CRM_DIGEST_SENDER` (DWD impersonation sender) + `SMTP_HOST`/`SMTP_PORT`/`SMTP_SECURITY`/`SMTP_FROM`/`SMTP_USERNAME`/`SMTP_PASSWORD` (SMTP fallback); daily digest (Phase B): `CRM_DIGEST_ENABLED` + `CRM_DIGEST_SEND_HOUR` **only seed the first-boot default** — the live control is the DB policy (`app_settings.digest_policy`, set in Settings → Admin).
- **Config placement:** operational/feature toggles live in the **admin panel**, DB-backed via `app_settings` (read-merge through a `load_*_policy(conn)` helper shared by the API + any scheduler; precedence DB-row → env-seed → default), so they're discoverable and take effect live. Reserve StartOS actions / env for **secrets and deploy-time config** (SMTP creds, API keys, DWD sender). Precedent: `digest_policy` (`GET/PATCH /api/admin/digest/policy`), `fundraising_backup_policy`. - **Config placement:** operational/feature toggles live in the **admin panel**, DB-backed via `app_settings` (read-merge through a `load_*_policy(conn)` helper shared by the API + any scheduler; precedence DB-row → env-seed → default), so they're discoverable and take effect live. Reserve StartOS actions / env for **secrets and deploy-time config** (SMTP creds, API keys, DWD sender). Precedent: `digest_policy` (`GET/PATCH /api/admin/digest/policy`), `fundraising_backup_policy`.
- **Agent/bot API access — three roles now (`admin`/`member`/`bot`).** `require_admin` is the only hard gate; everything else is "authenticated" (member, admin, *and* bot all pass). The **`bot` role** (added v0.1.0:89) is authenticated-but-never-admin: `require_bot_or_admin` gates agent-facing endpoints (e.g. `/api/intake/email-proposals*`) so a bot credential reaches *only* what it needs, never user-management/settings/security. Provision it via Settings → Admin edit-user dropdown (kept out of the teammate-invite form). **Two axes to keep separate as more agent capability lands:** the role controls *reach* (which endpoints); the per-feature human draft→approve gate controls *autonomy* (acting unattended). Money/merge/delete mutations stay behind the approval gate regardless of role. Don't build a finer capability/scope system until real NL-mutation endpoints exist to scope against. - **Agent/bot API access — three roles now (`admin`/`member`/`bot`).** `require_admin` is the only hard gate; everything else is "authenticated" (member, admin, *and* bot all pass). The **`bot` role** (added v0.1.0:89) is authenticated-but-never-admin: `require_bot_or_admin` gates agent-facing endpoints (e.g. `/api/intake/email-proposals*`) so a bot credential reaches *only* what it needs, never user-management/settings/security. Provision it via Settings → Admin edit-user dropdown (kept out of the teammate-invite form). **Two axes to keep separate as more agent capability lands:** the role controls *reach* (which endpoints); the per-feature human draft→approve gate controls *autonomy* (acting unattended). Money/merge/delete mutations stay behind the approval gate regardless of role. Don't build a finer capability/scope system until real NL-mutation endpoints exist to scope against.
- **Design:** before building or changing any user-facing UI, read `design/DESIGN.md` and `design/tokens.tokens.json` and conform to them. The **mobile-first redesign landed** (Claude Design round-trip distilled into the contract 2026-06-19): the authority for mobile/responsive work is **`DESIGN.md` §8** + the tokens `mobile` and `color.light` groups; `design/BRIEF.md` is the input brief and `design/_imports/2026-06-19/` the provenance + per-surface interaction reference (the comps are Claude Design runtime prototypes — re-author each surface in the app's React idiom + real API, not drop-in; **the design source of truth is each `*.dc.html` at its DEFAULT `data-props` (compact/dark/plex/earmark — see `GridApp.dc.html` `data-props`), NOT the `screenshots/` PNGs, which are option-history (rejected/stale combos: INVESTOR/PROSPECT disposition badges, 6-stage MEETING/FUNDED funnel, star flag). Don't anchor on the screenshots** (cost a re-scope 2026-06-19; general learning in `standards/guides/design.md` Phase C)). A **light theme is built (P6)**: it lives in `:root[data-theme="light"]` (set by a pre-paint boot script from `localStorage.venture_crm_theme`; dark is the default), with an app-wide toggle in the desktop sidebar footer + the mobile top bar. **Colors are theme vars now — any new UI color MUST use a `:root` var (grow the set if needed), never a literal, or it won't flip in light** (chips/badges flip via `.stage-chip--{stage}` + the `--chip-*`/`--note-*`/`--badge-priority-*`/`--rem-*`/`--money`/`--recency-*`/`--due-soon` slots; authoritative dark+light pairs are in the Claude Design export `design/_imports/2026-06-19_zip-file/` `store.js` + `*App.dc.html`). Mobile light is complete; desktop has known unthemed shades (Phase 7). (Note: inline `style={{}}` objects can't respond to media queries; responsive layout belongs in the CSS `<style>` block. The **mobile foundation primitives are built** — CSS: `.bottom-tab-bar`, the `.bottom-sheet` primitive, `.mobile-only`/`.desktop-only`, `:root` mobile vars; React (Phase 2): **`<BottomSheet>`** (scrim/Escape/drag-to-dismiss) + **`useIsMobile()`** (768px) + the **`MobileDetailRow`**/`.fs-detail` full-screen-detail + `.contact-card`/`.az-header` list patterns — **build new mobile surfaces on these** (P3 Grid reuses them directly; swap surfaces via a rules-of-hooks-safe `useIsMobile()` wrapper that mounts a `Mobile*`/`Desktop*` pair, never a per-component hook toggle). The inline-style→CSS migration is **scoped, per-surface** (~114 styles across 4 surfaces+shell, not ~1,300), folded into each surface's build; see `ROADMAP.md`.) - **Design:** before building or changing any user-facing UI, read `design/DESIGN.md` and `design/tokens.tokens.json` and conform to them. The **mobile-first redesign landed** (Claude Design round-trip distilled into the contract 2026-06-19): the authority for mobile/responsive work is **`DESIGN.md` §8** + the tokens `mobile` and `color.light` groups; `design/BRIEF.md` is the input brief and `design/_imports/2026-06-19/` the provenance + per-surface interaction reference (the comps are Claude Design runtime prototypes — re-author each surface in the app's React idiom + real API, not drop-in; **the design source of truth is each `*.dc.html` at its DEFAULT `data-props` (compact/dark/plex/earmark — see `GridApp.dc.html` `data-props`), NOT the `screenshots/` PNGs, which are option-history (rejected/stale combos: INVESTOR/PROSPECT disposition badges, 6-stage MEETING/FUNDED funnel, star flag). Don't anchor on the screenshots** (cost a re-scope 2026-06-19; general learning in `standards/guides/design.md` Phase C)). A **light theme is built (P6)**: it lives in `:root[data-theme="light"]` (set by a pre-paint boot script from `localStorage.venture_crm_theme`; dark is the default), with an app-wide toggle in the desktop sidebar footer + the mobile top bar. **Colors are theme vars now — any new UI color MUST use a `:root` var (grow the set if needed), never a literal, or it won't flip in light** (chips/badges flip via `.stage-chip--{stage}` + the `--chip-*`/`--note-*`/`--badge-priority-*`/`--rem-*`/`--money`/`--recency-*`/`--due-soon` slots; authoritative dark+light pairs are in the Claude Design export `design/_imports/2026-06-19_zip-file/` `store.js` + `*App.dc.html`). Mobile light is complete; desktop has known unthemed shades (Phase 7). (Note: inline `style={{}}` objects can't respond to media queries; responsive layout belongs in the CSS `<style>` block. The **mobile foundation primitives are built** — CSS: `.bottom-tab-bar`, the `.bottom-sheet` primitive, `.mobile-only`/`.desktop-only`, `:root` mobile vars; React (Phase 2): **`<BottomSheet>`** (scrim/Escape/drag-to-dismiss) + **`useIsMobile()`** (768px) + the **`MobileDetailRow`**/`.fs-detail` full-screen-detail + `.contact-card`/`.az-header` list patterns — **build new mobile surfaces on these** (P3 Grid reuses them directly; swap surfaces via a rules-of-hooks-safe `useIsMobile()` wrapper that mounts a `Mobile*`/`Desktop*` pair, never a per-component hook toggle). The inline-style→CSS migration is **scoped, per-surface** (~114 styles across 4 surfaces+shell, not ~1,300), folded into each surface's build; see `ROADMAP.md`.) **Phase 8 card/detail primitives (reuse these, don't reinvent):** `EarmarkCorner` (existing-LP corner triangle; `inline` variant for the org card), the `priority-pill`/`lp-pill` text pills, `StageChip` (+ `sm`), `NoteTimeline`, `LogCommunicationSheet`; `<BottomSheet>` takes a **`stacked`** prop to layer a sheet opened over another sheet (e.g. the Log sheet over a detail). The mobile **Contacts + Pipeline detail surfaces are drag-dismiss bottom sheets** (8b) that log via **`POST /api/communications`**; the **Grid detail stays full-screen** (its dc default). **The Contacts read path now injects derived read-only `committed` + `pipeline_stage`** (`contact_grid_signals()` — existing-LP ring + stage pill) on **both** `GET /api/contacts` and `/api/contacts/{id}`; this needs no strip-point (the directory is read-only, never written back as a row) — unlike the grid's injected columns.
- **Commit style:** imperative subject, concise body explaining the *why*; put the package version in the subject (`… (v0.1.0:NN)`) for shippable changes. **No AI co-author / attribution trailers** — commits are authored by the user. - **Commit style:** imperative subject, concise body explaining the *why*; put the package version in the subject (`… (v0.1.0:NN)`) for shippable changes. **No AI co-author / attribution trailers** — commits are authored by the user.
## Always ## Always
@@ -107,12 +107,12 @@ 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 07 + P3b + drag-reorder views — **all deploy-pending** (no s9pk built). **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign → Phase 8 (conform to the FINAL Claude Design mockups + functional parity)** — scoped this session, full plan in `ROADMAP.md`. **Plan (Grant, 2026-06-19): finish features first → then Grant device-tests + deploys** (NOT before; nothing is 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 07 + P3b + drag-reorder + **8a + 8b** — **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 8a8i 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 — all 4 core surfaces built + committed (Grid · Contacts · Pipeline · Reminders).** Each is a rules-of-hooks-safe `useIsMobile()` wrapper`Mobile*`/`Desktop*` pair (**desktop untouched**), re-authored against the real API on shared primitives `<BottomSheet>`/`useIsMobile()`/`StageChip`/`MobileDetailRow`. Foundation: bottom-tab bar + `:root` mobile vars (P1); 4-stage enum + read-only derived grid signals (`existing_investor`/`last_activity_at`/`staleness`/`opportunity_id`) injected on GET, **stripped on write at both points** (P0/P3a `_computed_row_values` + `stripComputedRows`). **Mobile writes use one-row endpoints only — never whole-grid PUT** (BRIEF §3a): log-communication, pipeline link/stage, reminders, and now **`POST /api/fundraising/update-row`** (P3b name/pill edit). Per-phase detail in `ROADMAP.md`. - **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.
- **This session — Phase 7 theme-conformance (committed + pushed `490cab9`).** Routed ~50 hardcoded colors through **28 new themed `:root` vars** so every surface flips under `[data-theme="light"]`; **retired the legacy Material `.badge-*` family (12 classes)** onto brand StageChip/`--chip-*`/`--badge-priority`/`--badge-danger-bg` slots; bottom-center mobile toast (`slideInUp`). Verified CSSOM render-smoke + `var()`-resolution + reviewer (APPROVE-WITH-NITS, toast-rise fixed). **No real-phone check.** (`.badge-*` remap may be largely cosmetic — several classes have no live JSX caller; legacy-usage sweep in ROADMAP.) - **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.
- **This session — design + functional conformance audit (2 agents) → Phase 8 scoped + spec'd.** **Functional-parity report = VALID** (built from `store.js` + `.dc.html` wiring). The screenshot-anchored visual pass was **re-anchored to the `.dc.html` defaults → durable per-surface build spec: `design/phase8-conformance.md`** (anatomy + deltas + line refs, the 8a8i reference). **Key correction (durable, see Design convention above): the design source of truth is each `*.dc.html` at its DEFAULT `data-props` (compact/dark/plex/**earmark**), NOT the `screenshots/` PNGs** (option-history — disposition badges, 6-stage funnel, star flag were rejected). 9-phase **8a8i** plan in `ROADMAP.md`; general learning promoted to `standards/guides/design.md` (pushed). **Grant design calls (2026-06-19):** Pipeline existing-LP uses the **earmark** (unify w/ Grid, override the dc star — build it as a reusable component); Contacts drops the investor/prospect type tabs (prospect unused) but keeps a **Priority-flag sort**. Reminder↔investor "grid-only" was a mislabel — `reminders.investor_id` already exists + `POST /api/reminders` accepts it; the picker is a simple client fix. - **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.
- **Live (deployed):** W2 NL query (v94; remaining: in-room smoke + web "Ask" box); 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:** **37/37 backend green** (`python3 backend/run_tests.py`; +`test_fundraising_update_row.py`), `py_compile` clean, render-smoke green, fresh-DB migrate clean. - **Tests:** **38/38 backend green** (`python3 backend/run_tests.py`; +`test_contacts_grid_signals.py`), `py_compile` clean, render-smoke green; both mobile surfaces interaction-verified via throwaway 375px jsdom harnesses (deleted after).
- **Next — Phase 8 (next session), in order build to `design/phase8-conformance.md`:** **8a** card re-author (Grid+Contacts → **earmark as a reusable component** + right-side PRIORITY pill + 4-stage chip; **reconcile `DESIGN.md` §8**) → **8b** detail-surface bottom-sheets w/ log actions (Contacts/Pipeline) → **8c** quick-log pencil**8d** sort (Grid+Pipeline sort sheet; Contacts = drop type tabs, add Priority sort) → **8e** reminders (read/edit/clear + snooze sheet + investor picker + due-chip) → **8f** pipeline card (earmark/Priority/recency) + dots → **8g** add-investor stage+priority → **8h** loose ends → **8i** shell SVG icons + `·Ten31·` wordmark. **Skip Pipeline accordion** (Grant: not wanted). **Then (Grant, after feature-complete):** deploy P0P8 + P3b in one s9pk (**authorize + version-bump first**) and device-test light/dark on a real phone. Later backlog: **PWA** (manifest+SW+icons+`server.py` routes — feasible, app has MOCK_MODE + local vendored libs; install verifies only post-deploy); legacy-usage sweep + delete; W2 web Ask box; W3 bot grid-mutations. - **Next — Phase 8, in order, build to `design/phase8-conformance.md`:** **8c** quick-log pencil (dc top-bar) + Grid-detail notes timeline**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 P0P8 + P3b in one s9pk (**authorize + version-bump first**) and device-test light/dark on a phone.
- **Open / risks:** all mobile work + light theme + P3b **built but never deployed or device-tested** (smoke/jsdom only — verify both themes on a phone); Phase 8 build spec is `design/phase8-conformance.md` (re-anchored — don't rebuild from the screenshots); W2 happy-path only; **Claude/Architect path unverified live on the box**; v2.0 reserve-asset spine **not canonical** (needs dual sign-off); 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.
+58
View File
@@ -1875,6 +1875,49 @@ def existing_investor_by_source_row(conn):
return out return out
def contact_grid_signals(conn, contact_id=None):
"""Return {contacts.id: {'committed': float, 'pipeline_stage': str|None}} for every classic
contact linked to a fundraising-grid investor (via fundraising_contacts.contact_id, migration
0004). Surfaces the canonical investor's committed rollup (total_invested → the mobile Contacts
card's existing-LP avatar ring, committed > 0, mirroring existing_investor_by_source_row) and its
live derived pipeline stage ( the card's stage pill). 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
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
existing_investor_by_source_row. Pass `contact_id` to score a single contact (the detail path),
avoiding a scan of the whole directory the list path needs."""
out = {}
where = "WHERE fc.contact_id IS NOT NULL"
params = ()
if contact_id is not None:
where += " AND fc.contact_id = ?"
params = (contact_id,)
try:
rows = conn.execute(
f"""
SELECT fc.contact_id AS cid, fi.total_invested AS committed, fi.source_row_id AS srid
FROM fundraising_contacts fc
JOIN fundraising_investors fi ON fc.investor_id = fi.id
{where}
""",
params,
).fetchall()
except sqlite3.OperationalError:
return out
stage_by_srid = pipeline_stage_by_source_row(conn)
for r in rows:
cid = str(r['cid'] or '')
if not cid:
continue
committed = float(r['committed'] or 0)
prev = out.get(cid)
# 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.
if prev is None or committed > prev['committed']:
out[cid] = {'committed': committed, 'pipeline_stage': stage_by_srid.get(str(r['srid'] or ''))}
return out
def staleness_by_source_row(conn): def staleness_by_source_row(conn):
"""Return {grid source_row_id: (last_activity_iso_or_None, staleness)} where staleness is """Return {grid source_row_id: (last_activity_iso_or_None, staleness)} where staleness is
'' (fresh or no recorded activity), 'aging' (>= STALE_AGING_DAYS since last contact), or '' (fresh or no recorded activity), 'aging' (>= STALE_AGING_DAYS since last contact), or
@@ -2671,6 +2714,14 @@ class CRMHandler(BaseHTTPRequestHandler):
args.extend([limit, offset]) args.extend([limit, offset])
contacts = rows_to_list(conn.execute(query, args).fetchall()) contacts = rows_to_list(conn.execute(query, args).fetchall())
# Enrich with read-only, live-derived grid signals (committed → existing-LP avatar ring,
# pipeline_stage → stage pill) for the mobile Contacts card. Harmless extra fields for the
# desktop page, which ignores them. Never persisted on the contact (the list is read-only).
signals = contact_grid_signals(conn)
for c in contacts:
sig = signals.get(str(c.get('id') or ''))
c['committed'] = sig['committed'] if sig else 0
c['pipeline_stage'] = sig['pipeline_stage'] if sig else None
conn.close() conn.close()
return self.send_json({ return self.send_json({
@@ -2708,6 +2759,13 @@ class CRMHandler(BaseHTTPRequestHandler):
(contact_id,) (contact_id,)
).fetchall()) ).fetchall())
# Same read-only grid signals the list injects (committed → existing-LP ring/pill,
# pipeline_stage → stage pill), so the mobile detail sheet can render them without a
# second round-trip. Derived live; never stored on the contact.
sig = contact_grid_signals(conn, contact_id).get(contact_id)
result['committed'] = sig['committed'] if sig else 0
result['pipeline_stage'] = sig['pipeline_stage'] if sig else None
conn.close() conn.close()
return self.send_json({"data": result}) return self.send_json({"data": result})
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Tests for the mobile Contacts card's grid-derived signals (Phase 8a).
GET /api/contacts enriches each classic contact with two read-only, live-derived fields
sourced from the fundraising grid (the canonical investor model), for the mobile card:
- `committed` -> the linked investor's total_invested (>0 drives the existing-LP avatar ring),
mirroring existing_investor_by_source_row (committed capital, not graveyard);
- `pipeline_stage` -> that investor's live derived stage (drives the card's stage pill),
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.
Signals are derived fresh on read and never stored on the contact. Synthetic data only.
Run: cd backend && python3 test_contacts_grid_signals.py
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
from http.server import ThreadingHTTPServer
_DATA = tempfile.mkdtemp()
os.environ["CRM_DATA_DIR"] = _DATA
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import server # noqa: E402
FAILS = []
def check(cond, msg):
print((" PASS " if cond else " FAIL ") + msg)
if not cond:
FAILS.append(msg)
class _Quiet(server.CRMHandler):
def log_message(self, *a):
pass
def _req(port, method, path, token=None, body=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
headers = {}
if token:
headers["Authorization"] = "Bearer " + token
payload = None
if body is not None:
payload = json.dumps(body)
headers["Content-Type"] = "application/json"
conn.request(method, path, body=payload, headers=headers)
resp = conn.getresponse()
raw = resp.read().decode("utf-8", "replace")
conn.close()
data = None
if raw:
try:
data = json.loads(raw)
except ValueError:
pass
return resp.status, data
# One fund column so a non-zero cell rolls up into total_invested (the "existing LP" signal).
COLUMNS = [{"id": "fund1", "label": "Fund III", "isFund": True}]
ROW_ACME = {"id": "rowAcme", "investor_name": "Acme Capital", "priority": True, "fund1": 250000,
"contacts": [{"name": "Jane Doe", "email": "jane@acme.com", "title": "GP"}]}
ROW_BETA = {"id": "rowBeta", "investor_name": "Beta Capital", "fund1": 0,
"contacts": [{"name": "Pat Roe", "email": "pat@beta.com", "title": ""}]}
def _db():
return sqlite3.connect(os.environ["CRM_DB_PATH"])
def seed():
c = _db()
c.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
# A pure classic contact with NO fundraising-grid link (not an investor).
c.execute("INSERT INTO contacts (id,first_name,last_name,email,contact_type,status) "
"VALUES ('cLegacy','Vendor','Vince','vince@vendor.com','other','active')")
c.commit()
c.close()
def _by_email(contacts, email):
return next((c for c in contacts if (c.get("email") or "").lower() == email), None)
def main():
server.init_db()
seed()
token = server.create_token("u1", "grant", "admin")
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet)
port = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()
try:
st, _ = _req(port, "PUT", "/api/fundraising/state", token,
{"grid": {"columns": COLUMNS, "rows": [ROW_ACME, ROW_BETA]}, "views": []})
check(st == 200, f"seed grid via PUT /state (got {st})")
# Put Acme into the pipeline at 'engaged' so its contact's card shows a stage pill.
st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token,
{"source_row_id": "rowAcme", "stage": "engaged"})
check(st in (200, 201), f"link Acme to pipeline @engaged (got {st}, {d})")
st, d = _req(port, "GET", "/api/contacts?limit=500", token)
contacts = (d or {}).get("data") or []
check(st == 200 and contacts, f"GET /api/contacts (got {st}, {len(contacts)} contacts)")
jane = _by_email(contacts, "jane@acme.com")
pat = _by_email(contacts, "pat@beta.com")
vince = _by_email(contacts, "vince@vendor.com")
check(jane is not None, "Acme's synced contact Jane Doe is in the directory")
check(pat is not None, "Beta's synced contact Pat Roe is in the directory")
check(vince is not None, "the pure classic contact Vince is in the directory")
# ── existing-LP ring signal: committed reflects the linked investor's rollup ──
print("\n[committed: existing-LP ring driven by the linked investor's total_invested]")
check((jane or {}).get("committed") == 250000,
f"Jane.committed == 250000 (existing LP) (got {(jane or {}).get('committed')})")
check((pat or {}).get("committed") == 0,
f"Pat.committed == 0 (zero-commit prospect, no ring) (got {(pat or {}).get('committed')})")
check((vince or {}).get("committed") == 0,
f"Vince.committed == 0 (no grid link) (got {(vince or {}).get('committed')})")
# ── stage-pill signal: pipeline_stage is the investor's live derived stage ──
print("\n[pipeline_stage: stage pill driven by the investor's live opp stage]")
check((jane or {}).get("pipeline_stage") == "engaged",
f"Jane.pipeline_stage == 'engaged' (got {(jane or {}).get('pipeline_stage')!r})")
check((pat or {}).get("pipeline_stage") is None,
f"Pat.pipeline_stage is None (not in pipeline) (got {(pat or {}).get('pipeline_stage')!r})")
check((vince or {}).get("pipeline_stage") is None,
f"Vince.pipeline_stage is None (no grid link) (got {(vince or {}).get('pipeline_stage')!r})")
# ── 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]")
st, d = _req(port, "GET", f"/api/contacts/{jane['id']}", token)
detail = (d or {}).get("data") or {}
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})")
st, d = _req(port, "GET", f"/api/contacts/{vince['id']}", token)
vdetail = (d or {}).get("data") or {}
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})")
# ── 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]")
opp_id = None
st, d = _req(port, "GET", "/api/fundraising/state", token)
for r in (d or {}).get("data", {}).get("grid", {}).get("rows", []):
if r.get("id") == "rowAcme":
opp_id = r.get("opportunity_id")
st, _ = _req(port, "PATCH", f"/api/opportunities/{opp_id}/stage", token, {"stage": "diligence"})
check(st == 200, f"advance Acme's opp -> diligence (got {st})")
st, d = _req(port, "GET", "/api/contacts?limit=500", token)
jane2 = _by_email((d or {}).get("data") or [], "jane@acme.com")
check((jane2 or {}).get("pipeline_stage") == "diligence",
f"Jane.pipeline_stage re-derives to 'diligence' (got {(jane2 or {}).get('pipeline_stage')!r})")
# ── dedup: a contact linked to two investors exposes the highest-committed one ──
print("\n[dedup: highest-committed linked investor wins for a multi-linked contact]")
c = _db()
# Link Jane's classic contact to a SECOND, richer investor (direct rows — the grid sync
# makes one link per pill; this exercises the multi-link branch in contact_grid_signals).
c.execute("INSERT INTO fundraising_investors (id, investor_name, source_row_id, total_invested) "
"VALUES ('inv2','Mega Fund LP','rowMega',500000)")
c.execute("INSERT INTO fundraising_contacts (id, investor_id, full_name, contact_id) "
"VALUES ('fc2','inv2','Jane Doe',?)", (jane['id'],))
c.commit()
c.close()
st, d = _req(port, "GET", f"/api/contacts/{jane['id']}", token)
jd = (d or {}).get("data") or {}
check(jd.get("committed") == 500000,
f"multi-linked contact exposes the higher committed (500000 > 250000) (got {jd.get('committed')})")
finally:
httpd.shutdown()
print()
if FAILS:
print(f"FAILED ({len(FAILS)}):")
for m in FAILS:
print(" - " + m)
sys.exit(1)
print("All contacts-grid-signals tests passed.")
if __name__ == "__main__":
main()
+20 -3
View File
@@ -89,9 +89,17 @@ White (`#ffffff`) appears only as text on accent fills and in the brand mark.
(`translateY(100%)→0`, 280ms `cubic-bezier(.2,.8,.2,1)`), scrim `rgba(4,9,16,0.55)` fade-in, (`translateY(100%)→0`, 280ms `cubic-bezier(.2,.8,.2,1)`), scrim `rgba(4,9,16,0.55)` fade-in,
max-height ~8890%, scroll-body; tap-scrim or × to dismiss. One field/action per sheet. max-height ~8890%, scroll-body; tap-scrim or × to dismiss. One field/action per sheet.
- **Mobile card** (the table→card transform): panel, 1px border, **radius-10**, card shadow, - **Mobile card** (the table→card transform): panel, 1px border, **radius-10**, card shadow,
1214px padding; name 16/600 + Priority corner badge (top-right); mono amount + stage chip + 1214px padding, `overflow:hidden` (clips the corner earmark). Three rows: (1) investor
staleness last-contact. Existing-LP = quiet accent **corner earmark** (star is the lighter name 16/600 left + a right-aligned **PRIORITY text pill** (mono 10/600 amber, only when
alternative; not a per-card banner). flagged); (2) mono amount 15/600 (green if committed, muted `text-subtle` at zero) + the
**4-stage chip**; (3) mono recency 12 (amber ≥10d, red ≥30d). **Existing-LP = a quiet accent
corner earmark** (top-left corner-triangle, a reusable `EarmarkCorner` component) — *not* a
★ star and *not* a per-card banner (both were option-history, rejected 2026-06-19; finalized
in Phase 8a). The **Contacts** card is the sibling pattern: a 40px elevated avatar with
two-letter mono initials, an **accent ring** when the person is an existing LP (the linked
investor's committed > 0), org **+ a small stage pill** under the name, and a mono recency at
right (no disposition/type badge). Detail surfaces carry an **"Existing LP" pill** (accent-
tinted) beside Priority instead of the retired ★.
- **Full-screen detail** (promotes the desktop slide-over): `screenIn` slide (translateX 14px, - **Full-screen detail** (promotes the desktop slide-over): `screenIn` slide (translateX 14px,
200ms), " Grid" back affordance, grouped read-only sections with per-field edit entry points. 200ms), " Grid" back affordance, grouped read-only sections with per-field edit entry points.
- **Swipe actions** (Reminders): pointer-drag a card to reveal complete (swipe-left) / snooze - **Swipe actions** (Reminders): pointer-drag a card to reveal complete (swipe-left) / snooze
@@ -180,6 +188,15 @@ brief in `design/BRIEF.md`). The system:
The gap between this section and the current `index.html` is the implementation backlog in The gap between this section and the current `index.html` is the implementation backlog in
`ROADMAP.md` (incl. the inline-style→CSS migration and the locked pipeline-stages/flags spec). `ROADMAP.md` (incl. the inline-style→CSS migration and the locked pipeline-stages/flags spec).
The **per-surface mobile visual-conformance reference** is `design/phase8-conformance.md` (the
8a8i build spec, anchored on each `*.dc.html` default — not the `screenshots/` PNGs). The
**Grid + Contacts card anatomy is finalized (Phase 8a)** — earmark corner-triangle, PRIORITY
text pill, 4-stage chip, Contacts avatar ring (see the §4 mobile-card bullet). The **Contacts +
Pipeline detail surfaces are drag-dismiss bottom sheets (Phase 8b)** — email-copy pill + Log/Email
actions + Organization card (Contacts); stat tiles + inline move-stage list + notes timeline + Log
sheet (Pipeline); both log through `POST /api/communications`. Remaining 8c8i (quick-log pencil,
sort sheets, reminders/Pipeline-card polish, add-investor, shell icons) are pending; the Grid detail
stays full-screen (its dc default) with the G4/G5/G6 stage-card/reminder-card/timeline restructure to do.
## 9. Agent prompt guide ## 9. Agent prompt guide
+438 -156
View File
@@ -2106,6 +2106,9 @@
transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1); transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
} }
.bottom-sheet.open { transform: translateY(0); } .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 { .sheet-handle {
width: 38px; height: 4px; border-radius: 2px; width: 38px; height: 4px; border-radius: 2px;
background: var(--border-strong); background: var(--border-strong);
@@ -2219,24 +2222,32 @@
box-shadow: 0 14px 26px rgba(2,12,24,0.28), inset 0 1px 0 #ffffff07; box-shadow: 0 14px 26px rgba(2,12,24,0.28), inset 0 1px 0 #ffffff07;
} }
.contact-card:active { border-color: var(--border-strong); } .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 { .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; display: inline-flex; align-items: center; justify-content: center;
background: var(--accent-soft); color: var(--accent-light); background: var(--bg-panel-elevated); color: var(--accent-light);
font-weight: 600; font-size: 15px; 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; } .mobile-avatar.ring { border: 1.5px solid var(--accent); } /* existing LP (committed > 0) */
.contact-card-main { flex: 1; min-width: 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 { .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; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
.contact-card-sub { .contact-card-sub { display: flex; align-items: center; gap: 8px; min-width: 0; }
display: block; font-size: 13px; color: var(--text-muted); margin-top: 2px; .contact-card-org {
font-size: 13px; color: var(--text-muted); min-width: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; 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-recency {
.contact-card-date { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-subtle); } 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). */ /* Sort sheet rows (the BottomSheet's first consumer — read-only). */
.sheet-option { .sheet-option {
@@ -2311,24 +2322,41 @@
} }
.grid-card { .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); background: var(--bg-panel); border: 1px solid var(--border);
border-radius: var(--mobile-card-radius); border-radius: var(--mobile-card-radius);
padding: 12px 14px; margin-bottom: var(--mobile-card-gap); cursor: pointer; 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; 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: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.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; } /* Existing-LP earmark — quiet accent corner-triangle (top-left). Reusable across cards
.grid-card-name { (Grid now, Pipeline 8f). Clipped to the card's rounded corner by .grid-card overflow:hidden. */
font-size: var(--mobile-font-card-title); font-weight: 600; color: var(--text-primary); .lp-earmark {
padding-right: 22px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 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 { 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-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-aging { color: var(--recency-aging); }
.grid-card-recency.recency-stale { color: var(--recency-stale); } .grid-card-recency.recency-stale { color: var(--recency-stale); }
.stage-chip { .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--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--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--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. */ /* 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-row { display: flex; gap: 8px; flex-wrap: wrap; }
.fs-action-btn { .fs-action-btn {
flex: 1; min-width: 140px; min-height: var(--mobile-touch-target); 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 // 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 // (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. // 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 [mounted, setMounted] = useState(open);
const [shown, setShown] = useState(false); const [shown, setShown] = useState(false);
const [dragY, setDragY] = useState(0); const [dragY, setDragY] = useState(0);
@@ -3859,8 +3988,8 @@
return ( return (
<> <>
<div className={`sheet-scrim ${shown ? 'open' : ''}`} onClick={onClose} /> <div className={`sheet-scrim ${stacked ? 'stacked' : ''} ${shown ? 'open' : ''}`} onClick={onClose} />
<div className={`bottom-sheet ${shown ? 'open' : ''}`} style={sheetStyle} role="dialog" aria-modal="true"> <div className={`bottom-sheet ${stacked ? 'stacked' : ''} ${shown ? 'open' : ''}`} style={sheetStyle} role="dialog" aria-modal="true">
<div <div
className="sheet-grab" className="sheet-grab"
onPointerDown={onPointerDown} 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) ───────────────────── /* ─── Shared grid helpers (Phase 3 — mobile Fundraising Grid) ─────────────────────
Pure functions so the mobile card list filters rows the SAME way the desktop grid's 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. // 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 || ''); const s = String(stage || '');
if (!s) return null; if (!s) return null;
// Colors live in .stage-chip--{stage} (theme-bound CSS vars) so chips flip with [data-theme]. // Colors live in .stage-chip--{stage} (theme-bound CSS vars) so chips flip with [data-theme].
return ( return (
<span className={`stage-chip stage-chip--${s}`}> <span className={`stage-chip stage-chip--${s}${sm ? ' stage-chip--sm' : ''}`}>
{pipelineStageLabel(s)} {pipelineStageLabel(s)}
</span> </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 LoginPage = () => {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -5044,101 +5255,116 @@
{ id: 'recent', label: 'Recently contacted', short: 'Recent' }, { 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 [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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
const r = await api(`/api/contacts/${contact.id}`, {}, token); const r = await api(`/api/contacts/${contact.id}`, {}, token);
if (!cancelled) setDetails(r.data); if (!cancelled) setDetails(r.data);
} catch (err) { } catch (err) { if (!cancelled) onShowToast(getErrorMessage(err, 'Failed to load contact'), 'error'); }
if (!cancelled) onShowToast(getErrorMessage(err, 'Failed to load contact'), 'error');
} finally {
if (!cancelled) setLoading(false);
}
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [contact.id, token]); }, [contact.id, token, reload]);
const base = details || contact; // Local open-state drives the slide-out before the parent unmounts us.
const name = `${base.first_name || ''} ${base.last_name || ''}`.trim() || base.email || 'Contact'; const close = () => { setOpen(false); setTimeout(onClose, 280); };
const initial = (base.last_name || base.first_name || name || '?').charAt(0).toUpperCase();
const org = base.organization || base.organization_name || ''; const name = `${contact.first_name || ''} ${contact.last_name || ''}`.trim() || contact.email || 'Contact';
const type = base.contact_type || 'other'; const org = contact.organization || contact.organization_name || '';
const location = details const existing = Number(contact.committed || 0) > 0;
? ([details.city, details.state, details.country].filter(Boolean).join(', ') || details.location_query || '') 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 ( return (
<div className="fs-detail" role="dialog" aria-modal="true"> <BottomSheet open={open} onClose={close}>
<div className="fs-detail-header"> <div className="dsheet-avatar-row">
<button className="fs-detail-back" onClick={onClose}> Contacts</button> <span className={`mobile-avatar lg${existing ? ' ring' : ''}`}>{contactInitials(contact)}</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 }}> <span style={{ minWidth: 0, flex: 1 }}>
<div className="fs-detail-title">{name}</div> <div className="dsheet-title">{name}</div>
<div className="fs-detail-subtitle">{org || '—'}</div> <div className="dsheet-sub">{org || '—'}</div>
</span> </span>
<span className={`badge ${contactTypeBadgeClass(type)}`}>{type}</span>
</div> </div>
{loading ? <SkeletonBlock lines={6} /> : ( {contact.email && (
<> <button className="email-pill" onClick={copyEmail}>
<div className="fs-section"> <span style={{ minWidth: 0 }}>
<div className="fs-section-label">Contact</div> <span className="email-pill-label">Email</span>
<MobileDetailRow label="Email" value={base.email} mono copyable onShowToast={onShowToast} /> <span className="email-pill-addr">{contact.email}</span>
<MobileDetailRow label="Phone" value={base.phone} mono /> </span>
<MobileDetailRow label="Title" value={base.title} /> <span className="email-pill-copy">copy</span>
<MobileDetailRow label="Organization" value={org} /> </button>
<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="detail-actions">
<div className="fs-section-label">Communication History</div> <button className="detail-btn detail-btn--primary" onClick={() => setLogOpen(true)}>Log communication</button>
{details && details.communications && details.communications.length > 0 ? ( {contact.email
<div className="timeline"> ? <a className="detail-btn detail-btn--secondary" href={`mailto:${contact.email}`}>Email</a>
{details.communications.map((cm) => ( : <button className="detail-btn detail-btn--secondary" disabled>Email</button>}
<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 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>
))} <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> </div>
) : (
<div style={{ color: 'var(--text-subtle)', fontSize: '13px' }}>No communications logged.</div>
)} )}
{onNavigate && <button className="org-open-link" onClick={() => { onNavigate('fundraising-grid'); close(); }}>Open investor in Grid </button>}
</div> </div>
</>
)} <LogCommunicationSheet open={logOpen} onClose={() => setLogOpen(false)} onSubmit={submitLog} busy={busy} forLabel={name} />
</div> </BottomSheet>
</div>
); );
}; };
const MobileContactsPage = ({ token, onShowToast }) => { const MobileContactsPage = ({ token, onShowToast, onNavigate }) => {
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('');
@@ -5207,19 +5433,25 @@
const renderCard = (c) => { const renderCard = (c) => {
const org = c.organization || c.organization_name || ''; const org = c.organization || c.organization_name || '';
const type = c.contact_type || 'other'; // Existing-LP ring + stage pill come from the grid signals the API injects (committed,
const initial = (sortBasis(c).charAt(0) || displayName(c).charAt(0) || '?').toUpperCase(); // 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 ( return (
<button className="contact-card" key={c.id} onClick={() => setSelected(c)}> <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-main">
<span className="contact-card-name">{displayName(c)}</span> <span className="contact-card-name">{displayName(c)}</span>
<span className="contact-card-sub">{org || '—'}</span> <span className="contact-card-sub">
<span className="contact-card-org">{org || '—'}</span>
{c.pipeline_stage && <StageChip stage={c.pipeline_stage} sm />}
</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> </span>
{c.last_contact_date && (
<span className={`contact-card-recency ${recencyClassForDays(days)}`}>
{days == null ? '' : formatAgeShort(days) + (days <= 0 ? '' : ' ago')}
</span>
)}
</button> </button>
); );
}; };
@@ -5286,6 +5518,7 @@
token={token} token={token}
onClose={() => setSelected(null)} onClose={() => setSelected(null)}
onShowToast={onShowToast} onShowToast={onShowToast}
onNavigate={onNavigate}
/> />
)} )}
</div> </div>
@@ -5843,7 +6076,9 @@
const [error, setError] = useState(''); const [error, setError] = useState('');
const [activeStage, setActiveStage] = useState(0); const [activeStage, setActiveStage] = useState(0);
const [selectedId, setSelectedId] = useState(null); 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 [busy, setBusy] = useState(false);
const swipeRef = useRef(null); const swipeRef = useRef(null);
@@ -5877,6 +6112,27 @@
const selectedOpp = useMemo(() => opportunities.find((o) => o.id === selectedId) || null, [opportunities, selectedId]); 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) => { const patchStage = async (oppId, stage) => {
setBusy(true); setBusy(true);
try { try {
@@ -5890,6 +6146,23 @@
} finally { setBusy(false); } } 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). // / on a card: advance/retreat one stage (kanban move without opening the detail).
const moveStage = async (opp, dir) => { const moveStage = async (opp, dir) => {
if (busy) return; if (busy) return;
@@ -5919,7 +6192,7 @@
const sub = [contactName(opp), opp.organization_name].filter((x) => x && x !== '-').join(' · '); const sub = [contactName(opp), opp.organization_name].filter((x) => x && x !== '-').join(' · ');
return ( return (
<div className="pipeline-card" key={opp.id}> <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> <div className="pipeline-card-name">{opp.name}</div>
{sub && <div className="pipeline-card-sub">{sub}</div>} {sub && <div className="pipeline-card-sub">{sub}</div>}
<div className={`pipeline-card-amount${amount > 0 ? '' : ' zero'}`}>{formatCurrencyLong(amount)}</div> <div className={`pipeline-card-amount${amount > 0 ? '' : ' zero'}`}>{formatCurrencyLong(amount)}</div>
@@ -5982,58 +6255,64 @@
const prob = (Number(opp.probability) || 0) > 1 const prob = (Number(opp.probability) || 0) > 1
? `${opp.probability}%` ? `${opp.probability}%`
: `${Math.round((Number(opp.probability) || 0) * 100)}%`; : `${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 ( return (
<div className="fs-detail" role="dialog" aria-modal="true"> <BottomSheet open={detailOpen} onClose={closeDetail}>
<div className="fs-detail-header"> <div className="dsheet-head">
<button className="fs-detail-back" onClick={() => { setSelectedId(null); setSheetOpen(false); }}> Pipeline</button> <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> </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> </span>
{opp.priority === 'high' && <span className="badge" style={{ background: 'var(--badge-priority-bg)', color: 'var(--badge-priority-text)' }}>Priority</span>} <button className="sheet-close" aria-label="Close" onClick={closeDetail}>×</button>
</div> </div>
<div className="fs-section"> <div className="stat-tiles">
<div className="fs-section-label">Pipeline</div> <div className="stat-tile">
<div className="fs-row"> <span className="stat-tile-label">Committed</span>
<span className="fs-row-label">Stage</span> <span className={`stat-tile-value${existing ? '' : ' zero'}`}>{formatMoneyMobile(committed)}</span>
<span className="fs-row-value"><StageChip stage={opp.stage} /></span>
</div> </div>
<div className="fs-action-row" style={{ marginTop: '10px' }}> <div className="stat-tile">
<button className="fs-action-btn" onClick={() => setSheetOpen(true)}>Change stage</button> <span className="stat-tile-label">Contacts</span>
<span className="stat-tile-value text">{contactLine}</span>
</div> </div>
</div> </div>
<div className="fs-section"> <div className="fs-section-label" style={{ margin: '18px 0 9px' }}>Move stage</div>
<div className="fs-section-label">Deal</div> <div className="move-stage-list">
<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>
</div>
<BottomSheet open={sheetOpen} onClose={() => setSheetOpen(false)} title="Pipeline stage">
{stages.map((st) => ( {stages.map((st) => (
<button <button key={st} className={`move-stage-row ${opp.stage === st ? 'active' : ''}`} disabled={busy}
key={st} onClick={async () => { await patchStage(opp.id, st); }}>
className={`sheet-option ${opp.stage === st ? 'active' : ''}`} <StageChip stage={st} />
disabled={busy} <span className="move-stage-check">{opp.stage === st ? '✓' : ''}</span>
onClick={async () => { if (await patchStage(opp.id, st)) setSheetOpen(false); }}
>
<span>{pipelineStageLabel(st)}</span>
{opp.stage === st && <span className="sheet-option-check"></span>}
</button> </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> </div>
@@ -9656,17 +9935,20 @@
const committed = gridRollup(row, fundColumnIds); const committed = gridRollup(row, fundColumnIds);
const days = daysSince(row.last_activity_at); const days = daysSince(row.last_activity_at);
const recencyCls = row.staleness === 'stale' ? 'recency-stale' : row.staleness === 'aging' ? 'recency-aging' : ''; 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 ( return (
<button className={cls} key={row.id} onClick={() => setSelectedId(row.id)}> <button className={cls} key={row.id} onClick={() => setSelectedId(row.id)}>
{row.priority && <span className="grid-card-priority" title="Priority"></span>} {row.existing_investor && <EarmarkCorner />}
<div className="grid-card-name">{row.investor_name || 'Unnamed investor'}</div> <div className="grid-card-row1">
<div className="grid-card-meta"> <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> <span className={`grid-card-amount${committed > 0 ? '' : ' zero'}`}>{formatMoneyMobile(committed)}</span>
{row.pipeline && <StageChip stage={row.pipeline_stage} />} {row.pipeline && <StageChip stage={row.pipeline_stage} />}
<span className={`grid-card-recency ${recencyCls}`}> </div>
<div className={`grid-card-recency ${recencyCls}`}>
{days == null ? 'no activity' : formatAgeShort(days) + (days <= 0 ? '' : ' ago')}{row.staleness === 'stale' ? ' · stale' : ''} {days == null ? 'no activity' : formatAgeShort(days) + (days <= 0 ? '' : ' ago')}{row.staleness === 'stale' ? ' · stale' : ''}
</span>
</div> </div>
</button> </button>
); );
@@ -9750,13 +10032,13 @@
<div className="fs-detail-body"> <div className="fs-detail-body">
<div className="fs-detail-id"> <div className="fs-detail-id">
<span style={{ minWidth: 0, flex: 1 }}> <span style={{ minWidth: 0, flex: 1 }}>
<div className="fs-detail-title"> <div className="fs-detail-title">{row.investor_name || 'Unnamed investor'}</div>
{row.existing_investor && <span className="fs-detail-star" title="Existing investor"></span>}
{row.investor_name || 'Unnamed investor'}
</div>
<div className="fs-detail-subtitle">{formatMoneyMobile(committed)} committed{row.lead ? ` · ${row.lead}` : ''}</div> <div className="fs-detail-subtitle">{formatMoneyMobile(committed)} committed{row.lead ? ` · ${row.lead}` : ''}</div>
</span> </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>
<div className="fs-section"> <div className="fs-section">
@@ -13594,7 +13876,7 @@
/> />
)} )}
{page === 'dashboard' && <DashboardPage token={token} />} {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 === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} />}
{page === 'reminders' && <RemindersPage token={token} user={user} onShowToast={showToast} />} {page === 'reminders' && <RemindersPage token={token} user={user} onShowToast={showToast} />}
{page === 'communications' && <CommunicationsPage token={token} user={user} onShowToast={showToast} />} {page === 'communications' && <CommunicationsPage token={token} user={user} onShowToast={showToast} />}