diff --git a/AGENTS.md b/AGENTS.md index 05dc137..7cce4ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude ## Conventions -- **Investor model — the grid is canonical (since v0.1.0:78).** The `fundraising_*` grid is the **system of record**: an investor entity (row) → many contact "pills" → per-fund commitments. The classic `contacts` table is a **read-only per-person directory**, auto-populated from the grid — create/edit people in the grid, not the Contacts page. Email capture rolls multiple people up to one investor. The legacy single-fund `lp_profiles` model is **retired** (empty table kept, per never-hard-delete). Reconciling grid ↔ classic `contacts` to canonical IDs is the core entity-resolution task — see `docs/crm-overview.md`. **Derived read-only columns** (`pipeline`, `pipeline_stage`, `reminder_status`, `existing_investor`, `last_activity_at`, `staleness`) are computed live and **injected on GET, never persisted** — any new one MUST be added to BOTH strip points (`server.py` `_computed_row_values` + frontend `stripComputedRows`) or it dirties the autosave / leaks into the blob. Pipeline stage is the 4-stage funnel `lead→engaged→diligence→commitment` (`PIPELINE_STAGES`), terminal at commitment. +- **Investor model — the grid is canonical (since v0.1.0:78).** The `fundraising_*` grid is the **system of record**: an investor entity (row) → many contact "pills" → per-fund commitments. The classic `contacts` table is a **read-only per-person directory**, auto-populated from the grid — create/edit people in the grid, not the Contacts page. Email capture rolls multiple people up to one investor. The legacy single-fund `lp_profiles` model is **retired** (empty table kept, per never-hard-delete). Reconciling grid ↔ classic `contacts` to canonical IDs is the core entity-resolution task — see `docs/crm-overview.md`. **Derived read-only columns** (`pipeline`, `pipeline_stage`, `opportunity_id`, `reminder_status`, `existing_investor`, `last_activity_at`, `staleness`) are computed live and **injected on GET, never persisted** — any new one MUST be added to BOTH strip points (`server.py` `_computed_row_values` + frontend `stripComputedRows`) or it dirties the autosave / leaks into the blob. Pipeline stage is the 4-stage funnel `lead→engaged→diligence→commitment` (`PIPELINE_STAGES`), terminal at commitment. - **Soft-delete only:** `deleted_at` and/or `status='retired'`; never hard-delete. Every READ path must filter `deleted_at IS NULL` — list handlers, get-by-id, nested related-data sub-selects, **and aggregate sub-selects (`COUNT`/`SUM`/`MAX`)**. Audits found leaks in all of these (2026-06-12 detail + nested; 2026-06-13 list-view `contact_count`/`total_funded`/`comm_count`); the **opportunities/pipeline** aggregates were fixed in v0.1.0:87 (`handle_pipeline_report` + dashboard pipeline metrics now filter `deleted_at`), but the **reports** subsystem's **communications-side** aggregates (dashboard `recent_comms`/`comms_this_month`/`meetings_this_month`, activity report) still leak (see Current state). Regression-guarded by `backend/test_soft_delete_reads.py` (+ `test_reminders.py` for the reminders read paths, incl. the recency rollup whose email-activity liveness signal is `email_account_messages.deleted_at`, not `emails`). (Thesis has a subtlety here — see the thesis guide.) - **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`. @@ -107,14 +107,15 @@ 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 Phase 0 + Phase 1 (committed `634fc42`, **deploy-pending**) + **Phase 2 Contacts (built, uncommitted)**. **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign implementation** (design distilled into the contract; Phases 0–2 built, **P3 Grid next** — the crux). Full mobile plan + backlog/debt: `ROADMAP.md` / `EVALUATION.md`; history: git log + `start9/0.4/startos/versions/`._ +_**Box live at v0.1.0:94**; `main` ahead by Phase 0 + Phase 1 (committed `634fc42`) + Phase 2 Contacts (committed `984b950`), all **deploy-pending**, + **Phase 3a Grid (built, uncommitted)**. **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign implementation** (Phases 0–2 + P3a built; **P4 Pipeline next**, then P5 Reminders, P6 light theme; P3b name/pill-edit deferred). Full mobile plan + backlog/debt: `ROADMAP.md` / `EVALUATION.md`; history: git log + `start9/0.4/startos/versions/`._ - **Mobile redesign — design DONE, implementation underway.** Plan + scoping in `ROADMAP.md` "Mobile-first implementation": the inline-style→CSS migration is **~114 styles across 4 surfaces+shell** (not ~1,300), divisible per-surface; two axes (responsive layout→classes; theming inline-hex→`var()`). Sequence: P0 data layer → P1 foundation → **P2 Contacts** → P3 Grid → P4 Pipeline → P5 Reminders → P6 light theme. - **Phase 0 (pipeline-stages/flags data layer) — BUILT, committed `e46dd36`, deploy-pending.** Enum→4 stages (`lead/engaged/diligence/commitment`) + migration `0007` (no-op on the live DB — 0 opps) + read-only `existing_investor`/`last_activity_at`/`staleness` injected into grid GET (stripped on write). Visible star/staleness column + Stale view deferred to P3. - **Phase 1 (mobile foundation) — BUILT, committed `634fc42`, deploy-pending.** `:root` mobile vars + `.bottom-tab-bar` (4 tabs wired in `App`) + mobile account popover + `.bottom-sheet`/`.mobile-only`/`.desktop-only` CSS — all `display:none` desktop (zero desktop change). `` component + `useIsMobile()` + per-surface 15px bump deferred to P2. -- **Phase 2 (Contacts surface) — BUILT 2026-06-19, uncommitted, deploy-pending.** Read-only mobile A–Z directory (sticky letter headers, last-name sort) + segmented All/Investors/Prospects tabs + pinned search → **full-screen detail** (`.fs-detail`: info w/ tap-to-copy email, opportunities, comm history) → **sort BottomSheet** (the sheet's first, read-only consumer). **Landed the shared primitives:** `` (scrim/Escape/pointer drag-to-dismiss; built on Phase-1 `.bottom-sheet` CSS) + `useIsMobile()` (768px `matchMedia`). `ContactsPage` is now a rules-of-hooks-safe wrapper → `Desktop`/`MobileContactsPage` (**desktop untouched**). Read-only per `BRIEF.md` §3b — no writes. Verified: render-smoke green + a throwaway jsdom interaction harness at 375px (14/14: list/grouping/sort-sheet/detail/back). **Browser/real-phone check still pending** (like P1). +- **Phase 2 (Contacts surface) — BUILT 2026-06-19, committed `984b950`, deploy-pending.** Read-only mobile A–Z directory (sticky letter headers, last-name sort) + segmented All/Investors/Prospects tabs + pinned search → **full-screen detail** (`.fs-detail`: info w/ tap-to-copy email, opportunities, comm history) → **sort BottomSheet** (the sheet's first, read-only consumer). **Landed the shared primitives:** `` (scrim/Escape/pointer drag-to-dismiss; built on Phase-1 `.bottom-sheet` CSS) + `useIsMobile()` (768px `matchMedia`). `ContactsPage` is now a rules-of-hooks-safe wrapper → `Desktop`/`MobileContactsPage` (**desktop untouched**). Read-only per `BRIEF.md` §3b — no writes. Verified: render-smoke green + a throwaway jsdom interaction harness at 375px (14/14: list/grouping/sort-sheet/detail/back). **Browser/real-phone check still pending** (like P1). +- **Phase 3a (Fundraising Grid — the crux) — BUILT 2026-06-19, uncommitted, deploy-pending.** Lean **`MobileFundraisingGrid`** (separate component — the desktop grid's whole-grid-PUT autosave would race on every mobile edit, so it's NOT reused; `FundraisingGridPage` is now a `useIsMobile()` wrapper → `Desktop`/`Mobile`, desktop untouched). Card list over the **active view** (ported the view-filter predicate to a shared pure helper, no drift), view-picker sheet, search, locked **card model** (committed $ · stage chip · staleness recency · Existing-Investor accent · Priority corner; graveyard muted). Detail = read-only commitments/pills/notes + **edit sheets**: log-note (`log-communication`), pipeline stage (`PATCH /api/opportunities/{id}/stage` via the new injected **`opportunity_id`**; or `pipeline/link`), set-reminder, and **`+ New` investor** (`log-communication`+`create_investor_if_missing`, dedup typeahead). **Never whole-grid PUT.** Backend: read-only `opportunity_id` injected (`opportunity_id_by_source_row`, both strip points). Tests: `test_grid_pipeline_link` extended, 36/36 green; render-smoke green; throwaway jsdom harness drove the real surface at 375px (18/18). **P3b deferred:** name/contact-pill editing on existing rows (needs `POST /api/fundraising/update-row` + a pill editor). **Real-phone check pending** (like P1/P2). - **Also deploy-pending:** drag-reorder grid views (frontend-only) — bundle into the next s9pk build. - **Live:** 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 drafts — all draft-only. - **Tests:** **36/36 backend green** (`python3 backend/run_tests.py`), `py_compile` clean, render-smoke green, fresh-DB migrate clean. -- **Next:** 1) **P3 Grid** (the crux; reuses Phase 2's ``/`useIsMobile()`; writes via one-row `log-communication` + pipeline link→stage, never whole-grid PUT; render the existing-investor star + staleness); 2) **P4 Pipeline → P5 Reminders → P6 light theme**; 3) **deploy** P0+P1+P2+view-reorder in one s9pk (**authorize first**); 4) W2 web Ask box + smoke; 5) W3 bot grid-mutations; 6) W1b nurture-gap; then P2 debt. -- **Open / risks:** P0+P1+P2 **built but not deployed** (P2 also uncommitted); P1/P2 mobile surfaces **browser-untested** on a real phone (render-smoke + jsdom interaction smoke only — verify the bottom bar + Contacts list/detail on a device, like view-reorder); **3 of 4 mobile surfaces still unbuilt** (Grid heaviest, ~70 styles + the two-call stage write; then Pipeline, Reminders). 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. +- **Next:** 1) **P4 Pipeline** (swipe-between-stages; reuses the Grid detail's opportunities endpoints + ``/`StageChip`); 2) **P5 Reminders → P6 light theme**; 3) **P3b** name/contact-pill editing (narrow per-row PATCH + pill editor); 4) **deploy** P0+P1+P2+P3a+view-reorder in one s9pk (**authorize first**); 5) W2 web Ask box + smoke; 6) W3 bot grid-mutations; 7) W1b nurture-gap. +- **Open / risks:** P0–P2 + P3a **built but not deployed** (P3a also uncommitted); P1/P2/P3a mobile surfaces **browser-untested** on a real phone (render-smoke + jsdom interaction smoke only — verify on a device, like view-reorder); **2 of 4 mobile surfaces still unbuilt** (Pipeline, Reminders); **P3b** (name/pill edit on existing rows) deferred. 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. diff --git a/ROADMAP.md b/ROADMAP.md index ad5c465..9f12700 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -352,13 +352,31 @@ migration into each surface's build, behind one shared foundation step. No upfro Verified: render-smoke green + a throwaway jsdom interaction harness (mounted the real app at 375px, stubbed `/api/contacts` — list/grouping/sort-sheet/detail/back all asserted, 14/14). **No browser/real-phone check yet** (same deferral as Phase 1 + view-reorder). **Deploy:** folds into the next s9pk build. -- **Phase 3 — Fundraising Grid (the crux).** ~70 inline styles → classes. Card list + bottom-sheet view - picker + search; full-screen detail with per-field bottom-sheet edits (name, contact pills, stage, - reminder, log note) + the `+`-create flow with client-side dedup typeahead. **Writes per `BRIEF.md` - §3a "Backend reality":** single-investor edits → `POST /api/fundraising/log-communication` (one-row, - no version race; can create investor+contact); stage → `POST /api/fundraising/pipeline/link` (needs ≥1 - contact) then `PATCH /api/opportunities/{id}/stage`; commitments/amounts read-only; **never whole-grid - `PUT /state`**. Renders Phase 0's stage chip + Existing-Investor star + staleness ramp. +- **Phase 3 — Fundraising Grid (the crux). P3a BUILT 2026-06-19 (deploy pending); P3b (name/pill edit) deferred.** + Split confirmed with Grant 2026-06-19: P3a ships the readable + already-write-supported surface now; + editing an existing investor's **name + contact pills** is **P3b** (needs a new narrow per-row PATCH + + a pill-editor UI — `log-communication` can't rename/edit pills, and the whole-grid PUT is forbidden on + mobile). + - **P3a (built):** lean **`MobileFundraisingGrid`** (separate component — the desktop grid's debounced + whole-grid-PUT autosave would race on every mobile edit, so it's NOT reused; `FundraisingGridPage` is + now a `useIsMobile()` wrapper → `Desktop`/`Mobile`, desktop untouched). Card list over the **active + view** (ported the desktop view-filter predicate — graveyard/follow-up/lead flags + columnFilters — to + a shared pure helper so it can't drift), tappable view-name → **view-picker sheet**, search, the locked + **card model** (name · committed $ via `formatMoneyMobile` · stage chip · staleness-colored recency · + Existing-Investor left-accent · Priority corner; graveyard muted). Full-screen detail (read-only: + commitments/funds, contact pills, notes) + **edit sheets**: **log a note** (`log-communication`), + **pipeline stage** (linked → `PATCH /api/opportunities/{id}/stage` via the new injected `opportunity_id`; + unlinked → `pipeline/link` then it; + remove-from-pipeline), **set a reminder** (`POST /api/reminders`), + and **`+ New` investor** (`log-communication` + `create_investor_if_missing`, client-side dedup + typeahead). **Never whole-grid `PUT /state`.** Backend: one small hook — read-only **`opportunity_id`** + injected into grid rows (`opportunity_id_by_source_row`, added to both strip points), so the detail can + PATCH the linked opp directly. Tests: `test_grid_pipeline_link` extended (opp_id inject/strip/round-trip), + 36/36 green; render-smoke green; a throwaway stateful jsdom harness drove the real surface at 375px + (view filter, picker, detail, stage-PATCH, log-note, reminder, create+dedup — 18/18). **No real-phone + check yet** (same deferral as P1/P2). **Deploy:** folds into the next s9pk. + - **P3b (deferred):** `POST /api/fundraising/update-row` (version-safe single-row name/contacts mutation, + +test) + the bottom-sheet **pill editor** (add/edit/remove pills, client-side dedup). Then name + pills + become editable on an existing investor, completing BRIEF §3a's editable set. - **Phase 4 — Pipeline.** ~7 inline styles. Swipe-between-stages (snap-scroll + segmented control + dots), per-card stage move sharing the Grid detail's opportunities endpoints. - **Phase 5 — Reminders.** ~18 inline styles. Urgency-grouped list, swipe complete/snooze, add/edit diff --git a/backend/server.py b/backend/server.py index 34cd4f7..2602ee5 100644 --- a/backend/server.py +++ b/backend/server.py @@ -1596,8 +1596,8 @@ def sanitize_fundraising_grid(grid): # linked opportunity and injected on read — never persisted as row data (the GET handler # re-injects them after sanitize). The column DEFINITIONS persist like any other column # so their position / width / hidden state is kept. - _computed_row_values = ('longshot_followup', 'pipeline', 'pipeline_stage', 'reminder_status', - 'existing_investor', 'last_activity_at', 'staleness') + _computed_row_values = ('longshot_followup', 'pipeline', 'pipeline_stage', 'opportunity_id', + 'reminder_status', 'existing_investor', 'last_activity_at', 'staleness') clean_columns = [] seen = set() @@ -1698,12 +1698,17 @@ def pipeline_stage_by_source_row(conn): of truth, so this is always derived fresh and injected as read-only grid columns — never stored in the grid blob, where it could go stale.""" out = {} + # ORDER BY created_at DESC so the EARLIEST opp is processed last and wins the overwrite — + # matching handle_pipeline_link's canonical choice (ORDER BY created_at LIMIT 1). The link + # enforces one live opp per investor, so multi-opp is an out-of-band anomaly; this just keeps + # the injection deterministic and consistent with opportunity_id_by_source_row if it occurs. for r in conn.execute( """ SELECT fi.source_row_id AS srid, o.stage AS stage FROM opportunities o JOIN fundraising_investors fi ON o.fundraising_investor_id = fi.id WHERE o.deleted_at IS NULL + ORDER BY o.created_at DESC """ ).fetchall(): srid = str(r['srid'] or '') @@ -1712,6 +1717,29 @@ def pipeline_stage_by_source_row(conn): return out +def opportunity_id_by_source_row(conn): + """Return {grid source_row_id: live opportunity id} for every investor with a non-deleted + linked opportunity. Injected read-only alongside pipeline_stage so the mobile grid detail can + PATCH /api/opportunities/{id}/stage directly (the same endpoint the Pipeline board uses) — the + grid row otherwise carries no opp id. Never persisted; stripped on write like pipeline_stage.""" + out = {} + # See pipeline_stage_by_source_row: earliest opp wins (ORDER BY created_at DESC + overwrite) so + # opportunity_id and pipeline_stage always reference the SAME opp, matching the link's canonical pick. + for r in conn.execute( + """ + SELECT fi.source_row_id AS srid, o.id AS opp_id + FROM opportunities o + JOIN fundraising_investors fi ON o.fundraising_investor_id = fi.id + WHERE o.deleted_at IS NULL + ORDER BY o.created_at DESC + """ + ).fetchall(): + srid = str(r['srid'] or '') + if srid: + out[srid] = r['opp_id'] + return out + + def last_activity_by_investor(conn): """Return {fundraising_investors.id: latest activity ISO timestamp} across captured emails (grid-linked) and logged communications — the per-investor recency signal behind @@ -5562,6 +5590,7 @@ class CRMHandler(BaseHTTPRequestHandler): self._ensure_fundraising_state_row(conn) row = conn.execute("SELECT * FROM fundraising_state WHERE id = 'main'").fetchone() stage_by_row = pipeline_stage_by_source_row(conn) + opp_id_by_row = opportunity_id_by_source_row(conn) reminder_by_row = reminder_status_by_source_row(conn) existing_by_row = existing_investor_by_source_row(conn) recency_by_row = staleness_by_source_row(conn) @@ -5592,6 +5621,9 @@ class CRMHandler(BaseHTTPRequestHandler): stage = stage_by_row.get(str(r.get('id') or '')) r['pipeline'] = bool(stage) r['pipeline_stage'] = stage or '' + # Live opportunity id for a linked row (read-only) — lets the mobile detail PATCH the + # stage on the opportunities endpoint; '' when the row isn't in the pipeline. + r['opportunity_id'] = opp_id_by_row.get(str(r.get('id') or ''), '') # Read-only reminder status, derived live from the reminders table (never stored # in the blob). '' = no open reminder; a saved view can filter on this column to # supersede the binary follow_up checkbox. diff --git a/backend/test_grid_pipeline_link.py b/backend/test_grid_pipeline_link.py index 93e653c..419bef2 100644 --- a/backend/test_grid_pipeline_link.py +++ b/backend/test_grid_pipeline_link.py @@ -167,13 +167,19 @@ def main(): and rows.get("rowAcme", {}).get("pipeline_stage") == "diligence", f"rowAcme pipeline true @diligence (got {rows.get('rowAcme', {}).get('pipeline')}, " f"{rows.get('rowAcme', {}).get('pipeline_stage')})") + # Read-only opportunity_id is injected for a linked row so the mobile grid detail can + # PATCH the stage on the opportunities endpoint (the grid row carries no opp id otherwise). + check(rows.get("rowAcme", {}).get("opportunity_id") == opp_id, + f"rowAcme carries the live opportunity_id (got {rows.get('rowAcme', {}).get('opportunity_id')}, want {opp_id})") # Phase 0 derived signals are injected read-only on every row (values depend on seed; # assert the keys are present so the strip/inject round-trip below is meaningful). check(all(k in rows.get("rowAcme", {}) for k in ("existing_investor", "staleness", "last_activity_at")), f"rowAcme carries derived existing_investor/staleness/last_activity (keys: {sorted(rows.get('rowAcme', {}).keys())})") check(rows.get("rowBeta", {}).get("pipeline") is False - and rows.get("rowBeta", {}).get("pipeline_stage") == "", - f"rowBeta not in pipeline (got {rows.get('rowBeta', {}).get('pipeline')})") + and rows.get("rowBeta", {}).get("pipeline_stage") == "" + and rows.get("rowBeta", {}).get("opportunity_id") == "", + f"rowBeta not in pipeline (got {rows.get('rowBeta', {}).get('pipeline')}, " + f"opp_id={rows.get('rowBeta', {}).get('opportunity_id')!r})") # ── round-trip: a save echoing the injected read-only values is lossless ── print("\n[round-trip: PUT carrying injected pipeline values strips them, link intact]") @@ -186,13 +192,14 @@ def main(): blob = json.loads(c.execute("SELECT grid_json FROM fundraising_state WHERE id='main'").fetchone()[0]) c.close() stored_acme = {r["id"]: r for r in blob.get("rows", [])}.get("rowAcme", {}) - check(not any(k in stored_acme for k in ("pipeline", "pipeline_stage", "existing_investor", - "staleness", "last_activity_at")), - "computed keys (pipeline + existing_investor/staleness/last_activity) NOT persisted into the grid blob") + check(not any(k in stored_acme for k in ("pipeline", "pipeline_stage", "opportunity_id", + "existing_investor", "staleness", "last_activity_at")), + "computed keys (pipeline + opportunity_id + existing_investor/staleness/last_activity) NOT persisted into the grid blob") st, d = _req(port, "GET", "/api/fundraising/state", token) rt = {r["id"]: r for r in (d or {}).get("data", {}).get("grid", {}).get("rows", [])}.get("rowAcme", {}) - check(rt.get("pipeline") is True and rt.get("pipeline_stage") == "diligence", - f"pipeline values re-injected after round-trip (got {rt.get('pipeline')}, {rt.get('pipeline_stage')})") + check(rt.get("pipeline") is True and rt.get("pipeline_stage") == "diligence" + and rt.get("opportunity_id") == opp_id, + f"pipeline values re-injected after round-trip (got {rt.get('pipeline')}, {rt.get('pipeline_stage')}, opp_id={rt.get('opportunity_id')!r})") check(all(k in rt for k in ("existing_investor", "staleness", "last_activity_at")), "derived signals re-injected after round-trip") diff --git a/frontend/index.html b/frontend/index.html index 52cfa3e..1c71127 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2136,6 +2136,95 @@ .fs-row-value.mono { font-family: 'IBM Plex Mono', monospace; } .fs-copy-hint { color: var(--accent); margin-left: 6px; font-size: 12px; } + /* ─── Phase 3 — Fundraising Grid mobile surface (card list → detail → edit sheets) ── + JS-gated to MobileFundraisingGrid; reuses the Phase-2 .fs-detail / .sheet / .mobile-* + patterns. Card model is the locked spec (ROADMAP "Pipeline stages + investor flags"). */ + .grid-toolbar-row { display: flex; gap: 8px; } + .view-picker-btn { + flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: 8px; + height: var(--mobile-input-h); padding: 0 12px; + background: var(--bg-panel); border: 1px solid var(--border); + border-radius: var(--mobile-control-radius); + color: var(--text-primary); font-size: var(--mobile-font-body); font-weight: 600; + font-family: inherit; cursor: pointer; + } + .view-picker-btn .vp-label { color: var(--text-subtle); font-size: 12px; flex: none; } + .view-picker-btn .vp-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .view-picker-btn .vp-caret { color: var(--text-muted); flex: none; margin-left: auto; } + .grid-new-btn { + flex: none; height: var(--mobile-input-h); padding: 0 16px; + border: none; border-radius: var(--mobile-control-radius); + background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%); + color: #fff; font-size: var(--mobile-font-body); font-weight: 600; font-family: inherit; cursor: pointer; + } + + .grid-card { + position: relative; 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: #fcd34d; 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; + } + .grid-card-meta { display: flex; align-items: center; gap: 10px; margin-top: 8px; } + .grid-card-amount { font-family: 'IBM Plex Mono', monospace; font-size: var(--mobile-font-body); font-weight: 600; color: #6ee7b7; 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.recency-aging { color: #e0b341; } + .grid-card-recency.recency-stale { color: #f87171; } + .stage-chip { + display: inline-block; padding: 2px 8px; border-radius: 4px; flex: none; + font-family: 'IBM Plex Mono', monospace; font-size: 10px; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.5px; + } + + /* Full-screen detail: read-only sections + edit-entry buttons. */ + .fs-detail-star { color: var(--accent); font-size: 18px; margin-right: 8px; } + .fs-action-row { display: flex; gap: 8px; flex-wrap: wrap; } + .fs-action-btn { + flex: 1; min-width: 140px; min-height: var(--mobile-touch-target); + background: var(--bg-panel-elevated); border: 1px solid var(--border-strong); + border-radius: var(--mobile-control-radius); color: var(--text-primary); + font-size: 14px; font-family: inherit; cursor: pointer; padding: 0 12px; + } + .fs-action-btn:active { background: var(--bg-hover); } + .fs-pill { background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius); padding: 9px 12px; margin-bottom: 8px; } + .fs-pill-name { font-size: var(--mobile-font-body); color: var(--text-primary); } + .fs-pill-email { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-muted); margin-top: 2px; word-break: break-word; } + .fs-note-log { white-space: pre-wrap; font-size: 13px; color: var(--text-secondary); line-height: 1.5; } + + /* Bottom-sheet form fields (shared by the log-note / stage / reminder / create sheets). */ + .sheet-field { margin-bottom: 14px; } + .sheet-field-label { display: block; font-size: 13px; color: var(--text-muted); margin-bottom: 6px; } + .sheet-input, .sheet-textarea, .sheet-select { + width: 100%; background: var(--bg-input); color: var(--text-primary); + border: 1px solid var(--border); border-radius: var(--mobile-control-radius); + font-size: var(--mobile-font-body); font-family: inherit; padding: 0 12px; height: var(--mobile-input-h); + } + .sheet-textarea { height: auto; min-height: 96px; padding: 10px 12px; resize: vertical; line-height: 1.5; } + .sheet-input:focus, .sheet-textarea:focus, .sheet-select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); } + .sheet-submit { + width: 100%; min-height: var(--mobile-touch-target); border: none; border-radius: var(--mobile-control-radius); + background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%); + color: #fff; font-size: var(--mobile-font-body); font-weight: 600; font-family: inherit; cursor: pointer; margin-top: 4px; + } + .sheet-submit:disabled { opacity: 0.6; cursor: default; } + .sheet-remove { + width: 100%; min-height: var(--mobile-touch-target); margin-top: 10px; + background: transparent; border: 1px solid #7a3030; color: #e06c6c; + border-radius: var(--mobile-control-radius); font-size: 14px; font-family: inherit; cursor: pointer; + } + .dedup-box { background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius); padding: 8px 12px; margin: -4px 0 14px; } + .dedup-box-title { font-size: 12px; color: var(--due-soon, #e0b341); margin-bottom: 4px; } + .dedup-match { font-size: 13px; color: var(--text-secondary); padding: 3px 0; } + /* Visibility utilities — base = desktop; flipped under the breakpoint. */ .mobile-only { display: none; } @@ -3564,6 +3653,83 @@ 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 + displayedRows does (ported from FundraisingGridPage), without sharing its closure. */ + + // Mobile money format (sourced from the comps): >=1e6 -> $N[.N]M (drop .0); >=1e3 -> $NK; else $N. + const formatMoneyMobile = (n) => { + const v = Number(n) || 0; + if (v >= 1e6) { const m = v / 1e6; return '$' + (m % 1 === 0 ? m.toFixed(0) : m.toFixed(1)) + 'M'; } + if (v >= 1e3) return '$' + Math.round(v / 1e3) + 'K'; + return '$' + Math.round(v); + }; + const daysSince = (iso) => { + if (!iso) return null; + const d = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000); + return Number.isFinite(d) ? d : null; + }; + const formatAgeShort = (days) => { + if (days == null) return ''; + if (days <= 0) return 'today'; + if (days < 7) return days + 'd'; + if (days < 30) return Math.floor(days / 7) + 'w'; + if (days < 365) return Math.floor(days / 30) + 'mo'; + return Math.floor(days / 365) + 'y'; + }; + const gridRollup = (row, fundColumnIds) => fundColumnIds.reduce((s, id) => s + parseNumericInput(row[id]), 0); + const gridFilterableValue = (row, col, fundColumnIds) => { + if (!col) return ''; + if (col.id === 'total_invested') return gridRollup(row, fundColumnIds); + if (col.type === 'contacts') { + return (Array.isArray(row[col.id]) ? row[col.id] : []) + .map((c) => `${c.name || ''} ${c.email || ''}`).join(' '); + } + return row[col.id]; + }; + const gridRuleMatches = (row, rule, columns, fundColumnIds) => { + const col = columns.find((c) => c.id === rule.colId); + if (!col) return true; + const cv = gridFilterableValue(row, col, fundColumnIds); + const op = rule.op || 'contains'; + const rv = rule.value ?? ''; + if (op === 'contains') return String(cv || '').toLowerCase().includes(String(rv).toLowerCase()); + if (op === 'equals') return String(cv || '').toLowerCase() === String(rv).toLowerCase(); + if (op === 'not_equals') return String(cv || '').toLowerCase() !== String(rv).toLowerCase(); + if (op === 'is_true') return !!cv; + if (op === 'is_false') return !cv; + if (op === 'gt') return parseNumericInput(cv) > parseNumericInput(rv); + if (op === 'lt') return parseNumericInput(cv) < parseNumericInput(rv); + if (op === 'on_or_after') return String(cv || '') >= String(rv || ''); + if (op === 'on_or_before') return String(cv || '') <= String(rv || ''); + return true; + }; + // Mirror of the desktop displayedRows base filter (graveyard / follow-up / lead flags + + // saved columnFilters). Search + sort are applied by the caller. Caveat: a columnFilter on a + // FORMULA column isn't evaluated here (gridFilterableValue returns the raw cell, not the + // computed value) — the mobile default views don't use those; revisit if a formula-filter view ships. + const gridRowMatchesView = (row, view, columns, fundColumnIds) => { + const f = (view && view.filters) || {}; + if (f.graveyardOnly && !row.graveyard) return false; + if (!f.graveyardOnly && !f.includeGraveyard && row.graveyard) return false; + if (f.followUpOnly && !row.follow_up) return false; + if (f.lead && row.lead !== f.lead) return false; + const cf = Array.isArray(view && view.columnFilters) ? view.columnFilters : []; + return cf.every((rule) => gridRuleMatches(row, rule, columns, fundColumnIds)); + }; + + // Pipeline-stage chip — reuses PIPELINE_STAGE_CHIP tints (DESIGN §2), mono-uppercase on mobile. + const StageChip = ({ stage }) => { + const s = String(stage || ''); + if (!s) return null; + const sc = PIPELINE_STAGE_CHIP[s] || { color: '#8ea2b7', border: '#3a4a5e' }; + return ( + + {pipelineStageLabel(s)} + + ); + }; + const LoginPage = () => { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); @@ -5704,7 +5870,10 @@ ); }; - const FundraisingGridPage = ({ user, token, onShowToast, views, activeView, setActiveView, setViews, uiAction, onUiActionHandled }) => { + // Desktop Fundraising Grid (the spreadsheet + autosave). Unchanged; rendered on >768px via + // the FundraisingGridPage switch at the end of this component. Mobile (<768px) renders the + // lean MobileFundraisingGrid instead — which never whole-grid PUTs (BRIEF §3a). + const DesktopFundraisingGridPage = ({ user, token, onShowToast, views, activeView, setActiveView, setViews, uiAction, onUiActionHandled }) => { const STORAGE_KEY = FUNDRAISING_GRID_STORAGE_KEY; const teamMembers = ['Grant', 'JK', 'GG', 'MB', 'Unassigned']; @@ -5946,7 +6115,7 @@ // signals (like pipeline_stage) — strip them so they never dirty the autosave or // get persisted into the blob. Their desktop column + mobile-card rendering lands // with the mobile surfaces (Phase 3); injecting them now keeps that pure-frontend. - const { pipeline, pipeline_stage, reminder_status, + const { pipeline, pipeline_stage, opportunity_id, reminder_status, existing_investor, last_activity_at, staleness, ...rest } = r; return rest; }) : rs); @@ -8490,6 +8659,391 @@ ); }; + // Mobile Fundraising Grid (<768px) — P3a of the mobile-first redesign. A lean card list → + // full-screen detail → edit sheets. Reads /api/fundraising/state once; ALL writes go through + // the targeted one-row endpoints (log-communication / pipeline link+stage / reminders), NEVER + // the whole-grid PUT (BRIEF §3a — that would race the 5-person live grid). Editable here: + // create investor, log a note, pipeline stage, set a reminder. Renaming + contact-pill edits + // on an existing row are read-only in P3a (need a narrow per-row PATCH — deferred to P3b). + const MobileFundraisingGrid = ({ user, token, onShowToast, views, activeView, setActiveView }) => { + const [columns, setColumns] = useState([]); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [search, setSearch] = useState(''); + const [selectedId, setSelectedId] = useState(null); + const [sheet, setSheet] = useState(null); // 'view' | 'create' | 'note' | 'stage' | 'reminder' + const [busy, setBusy] = useState(false); + const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '' }); + const [noteForm, setNoteForm] = useState({ type: 'note', subject: '', body: '' }); + const [reminderForm, setReminderForm] = useState({ title: '', due_date: '', details: '' }); + + const reload = useCallback(async (silent) => { + try { + if (!silent) setLoading(true); + const result = await api('/api/fundraising/state', {}, token); + const grid = (result && result.data && result.data.grid) || {}; + setColumns(Array.isArray(grid.columns) ? grid.columns : []); + setRows(Array.isArray(grid.rows) ? grid.rows : []); + setError(''); + } catch (err) { + setError(getErrorMessage(err, 'Failed to load the grid')); + } finally { + if (!silent) setLoading(false); + } + }, [token]); + + useEffect(() => { reload(); }, [reload]); + + const fundColumnIds = useMemo(() => columns.filter((c) => c && c.isFund).map((c) => c.id), [columns]); + const fundColumns = useMemo(() => columns.filter((c) => c && c.isFund), [columns]); + const activeViewObj = useMemo(() => views.find((v) => v.id === activeView) || null, [views, activeView]); + const activeViewName = (activeViewObj && activeViewObj.name) || 'All Investors'; + + const displayed = useMemo(() => { + const base = rows.filter((r) => r && typeof r === 'object' + && gridRowMatchesView(r, activeViewObj, columns, fundColumnIds)); + const q = search.trim().toLowerCase(); + const searched = !q ? base : base.filter((r) => { + // Same searched text as the desktop grid's rowText (name + notes + contact name/email/geo). + const contactText = (r.contacts || []).map((c) => `${c.name || ''} ${c.email || ''} ${c.city || ''} ${c.state || ''} ${c.country || ''}`).join(' '); + return `${r.investor_name || ''} ${r.notes || ''} ${contactText}`.toLowerCase().includes(q); + }); + return [...searched].sort((a, b) => String(a.investor_name || '') + .localeCompare(String(b.investor_name || ''), undefined, { sensitivity: 'base' })); + }, [rows, activeViewObj, columns, fundColumnIds, search]); + + const selectedRow = useMemo(() => rows.find((r) => r.id === selectedId) || null, [rows, selectedId]); + const closeSheet = () => setSheet(null); + + // ── writes (targeted one-row endpoints only) ── + const submitNote = async () => { + const row = selectedRow; if (!row) return; + const contact = (Array.isArray(row.contacts) && row.contacts[0]) || null; + if (!contact) { onShowToast('This investor has no contact yet — add one on desktop first', 'error'); return; } + if (!String(noteForm.body || noteForm.subject || '').trim()) { onShowToast('Add a note', 'error'); return; } + setBusy(true); + try { + await api('/api/fundraising/log-communication', { method: 'POST', body: JSON.stringify({ + row_id: row.id, investor_name: row.investor_name || '', contact, + type: noteForm.type || 'note', subject: noteForm.subject || '', body: noteForm.body || '', append_note: true, + }) }, token); + onShowToast('Note logged', 'success'); + setNoteForm({ type: 'note', subject: '', body: '' }); + closeSheet(); + await reload(true); + } catch (err) { onShowToast(getErrorMessage(err, 'Failed to log note'), 'error'); } + finally { setBusy(false); } + }; + + const applyStage = async (stage) => { + const row = selectedRow; if (!row) return; + setBusy(true); + try { + if (row.pipeline && row.opportunity_id) { + // Already in the pipeline → patch the linked opp directly (same endpoint the + // Pipeline board uses), via the read-only opportunity_id injected on the row. + await api(`/api/opportunities/${row.opportunity_id}/stage`, { method: 'PATCH', body: JSON.stringify({ stage }) }, token); + } else { + if (!(Array.isArray(row.contacts) && row.contacts.length)) { + onShowToast('Add a contact before adding to the pipeline', 'error'); setBusy(false); return; + } + // link creates the opp at `stage`; but if the row was already linked (e.g. another + // user linked it after our last load) link is idempotent and KEEPS the existing + // stage — so enforce the picked stage with a follow-up PATCH on the returned opp. + const resp = await api('/api/fundraising/pipeline/link', { method: 'POST', body: JSON.stringify({ + source_row_id: row.id, contact_index: 0, name: `${row.investor_name || 'Investor'} — Pipeline`, + stage, expected_amount: 0, probability: row.priority ? 55 : 35, fund_name: '', + }) }, token); + const opp = resp && resp.data; + if (opp && opp.id && opp.stage !== stage) { + await api(`/api/opportunities/${opp.id}/stage`, { method: 'PATCH', body: JSON.stringify({ stage }) }, token); + } + } + onShowToast('Stage updated', 'success'); + closeSheet(); + await reload(true); + } catch (err) { onShowToast(getErrorMessage(err, 'Failed to update stage'), 'error'); } + finally { setBusy(false); } + }; + + const removePipeline = async () => { + const row = selectedRow; if (!row) return; + if (!window.confirm(`Remove ${row.investor_name || 'this investor'} from the pipeline? The deal is archived (recoverable); the grid row is untouched.`)) return; + setBusy(true); + try { + await api('/api/fundraising/pipeline/unlink', { method: 'POST', body: JSON.stringify({ source_row_id: row.id }) }, token); + onShowToast('Removed from the pipeline', 'success'); + closeSheet(); + await reload(true); + } catch (err) { onShowToast(getErrorMessage(err, 'Failed to remove from pipeline'), 'error'); } + finally { setBusy(false); } + }; + + const submitReminder = async () => { + const row = selectedRow; if (!row) return; + if (!String(reminderForm.title || '').trim()) { onShowToast('A reminder needs a title', 'error'); return; } + setBusy(true); + try { + await api('/api/reminders', { method: 'POST', body: JSON.stringify({ + source_row_id: row.id, investor_name: row.investor_name || '', + title: reminderForm.title.trim(), due_date: reminderForm.due_date || '', details: reminderForm.details || '', + }) }, token); + onShowToast('Reminder set', 'success'); + setReminderForm({ title: '', due_date: '', details: '' }); + closeSheet(); + await reload(true); + } catch (err) { onShowToast(getErrorMessage(err, 'Failed to set reminder'), 'error'); } + finally { setBusy(false); } + }; + + const createDupes = useMemo(() => { + const q = createForm.name.trim().toLowerCase(); + if (q.length < 2) return []; + return rows.filter((r) => String(r.investor_name || '').toLowerCase().includes(q)).slice(0, 4); + }, [createForm.name, rows]); + + const submitCreate = async () => { + const name = createForm.name.trim(); + const cName = createForm.contactName.trim(); + if (!name) { onShowToast('Investor name is required', 'error'); return; } + if (!cName) { onShowToast('Add at least one contact name', 'error'); return; } + setBusy(true); + try { + // The one-row create path: log-communication finds-or-creates the investor + first + // contact (no whole-grid PUT). append_note only if a first note was given (else the + // create just seeds name + contact). + const hasNote = !!String(createForm.note || '').trim(); + await api('/api/fundraising/log-communication', { method: 'POST', body: JSON.stringify({ + investor_name: name, create_investor_if_missing: true, + contact: { name: cName, email: createForm.contactEmail.trim() }, + type: 'note', body: createForm.note || '', append_note: hasNote, + }) }, token); + onShowToast('Investor added', 'success'); + setCreateForm({ name: '', contactName: '', contactEmail: '', note: '' }); + closeSheet(); + await reload(true); + } catch (err) { onShowToast(getErrorMessage(err, 'Failed to add investor'), 'error'); } + finally { setBusy(false); } + }; + + const renderCard = (row) => { + 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' : ''}`; + return ( + + ); + }; + + return ( +
+
+
+ + +
+ setSearch(e.target.value)} /> +
+ {displayed.length} {displayed.length === 1 ? 'investor' : 'investors'} +
+
+ + {loading ? ( + + ) : error ? ( +
{error}
+ ) : displayed.length === 0 ? ( +
No investors in this view
+ ) : ( + displayed.map(renderCard) + )} + + + {views.map((v) => ( + + ))} + + + +
+ + setCreateForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. Acme Capital" /> +
+ {createDupes.length > 0 && ( +
+
Possible existing matches — tap a card instead of creating a duplicate
+ {createDupes.map((r) => (
{r.investor_name}
))} +
+ )} +
+ + setCreateForm((f) => ({ ...f, contactName: e.target.value }))} placeholder="Full name" /> +
+
+ + setCreateForm((f) => ({ ...f, contactEmail: e.target.value }))} placeholder="name@firm.com" /> +
+
+ +