Purpose. The Sales command center (
/portal/leads) is the contractor's CRM: it captures inbound leads from every channel, moves them through a five-stage pipeline, turns them into e-signable proposals, and converts a signed proposal into a live job. It is the office/PM home for pipeline, forecasting, proposals, bookings, contacts and follow-up.
Status: π’ Live β the core loop (capture β pipeline β proposal β e-sign β job) is fully wired end to end and used in production. One caveat (π‘ WIP): three of the six panes the nav plan calls for β Contacts, Nurture, Messages β are not yet folded in as native panes; the command-center rail links out (β) to their standalone pages instead (templates/sales_command_center.html:199-201). Those standalone pages are themselves fully live.
Audience: internal staff (office + PM). Every /portal/sales/*, /portal/leads*, /portal/proposals*, /portal/nurture, /portal/contacts, /portal/messages and /portal/booking route requires require_portal_role(UserRole.pm, UserRole.office) β the field role is deliberately excluded (app/routers/sales.py:45-48, sales_cc.py:30-33, nurture.py:25-28, comms.py:30-33, messages.py:29-32, booking.py:117-118). The public surfaces (booking page, proposal portal, embedded lead form) are prospect/client-facing and unauthenticated.
The command center is a single full-screen Alpine shell, templates/sales_command_center.html, served by sales.leads_page (app/routers/sales.py:231-242). It is the default at /portal/leads; ?classic=1 falls back to the legacy kanban page (leads.html). Panes fetch JSON on demand from the sibling router app/routers/sales_cc.py (prefix /portal/sales).
Native panes in the shell: Dashboard, Leads (pipeline kanban + a deep lead-detail sub-pane), Proposals, Bookings. The rail additionally links out to Contacts, Nurture, Messages, plus quick-create shortcuts (Estimate from photos, Add a lead).
Theme is per-user (nav_prefs.cc_theme, saved via /portal/prefs/cc-theme). A Keystone AI briefing loads async into each pane (/portal/leads-brief, leads_brief service), and briefing cards can deep-link into the pipeline pre-filtered to a focus subset (?focus=overdue|untouched|stalled|unassigned|today|nocadence, sales_cc.py:103-107).
Reference. In-hub sales analytics: open pipeline value, confidence-weighted forecast, win rate, proposals-out count/value, accepted value, plus pipeline-by-stage, an owner leaderboard, and source ROI. Data from GET /portal/sales/dashboard β reporting.lead_dashboard + reporting.pipeline (sales_cc.py:543-559).
How-to. Lands here by default on open. Read-only; it is a reporting surface, not an action surface.
Audience: staff. Status: π’ Live.
Reference. A five-column drag-and-drop kanban over LeadStatus: new β contacted β estimating β won β lost (PIPELINE, sales.py:50-56; STAGES, sales_cc.py:35). Columns show count + summed estimated_value. A header ticker shows open pipeline, forecast, win rate, proposals-out. A filter bar narrows by confidence / source / owner / "mine", and any active filter can be saved as a reusable Marketing Segment (POST /portal/sales/segment-from-filter, sales_cc.py:466-491).
How-to (primary flow).
GET /portal/sales/pipeline builds the columns (sales_cc.py:78-126).POST /portal/sales/leads/{id}/stage (sales_cc.py:129-148); the move is optimistic in the UI and logs a LeadActivity system note.Add a lead β classic #add) or the AI capture box (see Lead lifecycle).Reference. Deep single-lead view from GET /portal/sales/leads/{id} (sales_cc.py:153-253): identity, stage, confidence, owner, est. value, source, addresses, key contacts, an activity timeline (last 60), open/done tasks, this lead's proposals, and a Marketing block (lead score, segments, workflow enrollments, campaigns) pulled off the shared Person spine.
How-to. Inline actions each hit a sales_cc.py POST and re-fetch the lead:
/field + /stage (sales_cc.py:341-363)./assign (logs + notifies the new owner via sales._notify_owner_assigned, sales.py:199-225)./activity (sales_cc.py:256-274); also stamps Person.last_contacted_at./task, /task/{id}/toggle./enroll; add to a campaign send list β /add-to-campaign; apply a follow-up cadence β /cadence; enroll in an email drip β /drip (sales_cc.py:366-463)./portal/leads/{id}?classic=1), which additionally offers the plain-English AI lead editor (POST /portal/leads/{id}/ai-edit β apply_lead_patch, sales.py:509-636), custom fields, structured addresses, and contact management.Reference. Portfolio view of every Proposal with outstanding / accepted / draft stats, from GET /portal/sales/proposals (sales_cc.py:496-526). Display status derives a viewed state (sent + first_viewed_at) that sits between sent and accepted.
How-to (build & send β lives in the full builder, proposal_builder.html).
POST /portal/leads/{id}/proposals, auto-advances new/contacted β estimating, sales.py:933-952); standalone/"inverse" from a typed client or directory person (POST /portal/proposals/new, spawns an estimating lead, sales.py:999-1041); or from an uploaded drawing (POST /portal/proposals/from-plan, attaches a hidden draft "estimate workspace" job, sales.py:1044-1107)._build_proposal (sales.py:863-930) drafts the "magazine" sections + line items (AI-optional via ai_assistant.draft_full_proposal), seeds a proposed schedule and payment plan (from a job template or AI), and re-prices every line against the cost catalog.GET /portal/proposals/{id}, sales.py:1110-1206): sections (/sections/{key}, /rewrite, /ai-draft), line items + areas (/lines, /area/add), schedule (/schedule, /schedule/draft, /schedule/from-template), payments (/payments, /payments/draft), theme.POST /portal/proposals/{id}/send (sales.py:1614-1654): mints a public token, sets status sent, emails the client the public link, and creates a Documenso e-sign request (esign_service.request_proposal_signature; retry via /esign/retry).Reference. app/routers/proposal_portal.py β the tokenized /proposal/{token} page (no login) showing the branded proposal, schedule, payment plan, and an embedded Documenso signing frame.
How-to. Opening logs a viewed event (unless ?preview=). The client signs in-frame; /{token}/sign-status polls Documenso, and POST /{token}/accept (or the /api/webhooks/documenso backstop) runs conversion. POST /{token}/decline marks the proposal declined and the lead lost (lost_reason="Proposal declined", proposal_portal.py:149-163).
Audience: prospect/client (homeowner). Status: π’ Live.
Reference. Upcoming confirmed appointments (next 30 days) from GET /portal/sales/bookings β command_center.upcoming_appointments (sales_cc.py:531-538, services/command_center.py:71). A soon flag marks anything within ~36h; rows link straight to the originating lead.
How-to. Read-only here. Full configuration + calendar is the classic booking admin (/portal/booking, app/routers/booking.py:125-186): weekly hours, meeting types, availability, and an editable public booking page. Prospects self-book at the public /book page (booking.py:44-112), which resolves the org from the request host and can attach to an existing lead via ?lead=.
Audience: staff (pane + admin); prospect/client for public /book. Status: π’ Live.
Reference. The People / Contacts hub (GET /portal/contacts, app/routers/comms.py:169-197) β the directory of customers only (scope="customers"; crew/vendor/staff live in Team & HR). Backed by the unified Person spine; each row is a person with roles, tags, and a compose-and-send history. Add/edit people (/people, /people/{id}), attach them to a job (β tagged Client) or lead (β tagged Prospect) via attach_person_to (comms.py:133-166). Per-contact email/SMS compose with AI drafting and a live branded preview (/contacts/{id}/ai-draft, /email-preview, /send). Contact rows on leads/jobs are managed by app/routers/contacts.py (free-form tag roles, CONTACT_TAG_SUGGESTIONS).
How-to. Reached today from the Sales rail as an external link (β) to /portal/contacts, and from the lead-detail Contacts card.
Audience: staff. Status: π’ Live as a standalone page; π‘ WIP as a Sales command-center native pane (nav plan docs/nav_consolidation_plan.md:34 calls for it; the shell still links out, sales_command_center.html:199).
Reference. GET /portal/nurture (app/routers/nurture.py:45-72) β manage the two follow-up mechanisms applied to leads: cadences (LeadCadence β templated task sequences that materialize into LeadTasks) and drip sequences (DripSequence β timed email sends, with an engagement rollup of active enrollments + opens/clicks). Create/toggle/archive both. Defaults are seeded on first load (followup_svc.ensure_defaults). Actual application happens on a lead (/apply-cadence, /enroll in sales.py; /cadence, /drip in sales_cc.py).
How-to. External-link (β) from the Sales rail to /portal/nurture.
Audience: staff. Status: π’ Live standalone; π‘ WIP as a Sales native pane (sales_command_center.html:200).
Reference. GET /portal/messages (app/routers/messages.py:96-179) β a unified two-tab inbox (People = leads/customers/contacts, Crew = employees/subs), merging in-app chat (customer portal + crew field app), SMS and email into one timeline per Person, backed by Chatwoot. Send on any channel (/messages/send), AI reply-suggest/summarize/draft (/suggest, /summary, /ai-draft), start/reply crew threads (/crew/new, /crew/{sub}/message).
How-to. External-link (β) from the Sales rail to /portal/messages.
Audience: staff. Note: this inbox is shared with Team & HR (the composer's return_to=hub redirects to /portal/team-hub?pane=messages, messages.py:247-251) β it is not sales-specific, which is worth keeping in mind when folding it into the Sales CC. Status: π’ Live standalone; π‘ WIP as a Sales native pane (sales_command_center.html:201).
| Source | Entry point | source value |
Notes |
|---|---|---|---|
| Manual (form) | POST /portal/leads (sales.py:324-352) |
free-form | Office types a lead; owner defaults to the creator. |
| Manual (directory) | POST /portal/people kind=lead (comms.py:215-288) |
β | Adds a lead from the Contacts hub. |
| AI capture | POST /portal/leads/capture (sales.py:355-394) |
AI-classified | Paste an inbound email/text; ai_assistant.capture_lead extracts structured fields + next action; original message kept as first activity. |
| Desktop photo estimator | POST /portal/estimator (sales.py:78-120) |
"Desktop β photo walkthrough" | Uploads walkthrough photos β match_or_create_lead_contact β AI-drafts a proposal, redirects into the builder. |
| Field estimator | app/routers/field.py:1191+ (multiple variants) |
field sources | Same match_or_create_lead_contact / build_quote_from_photos path from the field app. |
| Embedded web form | POST /f/{token} (landing_portal.py:840-887) |
"Embedded form" | The org's iframe lead form (token from /portal/leads/embed, lead_admin.py:124-148); stitches visitor attribution + fires lead_created workflow. |
| Public booking | POST /book (booking.py:80-112) |
booking | A booked appointment; can attach to an existing lead via ?lead=. |
| Website chat | POST /api/webhooks/chatwoot (webhooks.py:357-459) |
"Website chat" / "website" | Chatwoot inbound; creates a lead when website_chat_to_lead is on (or the visitor left an email/phone) and records a chat touchpoint. |
| Proposal-first (inverse) | POST /portal/proposals/new, /proposals/from-plan (sales.py:999-1107) |
"proposal" | A proposal always needs a client, so a fresh one spawns an estimating lead. |
| Off-hub / showcase estimator | CP Admin API POST /orgs/{org_id}/leads (admin_api.py:659-701) |
"Website" (default) | Routes prospect estimator leads from showcase.gobuild.ca into the live org's CRM (recent CRM lead-routing feature); creates Lead + Person + lead_created trigger. |
Every intake path funnels through people_svc.ensure_person to attach the shared Person spine, then best-effort chatwoot_sync.sync_lead and a workflows.trigger_event(event="lead_created") (see _finalize_lead, sales.py:165-184).
new β contacted β estimating β won β lost. Stage changes come from the kanban drag (/portal/sales/leads/{id}/stage) or the classic status endpoint (POST /portal/leads/{id}/status, sales.py:639-674), each logging a system activity and re-pushing labels to Chatwoot + firing a status_change workflow event. Building a proposal auto-advances a new/contacted lead to estimating (sales.py:949-950).
When the client accepts + signs on the public proposal portal, conversion.convert_proposal (app/services/conversion.py:27+) runs once (idempotent):
Client (carrying the lead's Person), a Job (or promotes the hidden draft "estimate workspace" job if the proposal came from a drawing), and an approved Estimate with the proposal's marked-up lines.proposal.status = accepted, and on the lead: status = won and lead.won_job_id = job.id (conversion.py:214-216) β the link that lets contacts/invoices later follow a won lead to its job.docs/nav_consolidation_plan.md:34) specifies these as native Sales panes, but the shell rail links out to their standalone pages (sales_command_center.html:199-201). They work β they are just not "in" the command center./portal/messages is a shared People+Crew inbox also surfaced inside Team & HR (messages.py:247-251). Folding it into Sales as a "customer inbox" needs a scope decision (people-only vs. the full inbox).sales_command_center.html + sales_cc.py) and the legacy ?classic=1 kanban (leads.html + sales.py:243-261) both read the same data. The classic page is the only place several deep actions live (AI lead editor, custom fields, structured addresses) β the CC's lead detail links to it via "Full record β". Duplicate-path risk if edited independently.app/routers/marketing_forms.py (/api/demo-request) handles the Book-a-demo form on gobuild.ca, emailing sales@gobuild.ca and pushing to Mautic β this is GoBuild's internal sales funnel and does not create a tenant Lead. Easy to confuse with the tenant lead-capture form (/f/{token}).POST /api/webhooks/openproject only marks the mirror row stale ("Phase 1: touch updated_atβ¦ Full webhookβmirror application lands with the Celery reconcile job", webhooks.py:44-46). The nightly reconciler is the source of truth./api/webhooks/email/inbound), Documenso, and Chatwoot webhooks are inert (404/200 no-op) when their secret env var is unset (webhooks.py:143,187,375). On a pooled hub the Chatwoot receiver also no-ops when it can't resolve the org (webhooks.py:406-408) β chats can silently fail to attribute until per-org Chatwoot routing lands.except and never block the primary action β so a lead can save fine while its Chatwoot label / automation quietly failed. No user-facing signal when they do.