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