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/deadstates).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.portfoliosaugment:next_action_due_at TEXT(indexed). Stage age computed on read asjulianday('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/updatesportfolio_stages, stampsstage_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_stagesfor the demo user's existing portfolio rows so the board has content on first load. - Drag-drop: optimistic move on the client,
PUT …/stageto 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.