Spec — Pipeline page (NEW)

CRM deal board for won leads — the REsimpli replacement. Prototype: panel-pipeline. Extends the existing portfolios table (a portfolio row is a purchased lead; pipeline tracks what happens to it after the win).

UX

  • Header: 17 active · 4 due today · 2 contracts out + Filter / + Add lead.
  • Filter chips: All · Due today · Overdue · Stale 14d+ · Under contract (with counts).
  • KPI row: Leads in pipe (+N this week), Under contract (count + ~$GP), Assigned MTD (count + $), Conversion % (vs avg), Avg stage age (+ stale count).
  • Kanban: 6 columns, responsive 6→3 at 1300px. Each column = stage with a colored left border (inset 3px): |-|-| | stage | accent | | new | bull | | contacted | accent | | offer-sent | warn | | under-contract | hot | | assigned | gold | | dead | bear (dimmed) |
  • Card: address (Instrument Serif 15px), sub line, next-action tag (warn icon + desc + due), offer section (mono $), and a stage progress bar (on/cur/dead states). cursor: grab — drag to move stages.

Stage reconciliation

Current portfolios.status enum is new|contacted|follow_up|under_contract|converted|dead. Adopt the prototype's 6 stages: new|contacted|offer-sent|under-contract|assigned|dead. Migration mapping: follow_up → contacted, under_contract → under-contract, converted → assigned. (Use hyphenated values to match the prototype's data-stage.)

D1 schema (new migration)

  • portfolio_stages(id, portfolio_id UNIQUE→portfolios, current_stage, stage_entered_at, next_action_due_at, next_action_desc, offer_sent_amount_cents, contract_signed_at, contract_docs_url, assignment_price_cents, assignment_date, close_date, stale_days_threshold DEFAULT 14).
  • portfolio_actions(id, portfolio_stage_id→portfolio_stages, action_type[call|email|sms|task|note|offer_sent|offer_rejected|contract_signed|assignment], scheduled_for, completed_at, actor_user_id, description, related_artifact_id, created_at) — the activity/next-touch log; also the event source that triggers Sequences.
  • portfolios augment: next_action_due_at TEXT (indexed). Stage age computed on read as julianday('now') - julianday(stage_entered_at) (avoid stored generated columns for D1 portability).

KPIs are aggregates over portfolio_stages (+ portfolios.purchase_price and assignment fees for GP/ROI). "Stale" = stage_age_days >= stale_days_threshold.

API (/api/v1)

  • GET /pipeline?filter=all|due_today|overdue|stale_14d|under_contract{stages:[{key,name,count,cards[…]}], kpis:{leads_in_pipe, under_contract{count,gp_cents}, assigned_mtd{count,cents}, conversion_pct, avg_stage_age_days, stale_count}}. Auth required.
  • PUT /portfolios/:id/stage{current_stage, offer_sent_amount_cents?, next_action_due_at?, next_action_desc?} → moves a card; writes/updates portfolio_stages, stamps stage_entered_at. Emits a stage-change event for sequence enrollment.
  • POST /portfolios/:id/actions{action_type, scheduled_for?, description?, completed_at?} → logs an action, updates next-action fields.
  • PUT /portfolios/:id/next-action — quick edit {action_type, due_at, desc}.

Build notes

  • Seed portfolio_stages for the demo user's existing portfolio rows so the board has content on first load.
  • Drag-drop: optimistic move on the client, PUT …/stage to persist, reconcile on response. Keyboard-accessible fallback (stage <select> per card).
  • The stage-change event can start as a synchronous call into the sequence enrollment service (same worker) before any queue/cron exists.