Mobile Phase 3a: read + write-supported Fundraising Grid surface
Adds the mobile-first Fundraising Grid (<768px): a lean MobileFundraisingGrid that reads /api/fundraising/state once and renders an investor card list over the active view (name, committed $, pipeline-stage chip, staleness-colored recency, Existing-Investor accent, Priority corner; graveyard muted) with a bottom-sheet view picker and search. Tap a card -> full-screen detail with read-only commitments/contacts/notes plus edit sheets: log a note, pipeline stage, set a reminder, and a "+ New" investor create flow with client-side dedup typeahead. All writes go through the targeted one-row endpoints (log-communication, pipeline link, opportunities stage PATCH, reminders) — NEVER the whole-grid PUT, which would race the multi-user grid (BRIEF §3a). FundraisingGridPage is now a useIsMobile() wrapper over the renamed-but-untouched desktop grid and the new mobile one (rules-of-hooks-safe; desktop unchanged). Backend: inject a read-only opportunity_id into grid rows (opportunity_id_by_source_row; added to both strip points) so the mobile detail can PATCH a linked opp's stage directly. Earliest-opp-wins ordering keeps it consistent with pipeline_stage and the link's canonical pick. Editing an existing investor's name + contact pills stays read-only here (deferred to P3b — needs a narrow per-row PATCH + pill editor). Tests: test_grid_pipeline_link extended (opportunity_id inject/strip/round-trip); 36/36 backend green, render-smoke green.
This commit is contained in:
@@ -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). `<BottomSheet>` 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:** `<BottomSheet>` (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:** `<BottomSheet>` (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 `<BottomSheet>`/`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 + `<BottomSheet>`/`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.
|
||||
|
||||
+25
-7
@@ -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
|
||||
|
||||
+34
-2
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
+556
-2
@@ -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 (
|
||||
<span className="stage-chip" style={{ color: sc.color, border: `1px solid ${sc.border}`, backgroundColor: sc.color + '1a' }}>
|
||||
{pipelineStageLabel(s)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<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">
|
||||
<span className={`grid-card-amount${committed > 0 ? '' : ' zero'}`}>{formatMoneyMobile(committed)}</span>
|
||||
{row.pipeline && <StageChip stage={row.pipeline_stage} />}
|
||||
<span className={`grid-card-recency ${recencyCls}`}>
|
||||
{days == null ? 'no activity' : formatAgeShort(days) + (days <= 0 ? '' : ' ago')}{row.staleness === 'stale' ? ' · stale' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mobile-screen">
|
||||
<div className="mobile-toolbar">
|
||||
<div className="grid-toolbar-row">
|
||||
<button className="view-picker-btn" onClick={() => setSheet('view')}>
|
||||
<span className="vp-label">VIEW</span>
|
||||
<span className="vp-name">{activeViewName}</span>
|
||||
<span className="vp-caret">▾</span>
|
||||
</button>
|
||||
<button className="grid-new-btn" onClick={() => { setCreateForm({ name: '', contactName: '', contactEmail: '', note: '' }); setSheet('create'); }}>+ New</button>
|
||||
</div>
|
||||
<input className="mobile-search" type="text" placeholder="Search investors…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<div className="mobile-sortbar">
|
||||
<span className="mobile-count">{displayed.length} {displayed.length === 1 ? 'investor' : 'investors'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<SkeletonBlock lines={8} />
|
||||
) : error ? (
|
||||
<div className="empty-state">{error}</div>
|
||||
) : displayed.length === 0 ? (
|
||||
<div className="empty-state">No investors in this view</div>
|
||||
) : (
|
||||
displayed.map(renderCard)
|
||||
)}
|
||||
|
||||
<BottomSheet open={sheet === 'view'} onClose={closeSheet} title="Views">
|
||||
{views.map((v) => (
|
||||
<button key={v.id} className={`sheet-option ${v.id === activeView ? 'active' : ''}`} onClick={() => { setActiveView(v.id); closeSheet(); }}>
|
||||
<span>{v.name}</span>
|
||||
{v.id === activeView && <span className="sheet-option-check">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</BottomSheet>
|
||||
|
||||
<BottomSheet open={sheet === 'create'} onClose={closeSheet} title="New investor">
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Investor name</label>
|
||||
<input className="sheet-input" value={createForm.name} onChange={(e) => setCreateForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. Acme Capital" />
|
||||
</div>
|
||||
{createDupes.length > 0 && (
|
||||
<div className="dedup-box">
|
||||
<div className="dedup-box-title">Possible existing matches — tap a card instead of creating a duplicate</div>
|
||||
{createDupes.map((r) => (<div className="dedup-match" key={r.id}>{r.investor_name}</div>))}
|
||||
</div>
|
||||
)}
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Primary contact name</label>
|
||||
<input className="sheet-input" value={createForm.contactName} onChange={(e) => setCreateForm((f) => ({ ...f, contactName: e.target.value }))} placeholder="Full name" />
|
||||
</div>
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Contact email (optional)</label>
|
||||
<input className="sheet-input" type="email" value={createForm.contactEmail} onChange={(e) => setCreateForm((f) => ({ ...f, contactEmail: e.target.value }))} placeholder="name@firm.com" />
|
||||
</div>
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">First note (optional)</label>
|
||||
<textarea className="sheet-textarea" value={createForm.note} onChange={(e) => setCreateForm((f) => ({ ...f, note: e.target.value }))} placeholder="How you met, context…" />
|
||||
</div>
|
||||
<button className="sheet-submit" onClick={submitCreate} disabled={busy}>{busy ? 'Adding…' : 'Add investor'}</button>
|
||||
</BottomSheet>
|
||||
|
||||
{selectedRow && (() => {
|
||||
const row = selectedRow;
|
||||
const committed = gridRollup(row, fundColumnIds);
|
||||
const days = daysSince(row.last_activity_at);
|
||||
const fundsHeld = fundColumns.filter((c) => parseNumericInput(row[c.id]) > 0);
|
||||
const contacts = Array.isArray(row.contacts) ? row.contacts : [];
|
||||
const recencyColor = row.staleness === 'stale' ? '#f87171' : row.staleness === 'aging' ? '#e0b341' : undefined;
|
||||
return (
|
||||
<div className="fs-detail" role="dialog" aria-modal="true">
|
||||
<div className="fs-detail-header">
|
||||
<button className="fs-detail-back" onClick={() => setSelectedId(null)}>‹ Grid</button>
|
||||
</div>
|
||||
<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-subtitle">{formatMoneyMobile(committed)} committed{row.lead ? ` · ${row.lead}` : ''}</div>
|
||||
</span>
|
||||
{row.priority && <span className="badge" style={{ background: '#fcd34d22', color: '#fcd34d' }}>Priority</span>}
|
||||
</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">
|
||||
{row.pipeline ? <StageChip stage={row.pipeline_stage} /> : <span style={{ color: 'var(--text-subtle)' }}>Not in pipeline</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="fs-action-row" style={{ marginTop: '10px' }}>
|
||||
<button className="fs-action-btn" onClick={() => setSheet('stage')}>{row.pipeline ? 'Change stage' : 'Add to pipeline'}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fs-section">
|
||||
<div className="fs-section-label">Commitments</div>
|
||||
<div className="fs-row"><span className="fs-row-label">Total committed</span><span className="fs-row-value mono">{formatMoneyMobile(committed)}</span></div>
|
||||
{fundsHeld.map((c) => (
|
||||
<div className="fs-row" key={c.id}><span className="fs-row-label">{c.label}</span><span className="fs-row-value mono">{formatMoneyMobile(parseNumericInput(row[c.id]))}</span></div>
|
||||
))}
|
||||
<div style={{ fontSize: '12px', color: 'var(--text-subtle)', marginTop: '8px' }}>Amounts are read-only on mobile — edit on desktop.</div>
|
||||
</div>
|
||||
|
||||
<div className="fs-section">
|
||||
<div className="fs-section-label">Contacts</div>
|
||||
{contacts.length ? contacts.map((c, i) => (
|
||||
<div className="fs-pill" key={i}>
|
||||
<div className="fs-pill-name">{c.name || '—'}</div>
|
||||
{c.email && <div className="fs-pill-email">{c.email}</div>}
|
||||
</div>
|
||||
)) : <div style={{ color: 'var(--text-subtle)', fontSize: '13px' }}>No contacts yet.</div>}
|
||||
</div>
|
||||
|
||||
<div className="fs-section">
|
||||
<div className="fs-section-label">Activity</div>
|
||||
<div className="fs-row">
|
||||
<span className="fs-row-label">Last contact</span>
|
||||
<span className="fs-row-value mono" style={{ color: recencyColor }}>
|
||||
{days == null ? 'No activity' : (formatAgeShort(days) + (days <= 0 ? '' : ' ago'))}{row.staleness === 'stale' ? ' · stale' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{row.reminder_status && (
|
||||
<div className="fs-row"><span className="fs-row-label">Reminder</span><span className="fs-row-value">{String(row.reminder_status).replace('_', ' ')}</span></div>
|
||||
)}
|
||||
{row.notes && <div className="fs-note-log" style={{ marginTop: '10px' }}>{row.notes}</div>}
|
||||
<div className="fs-action-row" style={{ marginTop: '12px' }}>
|
||||
<button className="fs-action-btn" onClick={() => { setNoteForm({ type: 'note', subject: '', body: '' }); setSheet('note'); }}>Log a note</button>
|
||||
<button className="fs-action-btn" onClick={() => { setReminderForm({ title: '', due_date: '', details: '' }); setSheet('reminder'); }}>Set a reminder</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BottomSheet open={sheet === 'note'} onClose={closeSheet} title="Log a note">
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Type</label>
|
||||
<select className="sheet-select" value={noteForm.type} onChange={(e) => setNoteForm((f) => ({ ...f, type: e.target.value }))}>
|
||||
<option value="note">Note</option>
|
||||
<option value="call">Call</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="meeting">Meeting</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Summary (optional)</label>
|
||||
<input className="sheet-input" value={noteForm.subject} onChange={(e) => setNoteForm((f) => ({ ...f, subject: e.target.value }))} placeholder="e.g. Intro call" />
|
||||
</div>
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Note</label>
|
||||
<textarea className="sheet-textarea" value={noteForm.body} onChange={(e) => setNoteForm((f) => ({ ...f, body: e.target.value }))} placeholder="What happened…" />
|
||||
</div>
|
||||
<button className="sheet-submit" onClick={submitNote} disabled={busy}>{busy ? 'Saving…' : 'Log note'}</button>
|
||||
</BottomSheet>
|
||||
|
||||
<BottomSheet open={sheet === 'stage'} onClose={closeSheet} title="Pipeline stage">
|
||||
{PIPELINE_STAGES.map((st) => (
|
||||
<button key={st} className={`sheet-option ${row.pipeline_stage === st ? 'active' : ''}`} onClick={() => applyStage(st)} disabled={busy}>
|
||||
<span>{pipelineStageLabel(st)}</span>
|
||||
{row.pipeline_stage === st && <span className="sheet-option-check">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
{row.pipeline && <button className="sheet-remove" onClick={removePipeline} disabled={busy}>Remove from pipeline</button>}
|
||||
</BottomSheet>
|
||||
|
||||
<BottomSheet open={sheet === 'reminder'} onClose={closeSheet} title="Set a reminder">
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Title</label>
|
||||
<input className="sheet-input" value={reminderForm.title} onChange={(e) => setReminderForm((f) => ({ ...f, title: e.target.value }))} placeholder="e.g. Follow up on Fund III" />
|
||||
</div>
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Due date</label>
|
||||
<input className="sheet-input" type="date" value={reminderForm.due_date} onChange={(e) => setReminderForm((f) => ({ ...f, due_date: e.target.value }))} />
|
||||
</div>
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Details (optional)</label>
|
||||
<textarea className="sheet-textarea" value={reminderForm.details} onChange={(e) => setReminderForm((f) => ({ ...f, details: e.target.value }))} />
|
||||
</div>
|
||||
<button className="sheet-submit" onClick={submitReminder} disabled={busy}>{busy ? 'Saving…' : 'Set reminder'}</button>
|
||||
</BottomSheet>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Switch by viewport (rules-of-hooks-safe — only useIsMobile() runs here; the surfaces
|
||||
// mount/unmount on a breakpoint cross, each owning its own hooks).
|
||||
const FundraisingGridPage = (props) => {
|
||||
const isMobile = useIsMobile();
|
||||
return isMobile ? <MobileFundraisingGrid {...props} /> : <DesktopFundraisingGridPage {...props} />;
|
||||
};
|
||||
|
||||
const InstructionsPage = () => {
|
||||
return (
|
||||
<div className="page-container">
|
||||
|
||||
Reference in New Issue
Block a user