Mobile foundation (Phase 1) + harden opportunity stage validation
Phase 1 mobile foundation (additive, no desktop change): :root mobile vars, a 4-tab bottom nav bar + mobile account/logout popover wired into App, a bottom-sheet CSS primitive, and .mobile-only/.desktop-only utilities -- all display:none >=768px. The <BottomSheet> React component + useIsMobile() + the per-surface 15px type bump are deferred to Phase 2 (first use); light theme to Phase 6. Review hardening (fresh-eyes pass on the Phase 0+1 diff): validate stage in handle_create_opportunity + handle_update_opportunity against PIPELINE_STAGES -- the narrower 4-stage enum makes a stale-client write of a legacy value invisible to the report ORDER BY CASEs and unsettable from the UI. Use the canonical pipelineStageLabel in the opportunity detail select; document the intentional graveyard omission in the existing_investor / staleness helpers. Tests: stage-validation regression in test_grid_pipeline_link.py + empty source_row_id guard in test_pipeline_stages_v2.py; 36/36 green, render-smoke green.
This commit is contained in:
+15
-2
@@ -1807,7 +1807,10 @@ def existing_investor_by_source_row(conn):
|
||||
"""Return the set of grid source_row_ids whose investor has any committed capital
|
||||
(fundraising_investors.total_invested > 0) — the auto-derived "Existing Investor" flag
|
||||
(locked spec 2026-06-19). Injected read-only on grid read like pipeline_stage; never a
|
||||
maintained column. Orthogonal to stage: a re-solicited LP shows the star AND a live stage."""
|
||||
maintained column. Orthogonal to stage: a re-solicited LP shows the star AND a live stage.
|
||||
Deliberately NOT filtered by `graveyard` (unlike the dashboard total-invested aggregate):
|
||||
committed capital makes an investor "existing" regardless of disposition; graveyard rows are
|
||||
muted/filtered by the view, not by this signal. Same intentional omission in staleness below."""
|
||||
out = set()
|
||||
for r in conn.execute(
|
||||
"SELECT source_row_id FROM fundraising_investors WHERE total_invested > 0"
|
||||
@@ -2968,6 +2971,12 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
def handle_create_opportunity(self, user, body):
|
||||
if not body.get('name') or not body.get('contact_id'):
|
||||
return self.send_error_json("name and contact_id are required")
|
||||
# Validate stage (mirrors handle_update_stage). Matters more since the 4-stage migration:
|
||||
# a stale cached client could otherwise write a legacy value that's invisible to the
|
||||
# report ORDER BY CASEs and unsettable from the UI.
|
||||
stage = body.get('stage', 'lead')
|
||||
if stage not in PIPELINE_STAGES:
|
||||
return self.send_error_json(f"Invalid stage. Must be one of: {', '.join(PIPELINE_STAGES)}")
|
||||
|
||||
opp_id = generate_id()
|
||||
conn = get_db()
|
||||
@@ -2988,7 +2997,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
opp_id, body['name'], body['contact_id'], org_id,
|
||||
body.get('stage', 'lead'),
|
||||
stage,
|
||||
body.get('commitment_amount', 0), body.get('expected_amount', 0),
|
||||
body.get('probability', 10), body.get('expected_close_date'),
|
||||
body.get('fund_name'), body.get('description'), body.get('next_step'),
|
||||
@@ -3014,6 +3023,10 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
conn.close()
|
||||
return self.send_error_json("Opportunity not found", 404)
|
||||
|
||||
if 'stage' in body and body['stage'] not in PIPELINE_STAGES:
|
||||
conn.close()
|
||||
return self.send_error_json(f"Invalid stage. Must be one of: {', '.join(PIPELINE_STAGES)}")
|
||||
|
||||
updatable = ['name', 'contact_id', 'organization_id', 'stage', 'commitment_amount',
|
||||
'expected_amount', 'probability', 'expected_close_date', 'fund_name',
|
||||
'description', 'next_step', 'owner_id', 'priority', 'lost_reason']
|
||||
|
||||
Reference in New Issue
Block a user