Retire lp_profiles + LP Tracker; repoint Dashboard committed to the grid (v0.1.0:78)
The fundraising grid + email capture is the canonical system of record. lp_profiles was a superseded single-fund model with no reachable create/edit path, and the LP Tracker page was already orphaned (no nav entry + a redirect bouncing it to the grid). - Remove /api/lp-profiles* endpoints + handlers, the unused lp-breakdown report, the contact-dossier LP section, the demo-seed LP block, and (frontend) the LPTrackerPage component + its lp-tracker->fundraising-grid redirect. - Dashboard "Total Committed" now sums fundraising_investors.total_invested (graveyarded investors excluded) instead of the orphaned lp_profiles table, which read ~$0. "Total Funded" dropped: the grid tracks commitments, not a funded amount, and the frontend never rendered it. - Leave the empty lp_profiles table/index, the contact-delete soft-delete cascade, and the --reset-all-data clear in place (never-hard-delete). - Tests: add test_dashboard_report.py; update test_soft_delete_reads.py. 21/21 green.
This commit is contained in:
+32
@@ -108,6 +108,38 @@ Have the CRM send a **daily digest email** summarizing each registered user's ac
|
|||||||
|
|
||||||
Open design questions (settled at build time): send time = **6 PM box-local** (configurable in the admin panel), covering the ~24h window up to send; empty days = **always send** with a "no activity" note; summary granularity = **one per-user narrative** plus a **by-investor structured section** (inbound + outbound, team-wide) added 2026-06-16; enable/time live in the **admin panel** (DB-backed), not StartOS actions.
|
Open design questions (settled at build time): send time = **6 PM box-local** (configurable in the admin panel), covering the ~24h window up to send; empty days = **always send** with a "no activity" note; summary granularity = **one per-user narrative** plus a **by-investor structured section** (inbound + outbound, team-wide) added 2026-06-16; enable/time live in the **admin panel** (DB-backed), not StartOS actions.
|
||||||
|
|
||||||
|
### Email/communication search + natural-language query
|
||||||
|
*Requested 2026-06-16. Three increments, **sequenced 1 → 2 → 3** (1 and 2 first as a quick increment; 3 is a separate, larger build after). Origin: Grant asked whether we can query "emails sent to a specific investor" / "activity by user," and floated NL queries like "existing investors who have committed capital across our funds that we haven't emailed in a while."*
|
||||||
|
|
||||||
|
**Context — the data is captured but currently has NO front-end.** The entire Gmail email schema (`emails`, `email_threads`, `email_investor_links`, `email_account_messages`, `email_activity_proposals`, …) exists and is populated by the DWD capture pipeline, but is surfaced **nowhere** in `frontend/index.html` today (only as inputs to the daily digest). So all three items below are about making already-captured data queryable/visible. Email bodies of *matched* emails are already chunked + embedded into Qdrant with `{lp_id, lp_name, doc_type:"email", date_ts}` metadata.
|
||||||
|
|
||||||
|
**Caveat that shapes all three — the two-model join.** "Emails to an investor" link to the **fundraising grid** (`email_investor_links.fundraising_investor_id`); "committed capital" lives in the grid too (`fundraising_commitments`, multi-fund). But manually-logged `communications` and `lp_profiles` (single-fund) live in the **classic** model, and the two models are only bridged by fuzzy email/name matching (no authoritative join key). Any query spanning "committed capital" + "email recency" must reckon with this. Prefer the grid side as the higher-signal source (matcher already does).
|
||||||
|
|
||||||
|
**1. Activity query endpoints + panel (do first).** The logic already exists and is tested inside `backend/digest_builder.py` — `collect_user_activity()` (per team-member, sent vs received, with matched investor names) and `collect_investor_activity()` (re-pivoted by investor, team-wide). Expose them as on-demand endpoints (e.g. `GET /api/activity?user_id=…&since=…&until=…` and `…?investor_id=…`) returning the actual records (not just the counts that `/api/reports/activity` gives today), plus a simple UI panel. Answers "emails to investor X" and "what has user Y sent lately" interactively. Small build — mostly assembling tested parts + a thin UI. Soft-delete filter every read.
|
||||||
|
|
||||||
|
**2. Email content search box (do first, alongside 1).** Wire a search box onto the email bodies **already indexed in Qdrant** (capability is ~80% built — see the retrieval modes in `backend/ingest/search.py` and the MCP `hybrid_search`/`semantic_search`/`keyword_search` tools). This is semantic/lexical search over email *content* ("find where we discussed the mining deal"), distinct from the structured filters in item 1. Decide placement (global search bar vs. a dedicated email/search page — note there's no email UI at all today, so this may pair naturally with surfacing threads). Small.
|
||||||
|
|
||||||
|
**3. Natural-language → safe structured query (separate, larger, after 1 & 2).** An LLM translates a plain-English question into a **safe, read-only** DB query against the CRM, for relational/analytical questions that semantic search *cannot* answer — Grant's example ("committed across funds AND not emailed in a while") is joins + aggregates + recency, not a text-topic match. Design constraints (locked at request time, refine at build):
|
||||||
|
- **LLM = Claude behind the redaction boundary** (better at text-to-SQL than local Qwen; the scrub→Claude→re-hydrate path already exists for the PII concern). Not Spark — Spark Control offers embeddings/rerank/RAG + local chat, but **no text-to-SQL**.
|
||||||
|
- **Safety is the hard part, not the parsing.** Do NOT hand the LLM open-ended SQL against the live DB (soft-delete leaks, injection, runaway scans). Constrain it: read-only connection/view, a curated/parameterized query surface or a validated query AST, soft-delete-filtered views, row/time caps. Treat as its own designed feature with its own tests.
|
||||||
|
- Must reckon with the two-model join caveat above (capital lives in the grid; recency from email links).
|
||||||
|
|
||||||
|
### Consolidate on the fundraising grid as canonical; retire vestigial classic-CRM surfaces
|
||||||
|
*Decided 2026-06-16. The CRM carries two stacked models: the original generic CRM (contacts / lp_profiles / opportunities / manual communications) and the fundraising grid + email capture. The team uses the grid; most classic surfaces are un-adopted (verified on the box: Pipeline + Communications empty, Contacts auto-populated from the grid). **Decision: the fundraising grid + email capture is the canonical system of record;** prune or repurpose the rest rather than maintain a parallel half-empty CRM.*
|
||||||
|
|
||||||
|
**Retire `lp_profiles` + LP Tracker — DONE in code (2026-06-16), not yet deployed.** 21/21 backend tests green, `py_compile` clean. Needs a version bump + install to reach the box.
|
||||||
|
- Removed the orphaned `LPTrackerPage` component + the `lp-tracker`→`fundraising-grid` redirect (frontend).
|
||||||
|
- Removed the `/api/lp-profiles*` endpoints (list/get/create/update) and their handlers, the unused `lp-breakdown` report + route, the contact-dossier LP display (frontend + the `lp_profile` block in `handle_get_contact`), and the demo-seed LP block.
|
||||||
|
- **Dashboard KPIs repointed:** "Total Committed" now sums `fundraising_investors.total_invested` (the canonical grid rollup), **excluding graveyarded investors** so the headline reflects live committed capital — a deliberate divergence from `/api/fundraising/relational-summary`, which sums all rows. "Total Funded" dropped — the grid has no funded-vs-committed concept and the frontend never rendered it. (If a funded/wired status is wanted later, that's a new grid feature, not a revival of lp_profiles.) Regression-guarded by `test_dashboard_report.py`.
|
||||||
|
- **Left in place (intentional):** the empty `lp_profiles` table + index (no destructive drop, per never-hard-delete); the contact-delete soft-delete cascade; the `--reset-all-data` clear; and the inert MOCK_MODE `mockDb.lp_profiles` fixtures (dev-only fallback, never hits the backend — its dashboard mock still reads mock lp_profiles, a known dev-only divergence from the real backend). Updated `test_soft_delete_reads.py` to drop the now-removed `lp_profile` assertions (kept its org `total_funded` opportunities-aggregate checks).
|
||||||
|
|
||||||
|
**Adopt the Pipeline — wire it to the grid.**
|
||||||
|
- Pipeline (`opportunities`) is fully built and functional but unused. Keep it: it's the one classic surface that tracks something the grid doesn't — a forward-looking deal funnel (stage, `expected_amount × probability`, owner, close date) vs. the grid's actual committed dollars + flags.
|
||||||
|
- New idea (Grant, 2026-06-16): let users **flag an investor in the grid as a pipeline opportunity** (a grid column/control) so it **auto-creates / syncs an `opportunities` row** that loads into the Pipeline board. Design the grid↔pipeline link (which fund seeds it? what sets stage/expected amount? keep them reconciled). Turns Pipeline from a disconnected second data-entry surface into a view driven by the canonical grid.
|
||||||
|
- Revisit the stray contact-create side-door (the "Create Opportunity" modal `POST /api/contacts`, `frontend/index.html:6030`) once the grid-driven flow exists.
|
||||||
|
|
||||||
|
**Keep the Contacts table — as the read-only per-person directory it already is.** Confirmed 2026-06-16: the grid models **investor entity → many people** correctly today. The grid "contacts" column is a multi-pill editor; each pill syncs to a `fundraising_contacts` row AND its own classic `contacts` row (5-person family office → 1 investor + 5 contacts, linked via `fundraising_contacts.contact_id`, migration 0004). The Contacts page is **read-only for creation** (header: "added from the Fundraising Grid"; no New-Contact button), edit-only via the detail slide-over — the desired flow already holds. Email capture already rolls **multiple people up to one investor** (matcher indexes each pill's email separately, all → same `fundraising_investor_id`; `email_investor_links` records both investor and specific person). No build here — future email-surfacing UI should present comms grouped by investor across all its people.
|
||||||
|
|
||||||
## Definition of done for "Airtable substitute" v1
|
## Definition of done for "Airtable substitute" v1
|
||||||
- Team can manage all investors in one master table
|
- Team can manage all investors in one master table
|
||||||
- Saved views replicate current Airtable workflows
|
- Saved views replicate current Airtable workflows
|
||||||
|
|||||||
+7
-186
@@ -1781,20 +1781,11 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
if path == '/api/communications':
|
if path == '/api/communications':
|
||||||
return self.handle_list_communications(user, params)
|
return self.handle_list_communications(user, params)
|
||||||
|
|
||||||
# LP Profiles
|
|
||||||
if path == '/api/lp-profiles':
|
|
||||||
return self.handle_list_lp_profiles(user, params)
|
|
||||||
if re.match(r'^/api/lp-profiles/[^/]+$', path):
|
|
||||||
lp_id = path.split('/')[-1]
|
|
||||||
return self.handle_get_lp_profile(user, lp_id)
|
|
||||||
|
|
||||||
# Reports
|
# Reports
|
||||||
if path == '/api/reports/dashboard':
|
if path == '/api/reports/dashboard':
|
||||||
return self.handle_dashboard_report(user)
|
return self.handle_dashboard_report(user)
|
||||||
if path == '/api/reports/pipeline':
|
if path == '/api/reports/pipeline':
|
||||||
return self.handle_pipeline_report(user)
|
return self.handle_pipeline_report(user)
|
||||||
if path == '/api/reports/lp-breakdown':
|
|
||||||
return self.handle_lp_breakdown_report(user)
|
|
||||||
if path == '/api/reports/activity':
|
if path == '/api/reports/activity':
|
||||||
return self.handle_activity_report(user, params)
|
return self.handle_activity_report(user, params)
|
||||||
|
|
||||||
@@ -1907,8 +1898,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
return self.handle_create_opportunity(user, body)
|
return self.handle_create_opportunity(user, body)
|
||||||
if path == '/api/communications':
|
if path == '/api/communications':
|
||||||
return self.handle_create_communication(user, body)
|
return self.handle_create_communication(user, body)
|
||||||
if path == '/api/lp-profiles':
|
|
||||||
return self.handle_create_lp_profile(user, body)
|
|
||||||
if path == '/api/import/csv':
|
if path == '/api/import/csv':
|
||||||
return self.handle_import_csv(user, body)
|
return self.handle_import_csv(user, body)
|
||||||
if path == '/api/feature-requests':
|
if path == '/api/feature-requests':
|
||||||
@@ -1992,8 +1981,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
return self.handle_update_opportunity(user, path.split('/')[-1], body)
|
return self.handle_update_opportunity(user, path.split('/')[-1], body)
|
||||||
if re.match(r'^/api/communications/[^/]+$', path):
|
if re.match(r'^/api/communications/[^/]+$', path):
|
||||||
return self.handle_update_communication(user, path.split('/')[-1], body)
|
return self.handle_update_communication(user, path.split('/')[-1], body)
|
||||||
if re.match(r'^/api/lp-profiles/[^/]+$', path):
|
|
||||||
return self.handle_update_lp_profile(user, path.split('/')[-1], body)
|
|
||||||
if path == '/api/fundraising/state':
|
if path == '/api/fundraising/state':
|
||||||
return self.handle_update_fundraising_state(user, body)
|
return self.handle_update_fundraising_state(user, body)
|
||||||
if re.match(r'^/api/thesis/nodes/[^/]+$', path):
|
if re.match(r'^/api/thesis/nodes/[^/]+$', path):
|
||||||
@@ -2224,9 +2211,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
(contact_id,)
|
(contact_id,)
|
||||||
).fetchall())
|
).fetchall())
|
||||||
|
|
||||||
lp = conn.execute("SELECT * FROM lp_profiles WHERE contact_id = ? AND deleted_at IS NULL", (contact_id,)).fetchone()
|
|
||||||
result['lp_profile'] = row_to_dict(lp) if lp else None
|
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
return self.send_json({"data": result})
|
return self.send_json({"data": result})
|
||||||
|
|
||||||
@@ -2958,115 +2942,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
conn.close()
|
conn.close()
|
||||||
return self.send_json({"message": "Communication deleted"})
|
return self.send_json({"message": "Communication deleted"})
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
|
||||||
# LP PROFILE HANDLERS
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def handle_list_lp_profiles(self, user, params):
|
|
||||||
conn = get_db()
|
|
||||||
query = """
|
|
||||||
SELECT lp.*, c.first_name, c.last_name, c.email, o.name as organization_name
|
|
||||||
FROM lp_profiles lp
|
|
||||||
LEFT JOIN contacts c ON lp.contact_id = c.id
|
|
||||||
LEFT JOIN organizations o ON c.organization_id = o.id
|
|
||||||
WHERE 1=1
|
|
||||||
"""
|
|
||||||
args = []
|
|
||||||
if params.get('fund_name'):
|
|
||||||
query += " AND lp.fund_name = ?"
|
|
||||||
args.append(params['fund_name'])
|
|
||||||
if params.get('search'):
|
|
||||||
search = f"%{params['search']}%"
|
|
||||||
query += " AND (c.first_name LIKE ? OR c.last_name LIKE ? OR o.name LIKE ?)"
|
|
||||||
args.extend([search, search, search])
|
|
||||||
|
|
||||||
query += " ORDER BY lp.commitment_amount DESC"
|
|
||||||
profiles = rows_to_list(conn.execute(query, args).fetchall())
|
|
||||||
conn.close()
|
|
||||||
return self.send_json({"data": profiles, "total": len(profiles)})
|
|
||||||
|
|
||||||
def handle_get_lp_profile(self, user, lp_id):
|
|
||||||
conn = get_db()
|
|
||||||
lp = conn.execute("""
|
|
||||||
SELECT lp.*, c.first_name, c.last_name, c.email, c.phone, o.name as organization_name
|
|
||||||
FROM lp_profiles lp
|
|
||||||
LEFT JOIN contacts c ON lp.contact_id = c.id
|
|
||||||
LEFT JOIN organizations o ON c.organization_id = o.id
|
|
||||||
WHERE lp.id = ? AND lp.deleted_at IS NULL
|
|
||||||
""", (lp_id,)).fetchone()
|
|
||||||
if not lp:
|
|
||||||
conn.close()
|
|
||||||
return self.send_error_json("LP profile not found", 404)
|
|
||||||
conn.close()
|
|
||||||
return self.send_json({"data": row_to_dict(lp)})
|
|
||||||
|
|
||||||
def handle_create_lp_profile(self, user, body):
|
|
||||||
if not body.get('contact_id'):
|
|
||||||
return self.send_error_json("contact_id is required")
|
|
||||||
|
|
||||||
conn = get_db()
|
|
||||||
existing = conn.execute("SELECT id FROM lp_profiles WHERE contact_id = ?",
|
|
||||||
(body['contact_id'],)).fetchone()
|
|
||||||
if existing:
|
|
||||||
conn.close()
|
|
||||||
return self.send_error_json("LP profile already exists for this contact")
|
|
||||||
|
|
||||||
lp_id = generate_id()
|
|
||||||
conn.execute("""
|
|
||||||
INSERT INTO lp_profiles (id, contact_id, commitment_amount, funded_amount,
|
|
||||||
commitment_date, fund_name, investor_type, accredited, legal_docs_signed,
|
|
||||||
signed_date, wire_received, wire_date, k1_sent, preferred_communication, notes)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""", (
|
|
||||||
lp_id, body['contact_id'], body.get('commitment_amount', 0),
|
|
||||||
body.get('funded_amount', 0), body.get('commitment_date'),
|
|
||||||
body.get('fund_name'), body.get('investor_type'),
|
|
||||||
body.get('accredited', 0), body.get('legal_docs_signed', 0),
|
|
||||||
body.get('signed_date'), body.get('wire_received', 0),
|
|
||||||
body.get('wire_date'), body.get('k1_sent', 0),
|
|
||||||
body.get('preferred_communication', 'email'), body.get('notes')
|
|
||||||
))
|
|
||||||
|
|
||||||
# Update contact type to investor
|
|
||||||
conn.execute("UPDATE contacts SET contact_type = 'investor', updated_at = ? WHERE id = ?",
|
|
||||||
(now(), body['contact_id']))
|
|
||||||
|
|
||||||
log_audit(conn, user['user_id'], 'lp_profile', lp_id, 'create')
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
lp = row_to_dict(conn.execute("SELECT * FROM lp_profiles WHERE id = ?", (lp_id,)).fetchone())
|
|
||||||
conn.close()
|
|
||||||
return self.send_json({"data": lp}, 201)
|
|
||||||
|
|
||||||
def handle_update_lp_profile(self, user, lp_id, body):
|
|
||||||
conn = get_db()
|
|
||||||
existing = conn.execute("SELECT id FROM lp_profiles WHERE id = ?", (lp_id,)).fetchone()
|
|
||||||
if not existing:
|
|
||||||
conn.close()
|
|
||||||
return self.send_error_json("LP profile not found", 404)
|
|
||||||
|
|
||||||
updatable = ['commitment_amount', 'funded_amount', 'commitment_date', 'fund_name',
|
|
||||||
'investor_type', 'accredited', 'legal_docs_signed', 'signed_date',
|
|
||||||
'wire_received', 'wire_date', 'k1_sent', 'preferred_communication', 'notes']
|
|
||||||
sets = []
|
|
||||||
args = []
|
|
||||||
for field in updatable:
|
|
||||||
if field in body:
|
|
||||||
sets.append(f"{field} = ?")
|
|
||||||
args.append(body[field])
|
|
||||||
|
|
||||||
if sets:
|
|
||||||
sets.append("updated_at = ?")
|
|
||||||
args.append(now())
|
|
||||||
args.append(lp_id)
|
|
||||||
conn.execute(f"UPDATE lp_profiles SET {', '.join(sets)} WHERE id = ?", args)
|
|
||||||
log_audit(conn, user['user_id'], 'lp_profile', lp_id, 'update', body)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
lp = row_to_dict(conn.execute("SELECT * FROM lp_profiles WHERE id = ?", (lp_id,)).fetchone())
|
|
||||||
conn.close()
|
|
||||||
return self.send_json({"data": lp})
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# REPORT HANDLERS
|
# REPORT HANDLERS
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -3079,11 +2954,14 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
total_prospects = conn.execute("SELECT COUNT(*) as c FROM contacts WHERE contact_type = 'prospect'").fetchone()['c']
|
total_prospects = conn.execute("SELECT COUNT(*) as c FROM contacts WHERE contact_type = 'prospect'").fetchone()['c']
|
||||||
total_contacts = conn.execute("SELECT COUNT(*) as c FROM contacts").fetchone()['c']
|
total_contacts = conn.execute("SELECT COUNT(*) as c FROM contacts").fetchone()['c']
|
||||||
|
|
||||||
|
# Committed capital comes from the canonical fundraising grid (per-investor
|
||||||
|
# rollup of per-fund commitments). Graveyarded (written-off) investors are
|
||||||
|
# excluded so the headline reflects live committed capital — a deliberate
|
||||||
|
# divergence from /api/fundraising/relational-summary, which sums all rows.
|
||||||
|
# The legacy lp_profiles table is retired; the grid tracks commitments, not a
|
||||||
|
# separate "funded" amount, so total_funded is no longer reported.
|
||||||
total_committed = conn.execute(
|
total_committed = conn.execute(
|
||||||
"SELECT COALESCE(SUM(commitment_amount), 0) as total FROM lp_profiles"
|
"SELECT COALESCE(SUM(total_invested), 0) as total FROM fundraising_investors WHERE graveyard = 0"
|
||||||
).fetchone()['total']
|
|
||||||
total_funded = conn.execute(
|
|
||||||
"SELECT COALESCE(SUM(funded_amount), 0) as total FROM lp_profiles"
|
|
||||||
).fetchone()['total']
|
).fetchone()['total']
|
||||||
|
|
||||||
pipeline_value = conn.execute(
|
pipeline_value = conn.execute(
|
||||||
@@ -3156,7 +3034,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
"total_prospects": total_prospects,
|
"total_prospects": total_prospects,
|
||||||
"total_contacts": total_contacts,
|
"total_contacts": total_contacts,
|
||||||
"total_committed": total_committed,
|
"total_committed": total_committed,
|
||||||
"total_funded": total_funded,
|
|
||||||
"pipeline_value": pipeline_value,
|
"pipeline_value": pipeline_value,
|
||||||
"active_opportunities": active_opportunities,
|
"active_opportunities": active_opportunities,
|
||||||
"comms_this_month": comms_this_month,
|
"comms_this_month": comms_this_month,
|
||||||
@@ -3210,46 +3087,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
def handle_lp_breakdown_report(self, user):
|
|
||||||
conn = get_db()
|
|
||||||
lps = rows_to_list(conn.execute("""
|
|
||||||
SELECT lp.*, c.first_name, c.last_name, c.email, o.name as organization_name,
|
|
||||||
(SELECT MAX(communication_date) FROM communications WHERE contact_id = c.id) as last_contact_date,
|
|
||||||
(SELECT COUNT(*) FROM communications WHERE contact_id = c.id) as total_communications
|
|
||||||
FROM lp_profiles lp
|
|
||||||
LEFT JOIN contacts c ON lp.contact_id = c.id
|
|
||||||
LEFT JOIN organizations o ON c.organization_id = o.id
|
|
||||||
ORDER BY lp.commitment_amount DESC
|
|
||||||
""").fetchall())
|
|
||||||
|
|
||||||
summary = conn.execute("""
|
|
||||||
SELECT COUNT(*) as total_lps,
|
|
||||||
COALESCE(SUM(commitment_amount), 0) as total_committed,
|
|
||||||
COALESCE(SUM(funded_amount), 0) as total_funded,
|
|
||||||
COALESCE(AVG(commitment_amount), 0) as avg_commitment,
|
|
||||||
MAX(commitment_amount) as largest_commitment,
|
|
||||||
MIN(CASE WHEN commitment_amount > 0 THEN commitment_amount END) as smallest_commitment
|
|
||||||
FROM lp_profiles
|
|
||||||
""").fetchone()
|
|
||||||
|
|
||||||
by_type = rows_to_list(conn.execute("""
|
|
||||||
SELECT COALESCE(investor_type, 'Unknown') as investor_type,
|
|
||||||
COUNT(*) as count,
|
|
||||||
COALESCE(SUM(commitment_amount), 0) as total_committed
|
|
||||||
FROM lp_profiles
|
|
||||||
GROUP BY investor_type
|
|
||||||
ORDER BY total_committed DESC
|
|
||||||
""").fetchall())
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
return self.send_json({
|
|
||||||
"data": {
|
|
||||||
"lps": lps,
|
|
||||||
"summary": row_to_dict(summary),
|
|
||||||
"by_type": by_type
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
def handle_activity_report(self, user, params):
|
def handle_activity_report(self, user, params):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
days = int(params.get('days', 30))
|
days = int(params.get('days', 30))
|
||||||
@@ -5203,22 +5040,6 @@ def seed_demo_data():
|
|||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""", (*c, admin_id))
|
""", (*c, admin_id))
|
||||||
|
|
||||||
# Create LP profiles for investors
|
|
||||||
lp_data = [
|
|
||||||
(contacts[0][0], 25000000, 25000000, "2024-03-15", "Fund I", "institutional"),
|
|
||||||
(contacts[1][0], 15000000, 15000000, "2024-04-01", "Fund I", "family_office"),
|
|
||||||
(contacts[2][0], 20000000, 20000000, "2024-05-15", "Fund I", "pension"),
|
|
||||||
(contacts[3][0], 10000000, 10000000, "2024-06-01", "Fund I", "endowment"),
|
|
||||||
(contacts[4][0], 5000000, 5000000, "2024-07-15", "Fund I", "family_office"),
|
|
||||||
(contacts[5][0], 8000000, 8000000, "2024-08-01", "Fund I", "institutional"),
|
|
||||||
]
|
|
||||||
for lp in lp_data:
|
|
||||||
conn.execute("""
|
|
||||||
INSERT INTO lp_profiles (id, contact_id, commitment_amount, funded_amount,
|
|
||||||
commitment_date, fund_name, investor_type, accredited, legal_docs_signed, wire_received)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, 1)
|
|
||||||
""", (generate_id(), *lp))
|
|
||||||
|
|
||||||
# Create opportunities
|
# Create opportunities
|
||||||
opp_data = [
|
opp_data = [
|
||||||
(contacts[6][0], orgs[6][0], "Cascade Wealth - Fund II", "meeting", 10000000, 10000000, 40, user2_id),
|
(contacts[6][0], orgs[6][0], "Cascade Wealth - Fund II", "meeting", 10000000, 10000000, 40, user2_id),
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regression test for the dashboard KPI repoint + lp_profiles retirement (2026-06-16).
|
||||||
|
|
||||||
|
"Total Committed" used to SUM lp_profiles.commitment_amount — an orphaned table with no
|
||||||
|
reachable input path, so the dashboard read ~$0 while the real commitments lived in the
|
||||||
|
fundraising grid. It now sums fundraising_investors.total_invested (the canonical grid
|
||||||
|
rollup) with graveyarded (written-off) investors excluded, "Total Funded" is dropped
|
||||||
|
(the grid has no funded-vs-committed concept), and the /api/lp-profiles* + lp-breakdown
|
||||||
|
endpoints are gone.
|
||||||
|
|
||||||
|
This boots the REAL server against a temp DB, seeds two grid investors (one live, one
|
||||||
|
graveyarded), and asserts: total_committed reflects the live grid rollup only, the
|
||||||
|
metrics no longer carry a total_funded key, and the retired routes 404. Synthetic only.
|
||||||
|
|
||||||
|
Run: cd backend && python3 test_dashboard_report.py
|
||||||
|
"""
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
|
|
||||||
|
_DATA = tempfile.mkdtemp()
|
||||||
|
os.environ["CRM_DATA_DIR"] = _DATA
|
||||||
|
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import server # noqa: E402
|
||||||
|
|
||||||
|
FAILS = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
print((" PASS " if cond else " FAIL ") + msg)
|
||||||
|
if not cond:
|
||||||
|
FAILS.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class _Quiet(server.CRMHandler):
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _get(port, path, token):
|
||||||
|
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||||
|
conn.request("GET", path, headers={"Authorization": "Bearer " + token})
|
||||||
|
resp = conn.getresponse()
|
||||||
|
body = resp.read().decode("utf-8", "replace")
|
||||||
|
conn.close()
|
||||||
|
data = None
|
||||||
|
if body:
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return resp.status, data
|
||||||
|
|
||||||
|
|
||||||
|
def seed():
|
||||||
|
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
|
||||||
|
c.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
|
||||||
|
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
|
||||||
|
# live investor committed 3,000,000; graveyarded investor committed 500,000 (must be excluded)
|
||||||
|
c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested,graveyard) "
|
||||||
|
"VALUES ('fiLive','Harbor LP','rowLive',3000000,0)")
|
||||||
|
c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested,graveyard) "
|
||||||
|
"VALUES ('fiDead','Passed LP','rowDead',500000,1)")
|
||||||
|
c.commit()
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
server.init_db()
|
||||||
|
seed()
|
||||||
|
token = server.create_token("u1", "grant", "admin")
|
||||||
|
|
||||||
|
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet)
|
||||||
|
port = httpd.server_address[1]
|
||||||
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||||
|
try:
|
||||||
|
print("\n[dashboard total_committed comes from the grid, graveyard excluded]")
|
||||||
|
st, dash = _get(port, "/api/reports/dashboard", token)
|
||||||
|
check(st == 200, f"GET dashboard -> 200 (got {st})")
|
||||||
|
metrics = (dash or {}).get("data", {}).get("metrics", {})
|
||||||
|
check(metrics.get("total_committed") == 3000000,
|
||||||
|
f"total_committed = live grid rollup only (3,000,000; got {metrics.get('total_committed')})")
|
||||||
|
check("total_funded" not in metrics,
|
||||||
|
f"total_funded key dropped from metrics (got keys {sorted(metrics)})")
|
||||||
|
|
||||||
|
print("\n[retired lp_profiles endpoints 404]")
|
||||||
|
for path in ("/api/lp-profiles", "/api/lp-profiles/anything", "/api/reports/lp-breakdown"):
|
||||||
|
st, _ = _get(port, path, token)
|
||||||
|
check(st == 404, f"GET {path} -> 404 (got {st})")
|
||||||
|
finally:
|
||||||
|
httpd.shutdown()
|
||||||
|
|
||||||
|
print()
|
||||||
|
if FAILS:
|
||||||
|
print(f"FAILED ({len(FAILS)}):")
|
||||||
|
for f in FAILS:
|
||||||
|
print(f" - {f}")
|
||||||
|
sys.exit(1)
|
||||||
|
print("ALL PASS (dashboard KPI repoint + lp_profiles retirement)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -11,7 +11,7 @@ payload. The fix added `deleted_at IS NULL` to every get-by-id + nested sub-sele
|
|||||||
This boots the REAL server, hand-builds active + soft-deleted rows across the five
|
This boots the REAL server, hand-builds active + soft-deleted rows across the five
|
||||||
soft-deletable tables, and drives the live HTTP read paths with a real token. It
|
soft-deletable tables, and drives the live HTTP read paths with a real token. It
|
||||||
asserts: get-by-id 404s a soft-deleted contact/org, and nested sub-selects
|
asserts: get-by-id 404s a soft-deleted contact/org, and nested sub-selects
|
||||||
(org->contacts/opportunities, contact->communications/opportunities/lp_profile)
|
(org->contacts/opportunities, contact->communications/opportunities)
|
||||||
omit soft-deleted children while keeping the live ones. Synthetic only (guardrail #9).
|
omit soft-deleted children while keeping the live ones. Synthetic only (guardrail #9).
|
||||||
|
|
||||||
Run: cd backend && python3 test_soft_delete_reads.py
|
Run: cd backend && python3 test_soft_delete_reads.py
|
||||||
@@ -70,7 +70,7 @@ def seed():
|
|||||||
# organizations: one live, one soft-deleted
|
# organizations: one live, one soft-deleted
|
||||||
c.execute("INSERT INTO organizations (id,name) VALUES ('orgA','Harbor & Vine')")
|
c.execute("INSERT INTO organizations (id,name) VALUES ('orgA','Harbor & Vine')")
|
||||||
c.execute("INSERT INTO organizations (id,name,deleted_at) VALUES ('orgX','Deleted Org',?)", (DEL,))
|
c.execute("INSERT INTO organizations (id,name,deleted_at) VALUES ('orgX','Deleted Org',?)", (DEL,))
|
||||||
# contacts under orgA: one live (with children), one soft-deleted, one live w/ deleted lp
|
# contacts under orgA: one live (with children), one soft-deleted, one extra live (for org aggregates)
|
||||||
c.execute("INSERT INTO contacts (id,first_name,last_name,organization_id) VALUES ('cLive','Ada','Live','orgA')")
|
c.execute("INSERT INTO contacts (id,first_name,last_name,organization_id) VALUES ('cLive','Ada','Live','orgA')")
|
||||||
c.execute("INSERT INTO contacts (id,first_name,last_name,organization_id,deleted_at) VALUES ('cDead','Boris','Gone','orgA',?)", (DEL,))
|
c.execute("INSERT INTO contacts (id,first_name,last_name,organization_id,deleted_at) VALUES ('cDead','Boris','Gone','orgA',?)", (DEL,))
|
||||||
c.execute("INSERT INTO contacts (id,first_name,last_name,organization_id) VALUES ('cLp','Cora','Lp','orgA')")
|
c.execute("INSERT INTO contacts (id,first_name,last_name,organization_id) VALUES ('cLp','Cora','Lp','orgA')")
|
||||||
@@ -83,9 +83,6 @@ def seed():
|
|||||||
# communications on cLive
|
# communications on cLive
|
||||||
c.execute("INSERT INTO communications (id,contact_id,communication_date,created_by,subject) VALUES ('cmLive','cLive','2026-05-01','u1','Live note')")
|
c.execute("INSERT INTO communications (id,contact_id,communication_date,created_by,subject) VALUES ('cmLive','cLive','2026-05-01','u1','Live note')")
|
||||||
c.execute("INSERT INTO communications (id,contact_id,communication_date,created_by,subject,deleted_at) VALUES ('cmDead','cLive','2026-05-02','u1','Dead note',?)", (DEL,))
|
c.execute("INSERT INTO communications (id,contact_id,communication_date,created_by,subject,deleted_at) VALUES ('cmDead','cLive','2026-05-02','u1','Dead note',?)", (DEL,))
|
||||||
# lp_profiles: live one on cLive, soft-deleted one on cLp
|
|
||||||
c.execute("INSERT INTO lp_profiles (id,contact_id,fund_name) VALUES ('lpLive','cLive','Fund III')")
|
|
||||||
c.execute("INSERT INTO lp_profiles (id,contact_id,fund_name,deleted_at) VALUES ('lpDead','cLp','Fund III',?)", (DEL,))
|
|
||||||
c.commit()
|
c.commit()
|
||||||
c.close()
|
c.close()
|
||||||
|
|
||||||
@@ -115,11 +112,6 @@ def main():
|
|||||||
opp_ids = {x["id"] for x in d.get("opportunities", [])}
|
opp_ids = {x["id"] for x in d.get("opportunities", [])}
|
||||||
check("cmLive" in comm_ids and "cmDead" not in comm_ids, f"communications: live only (got {comm_ids})")
|
check("cmLive" in comm_ids and "cmDead" not in comm_ids, f"communications: live only (got {comm_ids})")
|
||||||
check("opLive" in opp_ids and "opDead" not in opp_ids, f"opportunities: live only (got {opp_ids})")
|
check("opLive" in opp_ids and "opDead" not in opp_ids, f"opportunities: live only (got {opp_ids})")
|
||||||
check(bool(d.get("lp_profile")) and d["lp_profile"].get("id") == "lpLive", "live lp_profile present on contact")
|
|
||||||
|
|
||||||
# soft-deleted lp_profile must read back as None (nested single-row sub-select)
|
|
||||||
_, lpc = _get(port, "/api/contacts/cLp", token)
|
|
||||||
check((lpc or {}).get("data", {}).get("lp_profile") is None, "soft-deleted lp_profile reads back as None")
|
|
||||||
|
|
||||||
# ── organization detail nested sub-selects exclude soft-deleted children ──
|
# ── organization detail nested sub-selects exclude soft-deleted children ──
|
||||||
print("\n[organization detail nested sub-selects]")
|
print("\n[organization detail nested sub-selects]")
|
||||||
|
|||||||
@@ -3826,32 +3826,6 @@
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{details.lp_profile && (
|
|
||||||
<div className="detail-section">
|
|
||||||
<div className="detail-section-title">LP Profile</div>
|
|
||||||
<div className="detail-row">
|
|
||||||
<span className="detail-label">Commitment</span>
|
|
||||||
<span className="detail-value">{formatCurrencyLong(details.lp_profile.commitment_amount)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="detail-row">
|
|
||||||
<span className="detail-label">Funded</span>
|
|
||||||
<span className="detail-value">{formatCurrencyLong(details.lp_profile.funded_amount)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="detail-row">
|
|
||||||
<span className="detail-label">Fund</span>
|
|
||||||
<span className="detail-value">{details.lp_profile.fund_name || '-'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="detail-row">
|
|
||||||
<span className="detail-label">Docs Signed</span>
|
|
||||||
<span className="detail-value">{details.lp_profile.legal_docs_signed ? '✓' : '✗'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="detail-row">
|
|
||||||
<span className="detail-label">Wire Received</span>
|
|
||||||
<span className="detail-value">{details.lp_profile.wire_received ? '✓' : '✗'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{details.opportunities && details.opportunities.length > 0 && (
|
{details.opportunities && details.opportunities.length > 0 && (
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<div className="detail-section-title">Opportunities</div>
|
<div className="detail-section-title">Opportunities</div>
|
||||||
@@ -4591,260 +4565,6 @@
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const LPTrackerPage = ({ token, onShowToast }) => {
|
|
||||||
const [lps, setLps] = useState([]);
|
|
||||||
const [contacts, setContacts] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [showForm, setShowForm] = useState(false);
|
|
||||||
const [formData, setFormData] = useState({ contact_id: '' });
|
|
||||||
const [formError, setFormError] = useState('');
|
|
||||||
const [selectedLP, setSelectedLP] = useState(null);
|
|
||||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchLPs = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const [lpResult, contactResult] = await Promise.all([
|
|
||||||
api(`/api/lp-profiles?search=${search}`, {}, token),
|
|
||||||
api('/api/contacts?limit=1000', {}, token)
|
|
||||||
]);
|
|
||||||
setLps(lpResult.data || []);
|
|
||||||
setContacts(contactResult.data || []);
|
|
||||||
} catch (err) {
|
|
||||||
onShowToast(getErrorMessage(err, 'Failed to load LP profiles'), 'error');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchLPs();
|
|
||||||
}, [token, search, onShowToast]);
|
|
||||||
|
|
||||||
const handleAddLP = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setFormError('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api('/api/lp-profiles', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(formData)
|
|
||||||
}, token);
|
|
||||||
|
|
||||||
setShowForm(false);
|
|
||||||
setFormData({ contact_id: '' });
|
|
||||||
|
|
||||||
const result = await api(`/api/lp-profiles?search=${search}`, {}, token);
|
|
||||||
setLps(result.data || []);
|
|
||||||
onShowToast('LP profile created', 'success');
|
|
||||||
} catch (err) {
|
|
||||||
setFormError(err.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteLP = async (id) => {
|
|
||||||
if (!MOCK_MODE) {
|
|
||||||
onShowToast('LP delete endpoint is not available yet in backend', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await api(`/api/lp-profiles/${id}`, { method: 'DELETE' }, token);
|
|
||||||
setLps(lps.filter(l => l.id !== id));
|
|
||||||
setConfirmDelete(null);
|
|
||||||
setSelectedLP(null);
|
|
||||||
onShowToast('LP deleted', 'success');
|
|
||||||
} catch (err) {
|
|
||||||
onShowToast(err.message, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const totalCommitted = useMemo(() => lps.reduce((sum, lp) => sum + (lp.commitment_amount || 0), 0), [lps]);
|
|
||||||
const totalFunded = useMemo(() => lps.reduce((sum, lp) => sum + (lp.funded_amount || 0), 0), [lps]);
|
|
||||||
const avgCheck = lps.length > 0 ? totalCommitted / lps.length : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page-container">
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
|
||||||
<h2 className="section-title">LP Tracker</h2>
|
|
||||||
<button onClick={() => setShowForm(true)}>+ Add LP Profile</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="kpi-grid">
|
|
||||||
<div className="kpi-card">
|
|
||||||
<div className="kpi-label">Total Committed</div>
|
|
||||||
<div className="kpi-value">{formatCurrencyLong(totalCommitted)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="kpi-card">
|
|
||||||
<div className="kpi-label">Total Funded</div>
|
|
||||||
<div className="kpi-value">{formatCurrencyLong(totalFunded)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="kpi-card">
|
|
||||||
<div className="kpi-label">Avg Check Size</div>
|
|
||||||
<div className="kpi-value">{formatCurrencyLong(avgCheck)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="kpi-card">
|
|
||||||
<div className="kpi-label">Number of LPs</div>
|
|
||||||
<div className="kpi-value">{lps.length}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="section">
|
|
||||||
<div className="controls">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="search-input"
|
|
||||||
placeholder="Search LPs..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<SkeletonBlock lines={8} />
|
|
||||||
) : lps.length === 0 ? (
|
|
||||||
<div className="empty-state">No LP profiles</div>
|
|
||||||
) : (
|
|
||||||
<table className="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Organization</th>
|
|
||||||
<th>Commitment</th>
|
|
||||||
<th>Funded</th>
|
|
||||||
<th>Docs</th>
|
|
||||||
<th>Wire</th>
|
|
||||||
<th>K1</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{lps.map(lp => (
|
|
||||||
<tr key={lp.id} onClick={() => setSelectedLP(lp)}>
|
|
||||||
<td>{contactName(lp)}</td>
|
|
||||||
<td>{lp.organization || lp.organization_name || '-'}</td>
|
|
||||||
<td>{formatCurrencyLong(lp.commitment_amount)}</td>
|
|
||||||
<td>{formatCurrencyLong(lp.funded_amount)}</td>
|
|
||||||
<td>{lp.legal_docs_signed ? '✓' : '✗'}</td>
|
|
||||||
<td>{lp.wire_received ? '✓' : '✗'}</td>
|
|
||||||
<td>{lp.k1_sent ? '✓' : '✗'}</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
className="button-danger"
|
|
||||||
style={{ padding: '4px 8px', fontSize: '11px' }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setConfirmDelete(lp.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showForm && (
|
|
||||||
<div className="modal-overlay">
|
|
||||||
<div className="modal">
|
|
||||||
<div className="modal-header">Add LP Profile</div>
|
|
||||||
{formError && <div className="toast error" style={{ position: 'static', marginBottom: '16px' }}>{formError}</div>}
|
|
||||||
<form onSubmit={handleAddLP}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Contact *</label>
|
|
||||||
<select
|
|
||||||
className="select-input"
|
|
||||||
value={formData.contact_id || ''}
|
|
||||||
onChange={(e) => setFormData({ ...formData, contact_id: e.target.value })}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Select contact</option>
|
|
||||||
{contacts.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>{contactName(c)}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Commitment Amount</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="text-input"
|
|
||||||
value={formData.commitment_amount || ''}
|
|
||||||
onChange={(e) => setFormData({ ...formData, commitment_amount: parseFloat(e.target.value) })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Funded Amount</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="text-input"
|
|
||||||
value={formData.funded_amount || ''}
|
|
||||||
onChange={(e) => setFormData({ ...formData, funded_amount: parseFloat(e.target.value) })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Fund Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="text-input"
|
|
||||||
value={formData.fund_name || ''}
|
|
||||||
onChange={(e) => setFormData({ ...formData, fund_name: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.legal_docs_signed || false}
|
|
||||||
onChange={(e) => setFormData({ ...formData, legal_docs_signed: e.target.checked })}
|
|
||||||
/>
|
|
||||||
{' '}Docs Signed
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.wire_received || false}
|
|
||||||
onChange={(e) => setFormData({ ...formData, wire_received: e.target.checked })}
|
|
||||||
/>
|
|
||||||
{' '}Wire Received
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.k1_sent || false}
|
|
||||||
onChange={(e) => setFormData({ ...formData, k1_sent: e.target.checked })}
|
|
||||||
/>
|
|
||||||
{' '}K1 Sent
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="form-actions">
|
|
||||||
<button type="button" className="button-secondary" onClick={() => setShowForm(false)}>Cancel</button>
|
|
||||||
<button type="submit">Create</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{confirmDelete && (
|
|
||||||
<ConfirmDialog
|
|
||||||
title="Delete LP"
|
|
||||||
message="Are you sure?"
|
|
||||||
onConfirm={() => handleDeleteLP(confirmDelete)}
|
|
||||||
onCancel={() => setConfirmDelete(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const FeatureRequestsPage = ({ token, onShowToast, user }) => {
|
const FeatureRequestsPage = ({ token, onShowToast, user }) => {
|
||||||
const [requests, setRequests] = useState([]);
|
const [requests, setRequests] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -10937,12 +10657,6 @@
|
|||||||
setToasts(t => [...t, { id, message, type }]);
|
setToasts(t => [...t, { id, message, type }]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (page === 'lp-tracker') {
|
|
||||||
setPage('fundraising-grid');
|
|
||||||
}
|
|
||||||
}, [page]);
|
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return <LoginPage />;
|
return <LoginPage />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
|
|||||||
// * 0.1.0:74 (security/privacy hardening — full-eval P0+2×P1: close /assets/ path traversal, add NER backstop to the outreach redaction boundary, filter deleted_at on get-by-id)
|
// * 0.1.0:74 (security/privacy hardening — full-eval P0+2×P1: close /assets/ path traversal, add NER backstop to the outreach redaction boundary, filter deleted_at on get-by-id)
|
||||||
// * 0.1.0:75 (Phase-A digest SMTP: per-package "Configure Digest SMTP" action writes /data/secrets/smtp/*; entrypoint exports SMTP_*; backend smtp_send.py + admin "send test email" endpoint + Settings→Admin "Send Test Digest Email" button)
|
// * 0.1.0:75 (Phase-A digest SMTP: per-package "Configure Digest SMTP" action writes /data/secrets/smtp/*; entrypoint exports SMTP_*; backend smtp_send.py + admin "send test email" endpoint + Settings→Admin "Send Test Digest Email" button)
|
||||||
// * 0.1.0:76 (digest send via Gmail DWD: backend/email_integration/gmail_send.py uses the existing service account's gmail.compose scope for users.messages.send; digest_mailer prefers Gmail DWD and falls back to SMTP; the admin test endpoint + Settings button route through it — no app password needed when Gmail is enabled)
|
// * 0.1.0:76 (digest send via Gmail DWD: backend/email_integration/gmail_send.py uses the existing service account's gmail.compose scope for users.messages.send; digest_mailer prefers Gmail DWD and falls back to SMTP; the admin test endpoint + Settings button route through it — no app password needed when Gmail is enabled)
|
||||||
// * Current: 0.1.0:77 (daily activity digest — Phase B: digest_builder builds by-team-member [per-user Spark narrative, never Claude] + by-investor [inbound+outbound, deduped] sections; always-on digest_scheduler reads a DB-backed policy; enable/send-time in Settings→Admin via GET/PATCH /api/admin/digest/policy; POST /api/admin/digest/send-now + "Send Digest Now" button)
|
// * 0.1.0:77 (daily activity digest — Phase B: digest_builder builds by-team-member [per-user Spark narrative, never Claude] + by-investor [inbound+outbound, deduped] sections; always-on digest_scheduler reads a DB-backed policy; enable/send-time in Settings→Admin via GET/PATCH /api/admin/digest/policy; POST /api/admin/digest/send-now + "Send Digest Now" button)
|
||||||
export const PACKAGE_VERSION = '0.1.0:77'
|
// * Current: 0.1.0:78 (retire legacy lp_profiles + orphaned LP Tracker; Dashboard "Total Committed" repointed onto the fundraising grid [graveyard-excluded], "Total Funded" dropped; /api/lp-profiles* + lp-breakdown report removed; contact-dossier LP section + demo-seed LP block removed)
|
||||||
|
export const PACKAGE_VERSION = '0.1.0:78'
|
||||||
|
|
||||||
export const DATA_MOUNT_PATH = '/data'
|
export const DATA_MOUNT_PATH = '/data'
|
||||||
export const WEB_PORT = 8080
|
export const WEB_PORT = 8080
|
||||||
|
|||||||
@@ -38,8 +38,9 @@ import { v_0_1_0_74 } from './v0.1.0.74'
|
|||||||
import { v_0_1_0_75 } from './v0.1.0.75'
|
import { v_0_1_0_75 } from './v0.1.0.75'
|
||||||
import { v_0_1_0_76 } from './v0.1.0.76'
|
import { v_0_1_0_76 } from './v0.1.0.76'
|
||||||
import { v_0_1_0_77 } from './v0.1.0.77'
|
import { v_0_1_0_77 } from './v0.1.0.77'
|
||||||
|
import { v_0_1_0_78 } from './v0.1.0.78'
|
||||||
|
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_0_1_0_77,
|
current: v_0_1_0_78,
|
||||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76],
|
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
// Retire the legacy lp_profiles table + the orphaned LP Tracker page, and repoint the
|
||||||
|
// Dashboard "Total Committed" KPI onto the canonical fundraising grid. Code-only, no
|
||||||
|
// schema change (the empty lp_profiles table is left in place; migrations are no-ops):
|
||||||
|
// * Removed /api/lp-profiles* endpoints + handlers, the unused lp-breakdown report,
|
||||||
|
// the contact-dossier LP section, the demo-seed LP block, and (frontend) the
|
||||||
|
// orphaned LPTrackerPage component + its lp-tracker->fundraising-grid redirect.
|
||||||
|
// * Dashboard "Total Committed" now sums fundraising_investors.total_invested
|
||||||
|
// (graveyarded investors excluded) instead of the orphaned lp_profiles table, which
|
||||||
|
// read ~$0. "Total Funded" dropped (the grid has no funded-vs-committed concept).
|
||||||
|
// * Tests: test_dashboard_report.py added; test_soft_delete_reads.py updated.
|
||||||
|
export const v_0_1_0_78 = VersionInfo.of({
|
||||||
|
version: '0.1.0:78',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US: [
|
||||||
|
'Dashboard "Total Committed" now reflects real committed capital from the fundraising grid',
|
||||||
|
'instead of the retired LP-profile table. Removed the unused LP Tracker page and its',
|
||||||
|
'legacy data endpoints; the grid remains the single source of truth for investors.',
|
||||||
|
].join(' '),
|
||||||
|
},
|
||||||
|
migrations: { up: async () => {}, down: async () => {} },
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user