in progress (strong CRM core shipped; benchmark-grade requirements still open — see §11)

Spec: F50 — CRM 2.0 (the contractor’s operating CRM, benchmarked against top CRMs)

Status: in progress (strong CRM core shipped; benchmark-grade requirements still open — see §11)

The Contract Graph assigns the shipped deal activity, contact-role, line-item, quote, and saved view operations individually to F50. This is endpoint ownership only; browser, native, live-service, and production evidence remain separate requirements.

1. Problem & CEO Job

See clearly + decide fast + trust the signal. F27 provides the relationship graph: companies, contacts, pipelines, deals, custom fields, and company 360. F50 turns that foundation into an operating CRM. A fit-out owner needs the next-step owner; whether a customer waits on us or we on a supplier; scheduled and stalled activity; the material contact; likely monthly revenue; quote line items; and risky job/deal handoffs, not just a deal stage.

The competitor is Excel + Zalo + owner memory, not Salesforce theater. Yet a Pipedrive- and HubSpot-class baseline requires coherent pipeline state, next action, account context, and deal history. Lumi matches that only where it sharpens the contractor wedge: activities, records, pipeline mechanics, product/quote structure, saved views, permissions, and forecast semantics, never marketing automation, giant admin surfaces, or enterprise bloat.

Cross-link (Loop 2 — company-to-company acquisition): external client/supplier/subcontractor confirmations still belong to F71. F50 owns the internal record model and the operator views that become more valuable after those confirmations land.

2. User Stories & Acceptance Criteria

2.1 Multi-view pipeline surfaces

  • As a salesperson or delivery manager, I want to switch between kanban, table, calendar, and timeline views, so that I use the surface that matches the task at hand.
    • Given /deals, when I use the view switcher, then I can switch between Kanban, Table, Calendar, and Timeline.
    • Given table view, when I inspect the grid, then core columns such as title, company, amount, stage, owner, and close date are visible.
    • Given table view, when I sort and filter, then the grid reacts client-side.
    • Given table view, when I export, then visible rows can be exported to CSV.
    • Given timeline view, when deals exist, then I can see a grouped timeline visualization.
    • Given a returning user, when they reopen /deals, then the prior view preference is remembered.
    • Given calendar view, when I drag a deal to a new date, then the close date reschedules. Web shipped 2026-06-18: CalendarView deal chips are draggable in close-date mode, day cells are drop targets, and DealsPage persists the new expected_close_date through the existing deal update path.
    • Given a mature CRM benchmark, when a user wants named saved views, then filters/sorts can be saved and shared. (Backend shipped 2026-06-16, Phase D: SavedDealView model + /api/deal-views/ CRUD — a member sees their own views plus org-shared ones, owner-only edit/delete, is_shared exposes read-only org-wide. Web UI shipped 2026-06-16, Phase D: SavedViewsBar on the deals board — built-in client-side presets (My overdue, Closing this week, No next activity, Stale >14d, Deposit expected) + user saved views (apply filters/sort/view_mode, save-current with share toggle, owner-only edit/delete, shared views read-only, 403 surfaced).)

2.2 Pipeline mechanics and ordering

  • As a sales manager, I want the pipeline order to reflect work priority, so that the team knows which deals to work first.
    • Given deals within a stage, when they render on the board, then they sort by position.
    • Given drag-and-drop within a stage, when I reorder, then PATCH /api/deals/<id>/reorder/ persists the new position.
    • Given a deal moved to a new stage, when it lands, then stage_changed_at updates and the activity timeline records the move.
    • Given stage-level probability values, when analytics run, then weighted pipeline can be computed.
    • Given a stage with required_fields, when a deal tries to ENTER that stage without those fields filled, then the move is rejected with a 400 listing the missing keys. (Backend shipped 2026-06-16, U7: Stage.required_fields (JSON list); a deal can’t enter a stage until each required key is satisfied by its post-update values — built-in amount/expected_close_date/owner check the deal column, any other key checks Deal.custom_fields. Enforced via a shared missing_required_fields() helper on BOTH the DealSerializer.validate (PATCH/POST) path and the DealViewSet.move action; empty (default) never gates.)
      • Native mobile contract: shared Android/iOS Deal Detail exposes a localized 48dp stage action only when the effective capability set contains crm.deal.edit. The current pipeline stage is marked and cannot be reselected. A successful POST /api/deals/<id>/move/ publishes the returned deal and resolved stage name without reloading unrelated detail sections. Pipeline metadata refreshes with the detail record so a transient lookup cannot permanently remove the permitted action. A required-field 400 keeps the prior deal truth and renders the complete server-authored explanation inline; viewers receive no move affordance. Source/API/model proof is registered under F50.AC013; runtime, named-device, accessibility-service, store, deployed, and production evidence remain open under F50.GAP013.
    • Given a benchmark like Pipedrive, when a stage has expected conversion semantics, then explicit win probability, rotting/stale rules, and forecast categories are all first-class. (Probability + stale/health semantics already existed; forecast categories shipped 2026-06-16 via Deal.forecast_category, PATCH /api/deals/<id>/forecast-category/, GET /api/deals/forecast/, and the web forecast surface.)

2.3 Activities and next-step discipline

  • As a deal owner, I want explicit scheduled activities, so that every open deal has a visible next step.
    • Given an open deal, when I schedule a call, meeting, site survey, or quote follow-up, then it appears as a first-class CRM activity with due date/time, type, owner, and completion state. (Backend shipped 2026-06-16: CRMActivity model + /api/crm-activities/ CRUD + complete action. UI shipped 2026-06-16: NextActivityCard in the deal detail panel — next-activity card with type icon/subject/due/owner, Complete + Reschedule actions, and a schedule form.)
    • Given the pipeline board or table, when a deal has no future activity, then the UI shows a strong “no next activity” warning. (Shipped 2026-06-16; visually aligned 2026-06-28: NextActivityIndicator on the kanban card and table row reads the annotated has_open_activity field and shows an amber shared warning badge; the detail panel shows an amber-railed warning + Schedule CTA.)
    • Given the calendar, when I switch to activity mode, then scheduled CRM activities appear there separately from expected close dates. (Web shipped 2026-06-17: CalendarView now has a deals-vs-activities toggle. DealsPage fetches scheduled /api/crm-activities/ rows and filters them to the visible open deals before passing them into the calendar, so the monthly grid can switch between expected-close-date pills and scheduled-activity pills without inventing a new backend filter contract. Empty-state copy is distinct per mode; tests in frontend/src/test/CrmViews.test.tsx cover activity rendering + empty state.)
    • Given an overdue activity, when it passes due time, then deal health reflects that staleness. (Web shipped 2026-06-16; visual classification refined 2026-06-28: getDealHealth maps has_overdue_activity → amber stale while preserving critical for true danger states such as unpaid overdue deposits and very stale stages, and an open deal with has_open_activity === false → no_activity; DealHealthBadge renders “Overdue activity” + “No next activity” through the shared amber warning badge recipe. daily-Brief (F12) glue shipped 2026-07-02 — see §11 Phase A item 4.)

Important benchmark note: top CRMs are not just boards plus comments. Their real discipline comes from activities. This is one of the biggest remaining gaps between Lumi’s current CRM and a Pipedrive-class operating CRM.

2.4 Deal activity timeline and auditability

  • As a CEO reviewing a stalled deal, I want to see every meaningful deal change, so that I understand what happened without asking three people.
    • Given deal create, stage change, amount change, owner change, or terminal close, when that mutation happens, then DealActivity rows are created.
    • Given a deal detail panel, when I open it, then activity entries render in reverse chronological order.
    • Given a bulk operation, when multiple deals change, then each deal gets a separate activity log entry.
    • Given a benchmark CRM audit trail, when materially important internal deal fields change, then the timeline logs them explicitly. Shipped 2026-06-17: direct deal updates now create DealActivity rows for title, expected close date, lost reason, and forecast category changes (plus the pre-existing stage/amount/owner/close events). Probability remains stage-level in Lumi today, so its audit trail continues to flow through stage_changed rather than a separate deal field change.
    • Given imported or system-originated activity, when it appears in the timeline, then provenance should be explicit (user, system, import, confirmation_link). (Backend shipped 2026-06-16, U11: DealActivity.origin + DealComment.origin (shared ORIGIN_CHOICES, default user) persist provenance on every note/event.)

2.5 Deal and account record richness

  • As a salesperson, I want deal and company records to be rich enough that I do not keep side spreadsheets, so that Lumi becomes the real system of record.
    • Given a company record, when I open its 360, then I can see related deals, commitments, risks, and tasks.
    • Given custom fields, when the org defines them, then backend validation enforces them.
    • Given a company or deal form, when business users need to edit custom fields, then those fields are rendered in the UI. (Shipped 2026-06-16, Phase B web: CustomFieldsEditor fetches /field-definitions/ for the entity type and renders text/number/date/choice inputs honoring required and the locale label; wired into the deal and company create forms, saved via the normal PATCH custom_fields.)
    • Given a deal, when it involves several stakeholders, then I should be able to associate multiple contacts to the deal with roles such as decision-maker, champion, procurement, or site contact. (Backend shipped 2026-06-16: DealContactRole + /api/deals/<id>/contacts/. UI shipped 2026-06-16, Phase B web: DealContactRoles in the deal detail panel — role chips with add/remove, contact picker scoped to the deal’s company.)
    • Given a deal amount, when it is really composed of quote line items, then products/line items should be captured structurally rather than only in free text or one total amount. (Backend shipped 2026-06-16: DealLineItem + /api/deals/<id>/line-items/ with deterministic Deal.amount rollup. UI shipped 2026-06-16, Phase B web: DealLineItems editable table with add/edit/delete rows, server line_total per row, and a Σ rollup footer; deal amount re-fetched after each mutation.)
    • Given a deal expected close, when it is tied to a real project kickoff or deposit milestone, then milestone dates should be capturable beyond a single expected close date. (Backend shipped 2026-06-16, F50 U5: Deal gains deposit_amount, deposit_due_date, deposit_paid, kickoff_date (thi công), handover_date (nghiệm thu) — all writable via DealSerializer. An open deal with an overdue UNPAID deposit (deposit_due_date < today ∧ ¬deposit_paid) now surfaces in the morning Brief as a high-priority deal BriefItem (suggestion deal_deposit_overdue, “thu cọc từ {company}”), ranking first among stuck-deal reasons; derived is_deposit_overdue property exposed read-only. Web milestone/deposit form UI shipped 2026-06-16: DealDeposit section on DealDetailPanel — amount/due-date/paid + kickoff/handover dates, view→edit→save via useUpdateDeal, red overdue treatment + kanban “Deposit overdue” chip when is_deposit_overdue ∧ ¬paid.)

2.6 Deal comments, notes, and collaboration

  • As a team member, I want to leave notes and comments on a deal, so that internal context stays with the record.
    • Given a deal detail panel, when I post a comment, then it persists and renders in the panel.
    • Given the activity timeline, when a comment is added, then that event appears in activity.
    • Given comment deletion, when I try to delete another user’s comment, then the API rejects it.
    • Given an @mention, when I mention an org member, then a notification fires. (Backend shipped 2026-06-16, U11: POST /api/deals/<id>/comments/ accepts write-only mentions = list of OrganizationMember UUIDs; for each active member of the same org (skipping the author, skipping invalid/cross-org ids silently) create_notification_event(category="mention", module="crm", route="/deals", payload_json={deal, comment}, dedupe_key="crm-mention-<comment>-<member>") is called. A bad mention never fails the comment. Web shipped 2026-06-16, U11: the comment composer (DealCommentComposer.tsx) has an @-trigger autocomplete over active org members — selecting one inserts @Name and records the member id; the chosen ids are sent as mentions[] on submit.)
    • Given an important note, when it is private or shared, then note visibility semantics are explicit. (Backend shipped 2026-06-16, U11: DealComment.visibility (shared|private, default shared). The comment LIST is org-scoped first, then filtered — managers/admins/owner see every comment; everyone else sees shared comments plus their own private ones. Deletion stays author-only. Web shipped 2026-06-16, U11: the composer has a shared/private toggle (default shared) sent as visibility; the list renders a “Private” badge on private notes and a muted origin badge for non-user origins (system/import/confirmation).)

2.7 Pipeline analytics, forecasting, and health

  • As a sales manager, I want pipeline health and forecast semantics, so that I can distinguish vanity pipeline from likely revenue.
    • Given open deals, when I open analytics, then I can see open count, total pipeline value, weighted value, and won/lost this month.
    • Given stage probabilities, when weighted pipeline is calculated, then each stage contributes proportionally.
    • Given a board card, when deals become stale or overdue, then health indicators appear.
    • Given a Pipedrive-class forecast view, when I categorize deals as commit / best case / pipeline / omitted, then forecast rollups should respect those categories. (Backend shipped 2026-06-16: Deal.forecast_category field + PATCH /api/deals/<id>/forecast-category/ + GET /api/deals/forecast/ per-currency rollups by category with weighted pipeline excluding omitted. Web shipped 2026-06-16: a “Forecast” mode in the deals view switcher (ForecastView.tsx) grouped by category with a month period selector; ForecastCategoryControl.tsx sets the bucket from the deal detail panel and shows a chip on the kanban card.)
      • Native mobile contract: shared Android/iOS Deal Detail shows the server-authored forecast category in the first operating viewport. Users with effective crm.deal.edit authority can choose exactly one of Commit, Best case, Pipeline, or Omitted through compact localized controls; viewers retain a read-only category. A successful PATCH publishes the returned server category without reloading unrelated Deal Detail sections. Failure keeps the prior category and one exact retry intent; capability failure removes mutation authority and pending retry state. Native month rollups, conversion charts, bulk forecast editing, and saved forecast views remain outside this slice.
    • Given forecast analytics, when mixed currencies exist, then totals should remain currency-honest rather than pretending everything is comparable without normalization. (Backend shipped 2026-06-16: /api/deals/forecast/ is currency_mode: per_currency and never merges currencies; /api/deals/analytics/ gained by_currency + weighted_by_currency breakdowns alongside the legacy merged keys. Web shipped 2026-06-16: ForecastView renders each currency on its own line and PipelineStats switches to per-currency stat lines when more than one currency exists — never a merged total. Tested in frontend/src/test/DealForecast.test.tsx (asserts two currencies are never summed).)
  • As a CEO, I want the Business lane to distinguish an unavailable Forecast summary from a valid empty forecast, so that a failed forecast request never looks like no pipeline.
    • A failed Forecast-summary request shows the shared unavailable row and retryable partial-data state; a successful empty forecast remains a valid authored result with concise No weighted pipeline yet copy.
    • A total Business summary outage stays on the existing error path so refresh preserves the last successful summary; retry also keeps that summary visible while loading, without replaying an old partial warning as current transport truth.

Contract Graph ownership: the shipped analytics, forecast, conversion, and aging read endpoints are assigned to their exact DealAnalyticsMixin actions. This records source/test ownership only; browser, native, live-service, and production evidence remain separate gaps.

  • Given a mature CRM dashboard, when I need conversion analytics, then stage-to-stage conversion, average stage duration, and win-rate by owner/pipeline should be available. (Backend shipped 2026-06-16: GET /api/deals/conversion/ — stage progression counts, avg stage duration from stage_changed activity, win-rate by owner + by pipeline; GET /api/deals/aging/ — per-stage stale counts + avg days in stage. Web shipped 2026-06-16: DealAnalyticsPanel — expandable analytics section on the deals board surfacing win-rate by owner/pipeline, stage duration + progression, and per-stage stale counts + avg days-in-stage, with the conversion basis note shown for honesty.)

2.8 Bulk operations and team workflows

  • As a manager, I want safe bulk actions, so that territory rebalancing and cleanup take seconds, not an hour.
    • Given selected deals, when I bulk move stage, update owner, or archive, then the backend performs tenant-scoped mutations and returns per-deal results.
    • Given bulk actions, when they run, then each deal logs an activity.
    • Given a benchmark CRM, when I need bulk edit for close date, pipeline, custom field, or forecast category, then those actions should exist. (Backend shipped 2026-06-16, Phase D: /api/deals/bulk/ gained update_close_date, update_pipeline, update_forecast_category, update_custom_field — each tenant-scoped, param-validated (400 on missing/invalid), with a per-deal DealActivity. Probability/labels remain stage-derived / out of scope. Web UI shipped 2026-06-16, Phase D: DealsBulkBar multi-select toolbar — action picker + per-action input (date picker / pipeline select / forecast select / custom-field key+value), with partial-failure errors[] rendered inline by {deal_id, error}.)
    • Given partial failure in a bulk action, when some deals fail, then the frontend should expose the failures clearly. (Web shipped 2026-06-17: DealsBulkBar keeps the partial-success toast, and now renders a structured follow-up alert with one row per failed deal showing the human title, raw deal id, and exact backend reason so an operator can recover without guessing which records failed.)

2.9 Company timeline and relationship memory

  • As a CEO opening a company, I want a merged relationship timeline, so that I see the story of the account in one place.
    • Given a company timeline request, when deals, commitments, risks, and tasks exist, then they merge into one chronological feed.
    • Given date filters, when I narrow the range, then timeline results filter accordingly.
    • Given many items, when the timeline is large, then the API caps to 100 with has_more.
    • Given a company 360 (customer, supplier, or partner), when I open it, then a People tab lists every contact at that company and lets me add/edit/delete them inline. (Web shipped 2026-06-16: CompanyPeople.tsx People tab in Company360 — useCompanyContacts(companyId) (TanStack Query, ?company= scoped), add-person form POSTs with company prefilled, edit/delete with confirm, tab count badge, empty/loading/error states. Closes the “see the people at any company” gap from §2.5/§2.9.)
    • Given explicit CRM-tracked emails, meetings, confirmations, activities, and attachments, when they exist, then the company timeline should show them as well. Web shipped 2026-06-18: CRMActivity(activity_type="email") now surfaces as a first-class email timeline event, alongside the already-shipped merged DealActivity audit events, ConfirmationRequest responses, confirmation response uploads, and task-file attachments. Meetings continue to flow through CRMActivity. General mailbox sync remains out of scope and is not implied by this acceptance criterion.

2.10 Better deal creation and capture

  • As a salesperson, I want to create a deal wherever I am in the CRM, so that capture friction stays lower than Excel or chat.
    • Given a company 360 page, when I click “New Deal”, then the company is prefilled.
    • Given a contact detail page, when I create a deal, then company and contact should prefill. (Web shipped 2026-06-17: /contacts standalone contacts workspace — searchable/filterable contact list, detail pane selected via ?contact=<id>, create/edit/delete contact form, and a New deal from contact action. That action routes to /deals?new=1&company=<companyId>&contact=<contactId>; DealsPage consumes both params so the create-deal form preselects the company and, after POST /api/deals/, immediately attaches the source contact as decision_maker via /api/deals/<id>/contacts/. Tests: frontend/src/test/ContactsPage.test.tsx, frontend/src/test/ContactDealPrefill.test.tsx, route wiring in frontend/src/test/CrmSettingsNav.test.tsx. Shared Android/iOS now expose the same contact-detail action, typed company/contact route context, and decision-maker association; runtime proof remains a named mobile evidence gap.)
    • Given a keyboard-driven operator workflow, when I use Cmd/Ctrl+Shift+D, then I can open “New Deal” quickly from the main CRM work surfaces. (Web shipped 2026-06-17: shared useCrmNewDealShortcut bound on /deals, /companies, and /contacts; it routes to /deals?new=1 and preserves active company/contact context when present. The handler ignores focused inputs/textareas/selects/contenteditable so typing is never hijacked.)
    • Given inbound unresolved names from F27 extraction, when Lumi identifies repeated unresolved company strings, then one-tap conversion into company + deal should be available. (Shipped 2026-06-16: POST /api/companies/resolve/ creates/reuses the company, backfills dangling CRM-adjacent references, and optionally opens a first-stage deal; CompaniesPage wires the unresolved strip to Create and + deal actions.)
    • Given a spreadsheet of companies/contacts/deals, when the owner migrates from Excel, then a bulk import validates and creates rows with a dry-run preview and per-row results. (Backend shipped 2026-06-16, U3: POST /api/companies|contacts|deals/import/, body {rows, dry_run?}, ≤1000 rows. Companies dedup by name_normalized (existing → skipped, reuses id; else create, default is_customer=true); contacts/deals resolve their company_name within the org (unknown → row error, never auto-created); deals resolve pipeline (id-or-name, else first active) + stage (name, else first by order). dry_run rolls back a savepoint and writes nothing. All resolution tenant-scoped (Rule 1). Web shipped 2026-06-16, U3: CrmImportPage at /crm/import (sidebar Business → Import) — a 4-step wizard: pick entity (company/contact/deal) + paste TSV from Excel or upload a .csv; a dependency-free CSV/TSV parser (src/lib/parseTable.ts, auto-detects tab vs comma, handles quoted/escaped/multiline fields + CRLF/BOM); column-mapping with case-insensitive auto-match and required-field guards; dry-run preview (useCrmImport → POST dry_run:true) showing created/skipped/error counts + a per-row results table; then real import (dry_run:false). >1000 rows blocks import with a clear message. Native .xlsx parsing is deferred (no xlsx/papaparse dep) — paste-from-Excel covers it.)

2.11 Permissions, ownership, and operational control

  • As an admin or manager, I want CRM permissions to reflect real operational ownership, so that pipeline truth is not corrupted by casual edits.
    • Given a tenant boundary, when any CRM record is read or written, then access is organization-scoped.
    • Given a deal comment, when a non-author tries to delete it, then the API rejects the delete.
    • Given private manager notes, when a non-authorized member opens the deal, then they should not see restricted notes. (Shipped 2026-06-16 as part of U11: DealComment.visibility supports shared|private; non-manager non-authors cannot read another member’s private note, while the author and managers/admins/owners can.)
    • Given stage configuration, when a non-admin attempts to change pipeline structure, then permissions should block the write and the UI should explain why. (Backend shipped 2026-06-16, Phase D: StageViewSet/PipelineViewSet reads stay open to members; structural writes require admin/owner via IsPipelineStructureAdmin, returning 403 with detail “Only an admin can change pipeline structure.” Web UI shipped 2026-06-16, Phase D: CrmSettingsPage reads the org role; non-admins see an explanatory info notice and all structural controls (create/rename pipeline, add/save/reorder/delete stage) render disabled with a tooltip; a 403 still surfaces the server detail in a toast.)
    • Given a sales rep, when they bulk-edit deals outside their scope, then permission rules should be explicit rather than emerging accidentally from endpoint shape. (Backend: bulk edits are tenant-scoped — a member cannot mutate another org’s deals (404); explicit cross-tenant tests added for every new bulk action, Phase D.)

2.12 Quote structure, products, and commercial clarity

  • As a salesperson or owner, I want to understand what a deal amount is made of, so that large quotes are explainable and auditable.
    • Given a deal, when products or line items are added, then deal value rolls up from structured rows rather than only a single freeform total. (Backend shipped 2026-06-16: recompute_deal_amount rolls Deal.amount = Σ line_total on every line-item create/update/delete; a manual amount with no line items is never overwritten. UI shipped 2026-06-16, Phase B web: DealLineItems table in the deal detail panel, with a Σ rollup footer and “deal amount is derived from these line items” note.)
    • Given line items, when quantity, unit price, discount, or tax changes, then the extended amount recalculates deterministically. (Backend shipped 2026-06-16: line_total = qty × price × (1 − discount_pct/100) × (1 + tax_pct/100), quantized to 2 dp. UI shipped 2026-06-16, Phase B web: editing a row re-fetches line_total from the server and re-rolls the deal amount via TanStack Query invalidation of the deal detail.)
    • Given a contractor quote, when the owner opens the deal, then they can see whether value is mostly labor, material, subcontractor, or service mix. (Shipped 2026-06-18: DealLineItem.value_category (labor|material|subcontractor|service, default service) and /api/deals/<id>/line-items/ now returns both items and value_mix (amount + percentage per category). DealLineItems.tsx renders the editable category column plus a compact grouped breakdown under the table.)
    • Given a product catalog, when common packages or SKUs recur, then reusable products should be selectable rather than retyped each time. (Backend shipped 2026-06-17: ProductCatalogItem (crm_product_catalog_items) — org-scoped name/sku/description/unit_price/is_active, conditional (organization, sku) unique constraint (blank SKU allowed), (organization, is_active, name) index; /api/products/ ModelViewSet — reads open to IsOrgMember, writes gated by IsPipelineStructureAdmin, ?active=true|false filter. Web shipped 2026-06-17: ProductCatalogSection on /crm/settings (admin-gated, mirrors the custom-fields section) for create/edit/delete; DealLineItems gains a “Use a saved product” picker that prefills name/description/unit_price into the add/edit row. Value-mix breakdown (next bullet) stays out of scope.)
    • Given line items, when the owner opens the báo giá, then a structured quote document (line items + subtotal/discount/tax/total in the deal currency + stored amount) is available to render/print. (Backend shipped 2026-06-16: GET /api/deals/<id>/quote/ → build_deal_quote (apps/crm/quote.py); Decimal-exact 2 dp; empty line items → zero totals + the deal’s stored amount. Web shipped 2026-06-16, U4: DealQuoteSection.tsx adds a “Quote” affordance to the deal detail panel; “View quote” opens a document-like dialog — company + deal title, a line-items table (item/qty/unit price/discount %/tax %/line total), then subtotal / discount / tax / total in the deal currency. A Print button (window.print() + a scoped @media print layout keyed off html[data-quote-printing], design-token colors only) prints just the quote sheet. Empty line items → a gentle “Add line items to build the quote” hint. dealsApi.quote(id) + useDealQuote(id) (lazy, fetched only when the dialog opens).)
      • Native mobile contract: shared Android/iOS Deal Detail renders a read-only commercial summary from that same tenant-scoped quote response. The first view shows the server total and compact line-item rows with quantity and line total; an empty quote explains that no structured items exist without replacing the deal’s stored amount. Quote loading and failure are isolated from deal, activity, stakeholder, and comment content. A failed refresh retains the last quote behind one compact retry. Native product selection, line-item mutation, printing, and external sending remain outside this slice.
    • Given a deal báo giá, when the owner sends it for external confirmation (F71), then the external party sees the same quote on the public /c/<token> page. (Backend shipped 2026-06-16: build_deal_quote_summary(deal) produces the ConfirmationRequest.shared_summary snapshot — company name + total + currency + line items — surfaced from the quote endpoint for the frontend to pass on confirmation create. On confirm/decline of a deal subject, a DealActivity with origin="confirmation_link" is logged on the deal — Loop-2 provenance. Web shipped 2026-06-16, U4: the quote dialog’s “Send for confirmation” button reuses the existing F71 SendConfirmationDialog with subjectType="deal", subjectId={deal.id}, the company name pre-filled as the external party label and role client; on create it reveals the copyable public_url so the owner can send it via Zalo/email. No F71 surface rebuilt.)

3. Scope Boundaries

  • In:

    • Multi-view deal pipeline (kanban, table, calendar, timeline)
    • Deal ordering and stage movement
    • Activity timeline and comments
    • Pipeline analytics and health indicators
    • Bulk operations
    • Company timeline / relationship memory
    • Better creation shortcuts
    • Benchmark-driven roadmap items that keep Lumi on a Pipedrive-class CRM path without bloating sideways
  • Out:

    • Marketing automation, email campaigns, ad attribution
    • Ticketing / support desk workflows
    • CPQ-level quoting complexity, invoice generation, accounting sync
    • Territory hierarchies and enterprise multi-region org charts
    • AI lead scoring black boxes without evidence
    • Generic “all-in-one revops suite” ambitions that do not tighten the contractor wedge

3.1 Benchmark capability matrix

CapabilityPipedrive / top-CRM patternLumi current stateProduct decision
Multiple deal viewsBoard, list/table, calendar, forecast/report viewsboard, table, calendar, timelinekeep and deepen
Scheduled activitiesfirst-class calls/meetings/tasks with due dates and next-step disciplinemissinghigh-priority gap
Deal historystructured activity feed, often field-level audit on key changesstrong partial DealActivitybroaden change coverage
Deal-contact rolesseveral contacts tied to one deal, each with a rolemissingimportant gap
Products / line itemsproducts or line items per dealmissingimportant gap for quotes/proposals
Custom fields in UIadmin-defined fields rendered and editable in record formsbackend-only validation, weak UI exposureclose the gap
Saved filters/viewsnamed saved views, often sharablemissingadd if the pipeline becomes heavier operationally
Forecast categoriescommit / best case / pipeline / omittedmissingimportant gap for managerial use
Conversion analyticsstage conversion, duration, owner performancemissing beyond weighted pipelinephase 2
Automationworkflow automation / remindersmissingonly after core CRM truth is solid
Account timelineunified company history across eventsgood partialextend with more event types
Permissionsrole-sensitive editing / visibility on notes and recordsbasic tenant and owner semanticsneeds tightening
Contact roles on dealsdecision-maker / champion / site contact semanticsmissinghigh-value contractor CRM gap
Products / catalogreusable products and quote structureProductCatalogItem + /api/products/ + settings UI + line-item prefill shipped (web, 2026-06-17)shipped; native shells pending
Forecast semanticsclear forecast categories and period rollupsmissingimportant manager-grade gap
Saved operational viewsnamed views for stale, overdue, closing-this-week, etc.missingneeded once teams rely on the CRM daily

3.2 Standard Lumi should actually hold itself to

For F50, “comparable to top CRMs” should not mean “become HubSpot.” The correct standard is:

  1. Every open deal has a clear next action or is visibly unowned

  2. Every manager can trust pipeline state without calling around

  3. Every company record can tell the relationship story in one place

  4. Revenue discussions are based on structured deal evidence, not memory

  5. The CRM gets better than Excel at the exact workflows this contractor actually runs

  6. Fast capture stays available from the core CRM work surfaces

3.3 What Lumi should copy from Pipedrive, and what it should refuse

Copy

  • activity-first discipline
  • clean pipeline mechanics
  • visible aging / stale work
  • structured deal/account/contact relationships
  • fast operator flows

Refuse

  • feature sprawl that turns every screen into a generic revops cockpit
  • marketing-suite creep
  • enterprise-admin labyrinths
  • fake AI scoring that cannot be audited

3.4 What Lumi should copy from HubSpot, and what it should constrain

Copy

  • richer record model
  • flexible properties / custom fields
  • better account context and related-record navigation

Constrain

  • property explosion without a wedge-driven reason
  • object proliferation that obscures the contractor workflow
  • dashboards that summarize more than the user can act on

3.5 What top CRMs converge on, and why that matters

Pipedrive, HubSpot, Zoho CRM, and lighter CRM surfaces converge on the same point: the board is skin, not the moat. Operating leverage requires:

  1. A next step for every open deal. Missing activity is a defect.
  2. Records that survive staff turnover. Contacts, quote structure, activity history, and ownership retain knowledge.
  3. Repeatable management slices. Saved views, forecast categories, stale filters, and close-window slices answer recurring queries.
  4. Explainable revenue. A deal without line items, a role map, or a next step is only a hopeful number.
  5. One-gesture capture. Slower than chat or Excel means skipped capture.

Lumi copies these disciplines, not the surrounding bloat.

4. Data Model

4.1 Confirmed current models

class Company(UUIDModel): ...
class Contact(UUIDModel): ...
class FieldDefinition(UUIDModel): ...
class Pipeline(UUIDModel): ...
class Stage(UUIDModel): ...
class Deal(UUIDModel): ...
class DealActivity(UUIDModel): ...
class DealComment(UUIDModel): ...
class CRMActivity(UUIDModel): ...   # Phase A (2026-06-16): the next-step engine — shipped backend
class DealContactRole(UUIDModel): ...  # Phase B (2026-06-16): multi-stakeholder deals — shipped backend
class DealLineItem(UUIDModel): ...     # Phase B (2026-06-16): quote line items + Deal.amount rollup — shipped backend
class SavedDealView(UUIDModel): ...    # Phase D (2026-06-16): named saved deal views — shipped backend

Key confirmed current fields:

  • Company: relationship booleans, owner, custom_fields
  • Contact: company, phone, zalo, email, title, custom_fields
  • Stage: order, expected_days, probability, terminal flags, required_fields (F50 U7, 2026-06-16 — JSON list of field keys a deal must have filled to ENTER this stage; built-in amount/expected_close_date/owner check the deal column, any other key checks Deal.custom_fields; empty default never gates)
  • Deal: company, pipeline, stage, amount, currency, owner, expected_close_date, status, lost_reason, closed_on, stage_changed_at, position, forecast_category (F50 Phase C, 2026-06-16 — pipeline/best_case/commit/omitted, default pipeline), deposit + handover milestones (F50 U5, 2026-06-16): deposit_amount (Decimal, nullable), deposit_due_date (Date, nullable), deposit_paid (Bool, default false), kickoff_date (Date, nullable — thi công start), handover_date (Date, nullable — nghiệm thu); derived read-only is_deposit_overdue property (open ∧ ¬paid ∧ due_date < today — never stored, mirrors CRMActivity.is_overdue), custom_fields. Migration 0013_deal_deposit_amount_deal_deposit_due_date_and_more. All five fields auto-exposed writable by DealSerializer (__all__); is_deposit_overdue added as a ReadOnlyField.
  • DealActivity: action + metadata JSON, origin (F50 U11, 2026-06-16 — provenance: user/system/import/confirmation_link, default user; shared ORIGIN_CHOICES)
  • DealComment: linear comments + visibility (F50 U11, 2026-06-16 — shared/private, default shared; private notes visible only to author + managers/admins/owner) + origin (same ORIGIN_CHOICES, default user). Migration 0014_dealactivity_origin_dealcomment_origin_and_more.

4.2 Confirmed current model strengths

  • Stage is a DB row, not JSON
  • Deal ordering via fractional position is already in place
  • Weighted pipeline is possible because stage probability exists
  • Activity rows are real persisted objects, not derived logs
  • Company/deal custom field definitions exist and are validated server-side

4.3 Important model gaps vs top CRM requirements

These are not first-class today and are the main structural reasons F50 is not yet Pipedrive-class. Each is specified concretely so a future PR can implement against it without re-deciding shape.

1. CRMActivity — the next-step engine (P0). The single biggest gap. Top CRMs are not boards plus comments; they are activity engines. Every open deal should either have a scheduled next action or be visibly flagged as having none.

class CRMActivity(UUIDModel):
    TYPE_CHOICES = [
        ("call", "Call"), ("meeting", "Meeting"), ("site_survey", "Site Survey"),
        ("quote_followup", "Quote Follow-up"), ("deposit_followup", "Deposit Follow-up"),
        ("task", "Task"), ("other", "Other"),
    ]
    STATUS_CHOICES = [
        ("scheduled", "Scheduled"), ("done", "Done"), ("cancelled", "Cancelled"),
    ]
    organization = models.ForeignKey("organizations.Organization", on_delete=models.CASCADE, related_name="crm_activities")
    deal = models.ForeignKey(Deal, null=True, blank=True, on_delete=models.CASCADE, related_name="activities_scheduled")
    company = models.ForeignKey(Company, null=True, blank=True, on_delete=models.CASCADE, related_name="activities")
    contact = models.ForeignKey(Contact, null=True, blank=True, on_delete=models.SET_NULL, related_name="activities")
    activity_type = models.CharField(max_length=20, choices=TYPE_CHOICES, default="task")
    subject = models.CharField(max_length=255)
    notes = models.TextField(blank=True)
    due_at = models.DateTimeField(null=True, blank=True)
    completed_at = models.DateTimeField(null=True, blank=True)
    owner = models.ForeignKey("organizations.OrganizationMember", null=True, on_delete=models.SET_NULL, related_name="crm_activities")
    status = models.CharField(max_length=12, choices=STATUS_CHOICES, default="scheduled")

    class Meta:
        db_table = "crm_activities"
        ordering = ["due_at", "-created_at"]
        indexes = [
            models.Index(fields=["organization", "owner", "status", "due_at"]),
            models.Index(fields=["deal", "status", "due_at"]),
        ]

    @property
    def is_overdue(self):
        from django.utils import timezone
        return self.status == "scheduled" and self.due_at is not None and self.due_at < timezone.now()
  • overdue is derived (is_overdue), not a stored status, so the clock never goes stale.
  • Constraint: at least one of deal/company/contact must be set (enforce in serializer).
  • Brief-glue (F12): an open deal with no scheduled future activity, or with an overdue activity, is a Brief candidate.

2. DealContactRole — multi-stakeholder deals (P0). One báo giá rarely has one human behind it: a decision-maker, a procurement lead, a site contact, finance.

class DealContactRole(UUIDModel):
    ROLE_CHOICES = [
        ("decision_maker", "Decision Maker"), ("champion", "Champion"),
        ("procurement", "Procurement"), ("site_contact", "Site Contact"),
        ("finance", "Finance"), ("other", "Other"),
    ]
    organization = models.ForeignKey("organizations.Organization", on_delete=models.CASCADE, related_name="deal_contact_roles")
    deal = models.ForeignKey(Deal, on_delete=models.CASCADE, related_name="contact_roles")
    contact = models.ForeignKey(Contact, on_delete=models.CASCADE, related_name="deal_roles")
    role = models.CharField(max_length=20, choices=ROLE_CHOICES, default="other")

    class Meta:
        db_table = "crm_deal_contact_roles"
        constraints = [models.UniqueConstraint(fields=["deal", "contact", "role"], name="crm_deal_contact_role_unique")]
  • Validate contact.company_id == deal.company_id unless the org allows cross-company stakeholders (decision: disallow in v1).

3. DealLineItem — quote structure and value transparency (P1). Lets Deal.amount be explained, not just asserted. Stops short of full CPQ.

class DealLineItem(UUIDModel):
    organization = models.ForeignKey("organizations.Organization", on_delete=models.CASCADE, related_name="deal_line_items")
    deal = models.ForeignKey(Deal, on_delete=models.CASCADE, related_name="line_items")
    name = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    value_category = models.CharField(max_length=20, choices=VALUE_CATEGORY_CHOICES, default="service")
    quantity = models.DecimalField(max_digits=12, decimal_places=2, default=1)
    unit_price = models.DecimalField(max_digits=18, decimal_places=2, default=0)
    discount_pct = models.DecimalField(max_digits=5, decimal_places=2, default=0)
    tax_pct = models.DecimalField(max_digits=5, decimal_places=2, default=0)
    sort_order = models.IntegerField(default=0)

    class Meta:
        db_table = "crm_deal_line_items"
        ordering = ["sort_order", "created_at"]

    @property
    def line_total(self):
        gross = self.quantity * self.unit_price
        net = gross * (1 - self.discount_pct / 100)
        return net * (1 + self.tax_pct / 100)
  • Decision: line items are optional. When present, recompute Deal.amount from the sum of line_total on write; when absent, Deal.amount is entered directly. Never silently overwrite a manual amount with an empty line-item set. Each line item also carries a lightweight value_category so the quote can expose labor/material/subcontractor/service mix without widening into CPQ.

4. Deal.forecast_category — managerial forecasting (P1). Adds the field every sales manager actually forecasts on, distinct from stage probability.

# Deal — new field:
FORECAST_CHOICES = [
    ("pipeline", "Pipeline"), ("best_case", "Best Case"),
    ("commit", "Commit"), ("omitted", "Omitted"),
]
forecast_category = models.CharField(max_length=12, choices=FORECAST_CHOICES, default="pipeline")
  • Manual in v1 (see §12 Q3). Weighted pipeline and forecast rollups read this alongside stage.probability.

5. SavedDealView — repeatable operational filtering (P1).

class SavedDealView(UUIDModel):
    organization = models.ForeignKey("organizations.Organization", on_delete=models.CASCADE, related_name="saved_deal_views")
    owner = models.ForeignKey("organizations.OrganizationMember", on_delete=models.CASCADE, related_name="saved_deal_views")
    name = models.CharField(max_length=120)
    view_mode = models.CharField(max_length=16, default="kanban")  # kanban|table|calendar|timeline
    filters = models.JSONField(default=dict)   # {stage, owner, pipeline, health, forecast_category, close_window}
    sort = models.JSONField(default=list)      # [{field, dir}]
    is_shared = models.BooleanField(default=False)

    class Meta:
        db_table = "crm_saved_deal_views"
        ordering = ["name"]
  • Examples this unlocks: “my overdue closes”, “stale > 14d”, “this week’s deposits”, “commit this month”.

6. Activity / note provenance. Provenance is an explicit origin field rather than inferred from actor IS NULL. As shipped (aligned 2026-07-02): the full four-value vocabulary user/system/import/confirmation_link lives on DealActivity (apps/crm/models.py ORIGIN_CHOICES) — e.g. an F71 confirm/decline on a deal subject writes a DealActivity with origin="confirmation_link" (apps/confirmations/views.py). The universal Comment model (F81, which replaced DealComment on 2026-06-21) uses the narrower user/system/ai (apps/comments/models.py ORIGIN_CHOICES); on create, any out-of-vocab origin is coerced to user. No code path creates a Comment with import or confirmation_link origin — the DealComment→Comment backfill (apps/comments/migrations/0003) collapsed those legacy values to system, and confirmation-link provenance is captured on the DealActivity timeline, not the comment.

4.4 Model decisions Lumi should keep

  • No separate Lead entity yet
  • No giant email thread model inside CRM
  • No heavy quote/invoice/accounting schema in F50
  • No probability auto-scoring magic

5. API Contract

5.1 Confirmed shipped endpoints

MethodPathAuth/RolePurpose
CRUD/api/companies/member / viewer readcompany records
GET/api/companies/<id>/signals/viewercompany 360 aggregated signals
GET/api/companies/<id>/timeline/viewermerged company timeline
GET/api/companies/unresolved/viewerunresolved extraction/company strings
POST/api/companies/resolve/memberone-tap resolve an unresolved extracted name → Company + backfill (F50 U8, 2026-06-16). Body {name, create_deal?, pipeline?}. Atomic: get-or-create the Company by normalized name (reuses an existing same-normalized-name company, never duplicates; new ones are is_customer=True), then set the company FK on every dangling Risk.related_customer_name / Commitment.customer_name / Task.customer_name row in the org whose trimmed+lowercased name matches (same grouping as unresolved, so the name drops out of it). Optional create_deal=true opens a Deal on the first stage (by order) of the given/default-active pipeline, stamping stage_changed_at; if no pipeline/stage exists the deal is skipped (never a 500). Empty/missing name → 400. Tenant-scoped throughout — a name from org A never touches org B rows. Returns {company, backfilled:{risks,commitments,tasks}, deal}
CRUD/api/contacts/member / viewer readcontacts. List filters (all tenant-scoped, ANDed onto org scope): ?company=<id> (a company id from another org returns empty — Company-360 People tab), ?search=<str> (name/email icontains via SearchFilter), ?relationship=customer|supplier|partner (contacts whose company has that flag) (2026-06-16)
CRUD/api/field-definitions/admincustom field definitions
CRUD/api/pipelines/member / viewer readpipelines
CRUD/api/stages/member / viewer readstages. required_fields (F50 U7, 2026-06-16): a JSON list of field keys a deal must have filled to ENTER the stage; writable only by admin/owner (rides the existing IsPipelineStructureAdmin gate). Empty default never gates.
CRUD/api/deals/member / viewer readdeals. Stage-entry policy (F50 U7, 2026-06-16): a POST (create) or PATCH (update) that places a deal into a stage with non-empty required_fields whose post-update values aren’t all filled returns 400 {stage: "Cannot move to '<stage>': missing <keys>.", missing_required_fields: [...]}. Built-in amount/expected_close_date/owner check the deal column; any other key checks Deal.custom_fields. List filters (all tenant-scoped, ANDed onto org scope; absent or invalid enum/uuid → no filter, never a 500): ?pipeline=<id>, ?stage=<id>, ?company=<id>, and the saved-view vocabulary (F50 Phase D, U10, 2026-06-16) ?owner=<member-id>, ?status=open|won|lost, ?forecast_category=pipeline|best_case|commit|omitted, ?health=overdue|no_activity|stale|close_soon, ?close_window=this_week|this_month|overdue. Health derives from existing state (reusing the has_overdue_activity/has_open_activity annotations): overdue=has a past-due scheduled activity; no_activity=open deal with no scheduled activity; stale=open deal whose stage_changed_at is older than the stage’s expected_days (default 14 when null), computed DB-side per row; close_soon=open deal with expected_close_date in the next 7 days. close_window filters expected_close_date: this_week=current ISO week (Mon–Sun); this_month=current calendar month; overdue=< today AND status open. Lets saved views filter server-side at scale
PATCH/api/deals/<id>/reorder/memberposition within stage
POST/api/deals/<id>/move/memberstage/position move. Stage-entry policy (F50 U7, 2026-06-16): moving into a stage with unsatisfied required_fields returns 400 {detail: "Cannot move to '<stage>': missing <keys>.", missing_required_fields: [...]} (same shared missing_required_fields() helper as the serializer path).
GET/api/deals/analytics/viewerpipeline analytics (F50 Phase C, 2026-06-16: now also returns by_currency + weighted_by_currency per-currency breakdowns alongside the legacy merged keys)
PATCH/api/deals/<id>/forecast-category/memberset forecast bucket (commit/best_case/pipeline/omitted); invalid choice → 400 (F50 Phase C)
GET/api/deals/forecast/viewerper-currency forecast rollups by category + weighted pipeline; ?pipeline=&period=YYYY-MM; never merges currencies (F50 Phase C)
GET/api/deals/conversion/viewerstage progression counts, avg stage duration, win-rate by owner + by pipeline; ?pipeline= (F50 Phase C)
GET/api/deals/aging/viewerper-stage stale counts (deals past stage.expected_days) + avg days in stage; ?pipeline= (F50 Phase C)
GET/api/deals/<id>/activities/viewerdeal activity timeline
GET/api/comments/?target_type=deal&target_id=<deal-id>member / viewer readDeal comments now live on the universal F81 Comment endpoint (apps/comments; the per-deal DealComment model + nested /api/deals/<id>/comments/ route were consolidated away in migration 0018 on 2026-06-21 — see F81). target_type+target_id are required query params (missing → 400); returns root comments keyset-paginated (?after=<id> cursor, ?resolved=true to include resolved). Visibility (F50 U11 semantics, preserved): LIST is org-scoped first, then filtered — managers/admins/owner see all; everyone else sees shared comments + their own private ones.
POST/api/comments/membercreate a deal comment. Body: target_type="deal", target_id=<deal-id>, body (or an upload attachment), optional parent (reply), visibility (shared default · private; out-of-vocab → coerced to shared), origin (user default; out-of-vocab → coerced to user — note the universal Comment.ORIGIN_CHOICES is the narrower user|system|ai, see §4.3(6)), anchor. Comment notifications fire through the shared notify_comment service.
PATCH/api/comments/<id>/authoredit own comment
DELETE/api/comments/<id>/authordelete own comment
POST/api/comments/<id>/resolve/memberresolve/unresolve a root comment ({resolved: bool})
POST/api/deals/bulk/memberbulk ops. action ∈ move_stage·update_owner·archive·update_close_date·update_pipeline·update_forecast_category·update_custom_field (F50 Phase D added the last four). Tenant-scoped; per-deal DealActivity; params validated (400 on missing/invalid). update_pipeline keeps a stage compatible with the destination pipeline, else lands on its first stage
GET/api/deal-activities/viewerflat read-only activity feed
CRUD/api/crm-activities/memberscheduled CRM activities (next-step engine, F50 Phase A). Filters: deal, company, contact, owner, status, activity_type, overdue=true, upcoming=true
POST/api/crm-activities/<id>/complete/membermark activity done (sets status=done, completed_at=now)
GET/POST/api/deals/<id>/contacts/member / viewer readlist deal-contact roles · assign a contact to the deal with a role (F50 Phase B). Rejects cross-company contacts (contact.company_id == deal.company_id) and duplicate (deal, contact, role) with 400
DELETE/api/deals/<id>/contacts/<role_id>/memberremove a deal-contact role
GET/POST/api/deals/<id>/line-items/member / viewer readlist quote line items · add one (F50 Phase B). GET now returns {items:[...], value_mix:[{category, amount, percentage}]}; each row carries value_category (`labor
PATCH/DELETE/api/deals/<id>/line-items/<item_id>/memberedit / remove a line item; triggers the Deal.amount rollup. Deleting the last item leaves the last computed amount (never zeroes a manual amount)
GET/api/deals/<id>/quote/member / viewer readBáo giá (quote) document (F50 U4, 2026-06-16). Structured quote: {deal, title, company:{id,name}, currency, line_items:[{name,description,quantity,unit_price,discount_pct,tax_pct,line_total}], subtotal (Σ qty·unit_price), discount_total, tax_total, total (Σ line_total), amount (Deal.amount)}. Decimal-exact, quantized 2 dp, in the deal’s currency. Empty line items → zero totals + the stored amount. The frontend also passes build_deal_quote_summary(deal) as shared_summary when sending the quote for F71 confirmation. Tenant-scoped via get_object (Rule 1)
CRUD/api/deal-views/membernamed saved deal views (F50 Phase D). A member sees their own views + org-shared (is_shared=True) views read-only; create stamps owner+org; update/delete are owner-only (non-owner editing a shared view → 403 with detail)
POST/api/companies/import/memberBulk company import (F50 U3, 2026-06-16). Body {rows:[{name (req), is_customer?, is_supplier?, is_partner?, tax_code?, website?, industry?, city?}], dry_run?}, ≤1000 rows (else 400). Dedup by name_normalized within the org → existing → skipped (reuses id); else create via CompanySerializer (default is_customer=true when no relationship flag). Bad row → error, continues. dry_run rolls back a savepoint (writes nothing, id:null). Returns the shared import contract (below)
POST/api/contacts/import/memberBulk contact import (F50 U3, 2026-06-16). Body {rows:[{company_name (req), name (req), phone?, zalo?, email?, title?}], dry_run?}, ≤1000. Resolves company_name by name_normalized within the org (never auto-creates — company import runs first); unknown → row error "Unknown company '<x>'". Same contract
POST/api/deals/import/memberBulk deal import (F50 U3, 2026-06-16). Body {rows:[{company_name (req), title (req), amount?, currency?, pipeline? (name|id), stage? (name)}], dry_run?}, ≤1000. Resolves company (unknown → error), pipeline (id-or-name, else org’s first active), stage (name within pipeline, else first by order); no pipeline/stage → error. Creates an open deal, stamps stage_changed_at. Same contract

Import result contract (shared by all three /import/ endpoints). Always 200: {dry_run, created, skipped, errors:[{row,error}], results:[{row, status:"created"|"skipped"|"error", id:"<uuid|null>", detail}]}. Rows processed in order, each independent — a per-row failure never aborts the rest. All resolution/dedup tenant-scoped (Rule 1): a company_name/pipeline/stage from another org is never matched.

Deal records now expose three read-only next-step fields on GET /api/deals/ and GET /api/deals/<id>/ — next_activity_at (earliest scheduled due_at), has_overdue_activity, has_open_activity — annotated in the queryset to avoid N+1.

5.2 Confirmed contract strengths

  • Tenant scoping is explicit and consistent
  • Analytics endpoint exists
  • Bulk operations exist
  • Company timeline exists
  • Comment routes are nested at the deal level
  • Activity feed is both deal-scoped and globally readable

5.3 Current API weaknesses vs benchmark CRM

  • No first-class activity scheduling endpoints
  • No endpoints for deal-contact roles
  • No endpoints for deal line items/products
  • No saved view endpoints
  • No forecast-category semantics
  • Limited bulk action vocabulary
  • No explicit stage policy / stage-required-field enforcement surface

5.4 Future API shape for benchmark-grade CRM

If F50 is deepened, the next contract additions should be:

MethodPathPurpose
CRUD/api/crm-activities/scheduled CRM activities
CRUD/api/deals/<id>/contacts/assign contacts to deals with roles
CRUD/api/deals/<id>/line-items/structured quote items
CRUD/api/deal-views/save/share named filters and sort layouts
PATCH/api/deals/<id>/forecast-category/commit / best_case / pipeline / omitted
GET/api/deals/forecast/period-based forecast rollups

Representative payloads (so a future PR builds the exact contract, not an approximation):

// POST /api/crm-activities/
// → 201
{
  "id": "uuid",
  "deal": "deal-uuid",
  "contact": "contact-uuid",
  "activity_type": "site_survey",
  "subject": "Khảo sát mặt bằng tầng 3",
  "due_at": "2026-06-18T02:00:00Z",
  "owner": "member-uuid",
  "status": "scheduled",
  "is_overdue": false
}
// GET /api/deals/forecast/?pipeline=<id>&period=2026-06
{
  "period": "2026-06",
  "currency_mode": "per_currency",          // never silently merge currencies
  "by_category": {
    "commit":    { "count": 6,  "value": { "VND": 920000000 } },
    "best_case": { "count": 9,  "value": { "VND": 1480000000 } },
    "pipeline":  { "count": 22, "value": { "VND": 3100000000 } },
    "omitted":   { "count": 4,  "value": { "VND": 210000000 } }
  },
  "weighted_pipeline": { "VND": 1357000000 }
}
// PATCH /api/deals/<id>/forecast-category/  { "forecast_category": "commit" }
// → 200  { "id": "uuid", "forecast_category": "commit" }

Contract rules that make this benchmark-grade, not decorative:

  • Forecast and analytics responses are per-currency ({"VND": ..., "USD": ...}), never a single merged number across currencies (see §9 row 6).
  • is_overdue is computed server-side from due_at vs now, so clients never disagree on staleness.
  • Activity write requires at least one of deal/company/contact; reject with 400 otherwise.
  • Saved views are tenant-scoped; is_shared=true exposes them org-wide read-only, edits stay with owner. | GET | /api/deals/aging/ | stage-duration and stale-deal analytics | | GET | /api/products/ | reusable product catalog items (?active=true|false); members read | | POST/PATCH/DELETE | /api/products/ /api/products/<id>/ | create/edit/remove catalog items — admin/owner only (IsPipelineStructureAdmin) |

Contract Graph reconciliation (2026-07-16): the shipped PATCH /api/products/{id}/ mutation is separately represented as F50.AC001.PRODUCT_PATCH; repository ownership does not establish backend execution, browser, native, live-service, or production evidence.

6. UI / UX

6.1 Confirmed current shipped CRM surfaces

  • DealsPage.tsx
  • CompaniesPage.tsx
  • frontend/src/pages/crm/ViewSwitcher.tsx
  • TableView.tsx
  • CalendarView.tsx
  • TimelineView.tsx
  • PipelineStats.tsx
  • DealHealthBadge.tsx
  • DealDetailPanel.tsx
  • DealTimeline.tsx
  • CompanyTimeline.tsx
  • CompanyPeople.tsx (2026-06-16 — People tab in Company360: lists a company’s contacts (name, title, tappable phone/email + zalo) with add/edit/delete inline, contact-count tab badge, empty/loading/error states; useCompanyContacts/useCreateContact/useUpdateContact/useDeleteContact in useDealStructure.ts, all ?company= scoped via TanStack Query)
  • CrmSettingsPage.tsx — now reachable (2026-06-16): routed at /crm/settings, linked from the sidebar Business group and via a gear icon-link in the deals-board header. Previously orphaned (added 548ac7d7, never wired into App.tsx). Hosts pipeline + stage config and (2026-06-16, U1) a Custom Fields section: define/edit/delete FieldDefinitions per entity_type (company/contact/deal) — key (slug-ified), en/vi label, type, required, and a chip choices editor for choice — admin-gated via canEditStructure, reusing the page’s permission notice + 403 toast handler. Hooks useCreate/Update/DeleteFieldDefinition.
  • NextActivityCard.tsx (F50 Phase A, 2026-06-16 — next-step discipline card + schedule form in the deal detail panel)
  • NextActivityIndicator.tsx (F50 Phase A, 2026-06-16 — compact next-activity / “no next activity” marker on kanban cards and table rows)
  • DealContactRoles.tsx (F50 Phase B, 2026-06-16 — multi-stakeholder role chips + add/remove in the deal detail panel; contact picker scoped to the deal’s company)
  • DealLineItems.tsx (F50 Phase B, 2026-06-16 — editable quote line-item table with add/edit/delete + Σ rollup footer in the deal detail panel)
  • DealQuoteSection.tsx (F50 U4, 2026-06-16 — “Quote” affordance on the deal detail panel; opens a document-like dialog rendering the báo giá from GET /deals/<id>/quote/ — company + title, line-items table, subtotal/discount/tax/total in the deal currency — with a Print button (scoped @media print layout) and a “Send for confirmation” button that reuses the F71 SendConfirmationDialog with subjectType="deal")
  • components/crm/CustomFieldsEditor.tsx (F50 Phase B, 2026-06-16 — renders admin-defined custom fields per field_type; wired into the deal and company create forms)
  • DealAnalyticsPanel.tsx (F50 Phase C, 2026-06-16 — expandable conversion + aging analytics section on the deals board: win-rate by owner/pipeline, stage duration + progression, per-stage stale counts + avg days-in-stage, honest basis note; loading/empty/error states)

6.2 Current UX strengths

  • Four view modes already exist
  • Board reorder exists
  • Analytics banner exists
  • Health badges exist
  • Deal detail panel is richer than a basic kanban card
  • Company timeline is already more relational than many lightweight CRMs

6.3 UX gaps that matter most

  1. No next activity discipline

    • This is the biggest practical miss relative to top CRMs.
  2. Deal records are still thinner than quote-driven sales work needs

    • No contact-role assignment
    • No line items/products
    • Limited quote structure
  3. Saved operational views are missing

    • Managers often need “my overdue closes”, “stale >14d”, “this week’s deposits”, etc.
  4. Custom fields are not a real user-facing power yet

    • Backend validation exists, but that is not the same as a useful admin UX
  5. Deal records lack commercial anatomy

    • A total amount without line-item structure is too thin for serious quote-driven selling
  6. Permissions are not yet legible in the UI

    • Benchmark-grade CRM UX explains why an action is unavailable; it does not just fail

6.4 Benchmark conclusions for Lumi CRM UX

From Pipedrive-class systems

  • Next activities must be front-and-center
  • Deals need clear stage, owner, value, next step, and aging
  • Contacts matter inside the deal, not only inside the company

From HubSpot-class systems

  • Account context should be broad and linked
  • Record models can sprawl fast if not constrained
  • Saved views and properties are valuable, but only when they sharpen action

6.5 Lumi-specific UX rules

  • CRM should feel like an operating tool for a contractor, not a sales-theater dashboard
  • Activities, deposits, site surveys, supplier follow-ups, and handover milestones matter more than abstract funnel jargon
  • A calm, dense table is preferable to decorative cards if it shortens decision time
  • The detail panel should keep gaining depth so users do not bounce across routes

6.6 Required benchmark-grade screens and states

The following are the concrete UI surfaces F50 needs before “top CRM comparable” is an honest claim:

  1. Deal detail panel, benchmark version

    • header: company, owner, value, stage, forecast category, expected close
    • next activity card: due time, type, owner, CTA to complete/reschedule — ✅ shipped 2026-06-16 (NextActivityCard): soonest scheduled activity with type icon/subject/due/owner, DESIGN.md-aligned overdue treatment (amber rail + shared overdue badge), Complete + Reschedule actions, a “no next activity” warning + Schedule CTA when an open deal has none, an inline schedule form (subject · type · due_at · owner), and a small list of further scheduled + done activities. Kanban cards and table rows carry a compact NextActivityIndicator. (Forecast category, contacts/roles, line items remain later phases.)
    • contacts on deal: role chips
    • products/line items table
    • comments + activity history
    • linked tasks / commitments / risks
  2. Saved views bar

    • examples: My overdue, Closing this week, No next activity, Stale >14d, Deposit expected
  3. Activity inbox / agenda — ✅ shipped 2026-06-16 (ActivityAgendaPage.tsx, route /crm/agenda, sidebar “Agenda”)

    • daily view of overdue and upcoming CRM activities across all deals: Overdue section first with amber warning treatment, then Upcoming grouped Today / Tomorrow / Later; My/All owner toggle (defaults to the signed-in member); each row reuses the Phase A vocabulary (type icon, due, owner) with Complete + inline Reschedule; loading / error / “you’re clear” empty states. Reuses ?overdue=true / ?upcoming=true / ?owner= on /api/crm-activities/ and the useCompleteCrmActivity / useUpdateCrmActivity hooks.
  4. Forecast view — ✅ shipped 2026-06-16 (ForecastView.tsx)

    • grouped by period and forecast category, with currency-honest totals
    • A “Forecast” view mode in the deals view switcher (persisted with the other view preferences). Grouped by Commit / Best Case / Pipeline / Omitted, each with per-currency totals rendered on separate lines (never summed across currencies), plus per-currency weighted pipeline (excludes omitted). Month period selector. Defines loading (skeleton), empty (no deals this period), and error states. The forecast bucket is set via ForecastCategoryControl (shadcn Select) on the deal detail panel, with a read-only chip on the kanban card; the control PATCHes /api/deals/<id>/forecast-category/ and invalidates deals + forecast + analytics queries. Conversion win-rate + stage-aging surfaces remain deferred (hooks useDealConversion/useDealAging shipped against the backend, no dedicated UI yet).
  5. Stage policy / required-field config + board reason (U7) — ✅ shipped 2026-06-16

    • Config: CrmSettingsPage StageRow carries a quiet “Required fields” chip-toggle editor under the name/days/win-% controls — options are the three built-ins (Amount · Expected close date · Owner, localized) plus the org’s deal custom fields (locale-labeled), persisted via the existing onSave(stage, { required_fields }). Admin-gated: members see the toggles read-only/disabled like the rest of the row (permission-blocked state). A required key no longer matching a known option is still shown as its raw key so it isn’t silently dropped.
    • Board reason: when a drag, the kanban card “Move to” button, or the detail-panel move hits the backend 400 with missing_required_fields, the board toasts a specific reason (deals:move_blocked — “Can’t move to {stage}: add {fields}.”, keys mapped to localized labels) and refetches so the card snaps back to its original column; any other failure keeps the generic error_move.
  6. CRM import wizard (U3) — ✅ shipped 2026-06-16 (CrmImportPage.tsx, route /crm/import, sidebar Business → “Import”)

    • A calm 4-step flow: (1) pick entity (company / contact / deal) + provide data via a paste textarea (TSV from Excel) or a .csv file upload; (2) column mapping — one shadcn Select per target field bound to a parsed header, auto-matched case-insensitively, required fields badged and gated; (3) dry-run preview — builds row objects from the mapping, POSTs dry_run:true, shows created/skipped/error counts + a per-row results table (status badge + detail/error); (4) import — POSTs dry_run:false, shows the final summary and offers “Import another”. Parsing is dependency-free (src/lib/parseTable.ts); >1000 rows blocks the import with a clear message. Loading (button-pending), empty (“no rows found”), and error (toast) states throughout. Native .xlsx parsing is deferred (no xlsx/papaparse dependency) — paste-from-Excel yields TSV and covers the migration path.

Every one of these surfaces must define:

  • empty state
  • loading state
  • permission-blocked state
  • partial-data state
  • success state

6.7 i18n

Current namespaces in play:

  • deals
  • companies

Future expansions will likely require keys for:

  • activity types
  • forecast categories
  • line items / products
  • deal-contact roles
  • saved views / filters

Both en and vi must stay paired.

7. Test Plan

7.1 Confirmed current backend suites

  • backend/tests/test_crm.py
    • TestCompany
    • TestContact
    • TestCustomFields
    • TestCompany360
    • TestPipeline
    • TestDeal
    • TestStageViewSet
    • TestDealTerminalState
    • TestNameResolution
    • TestExtractionResolution
    • TestUnresolvedNames
    • TestCompany360Full
    • TestBriefDealItems
    • TestDealActivity
    • TestDealAnalytics
    • TestStageFields
  • backend/tests/test_crm_extensions.py
    • TestDealActivity
    • TestDealActivityOrigin — deal-activity provenance (origin defaults user, persists non-user values; replaces the removed TestDealComment after the 2026-06-21 comment consolidation)
    • TestDealAnalytics
    • TestDealReorder
    • TestDealActivityOwnerAndStatus
    • TestCompanyTimeline
    • TestBulkOperations
    • TestCoverageEdgePaths
  • backend/tests/test_crm_activities.py (F50 Phase A — next-step engine)
    • TestCRMActivityModel — is_overdue true/false/no-due/done, __str__
    • TestCRMActivityCreate — create against deal/company/contact, at-least-one-of 400, owner default + explicit + owner_name
    • TestCRMActivityCrossTenant — cross-tenant deal/company/contact rejection, list isolation, retrieve 404
    • TestCRMActivityFilters — overdue/upcoming/status/activity_type/deal/company/contact/owner filters, ordering by due_at
    • TestCRMActivityComplete — complete action sets done+completed_at, cross-tenant 404
    • TestCRMActivityUpdateDelete — patch subject, delete
    • TestDealNextStepFields — next_activity_at/has_overdue_activity/has_open_activity annotation values + serializer fallback paths
  • backend/tests/test_crm_forecast.py (F50 Phase C — managerial truth)
    • TestForecastCategoryField — default pipeline, serializer exposes + writable
    • TestForecastCategoryAction — PATCH valid (200, {id, forecast_category}), invalid choice 400, missing field 400, cross-tenant 404
    • TestForecastEndpoint — category rollup counts/values, per-currency never merged, weighted excludes omitted + uses probability, null probability→0, open-only, period filter (incl. December rollover) + invalid 400, pipeline filter, cross-tenant
    • TestConversionEndpoint — win-rate by owner/pipeline math, stage duration from activity, stage progression, pipeline filter, cross-tenant
    • TestAgingEndpoint — stale count past expected_days, avg days in stage, open-only, pipeline filter, cross-tenant
    • TestAnalyticsCurrencyHonesty — legacy keys preserved + by_currency/weighted_by_currency added, cross-tenant
  • backend/tests/test_crm_deal_structure.py (F50 Phase B — deal structure)
    • TestDealLineItemModel — line_total math (qty×price×(1−disc)×(1+tax)), no-disc/no-tax, ordering by sort_order, __str__
    • TestDealContactRoleModel — __str__
    • TestDealContactRoleAPI — add/list/remove role, cross-company contact rejected (400), duplicate (deal, contact, role) rejected (400), same contact different role allowed, cross-tenant list/add/delete rejection (two-org)
    • TestDealLineItemAPI — add/list/patch/delete; rollup correctness (first item overrides manual amount, Σ line_total, patch & delete recompute, deleting all items leaves last computed amount, manual amount never zeroed when no items); ordering by sort_order; cross-tenant list/add/patch/delete rejection (two-org); missing-item 404
  • backend/tests/test_crm_saved_views.py (F50 Phase D — saved views + wider bulk + permission legibility)
    • TestSavedDealViewModel — defaults (kanban/{} /[]/false), __str__, ordering by name
    • TestSavedDealViewCRUD — create stamps owner+org; list shows own + shared, hides others’ private; owner can update/delete; non-owner cannot update/delete a shared view (403 + detail); retrieve a shared view allowed
    • TestSavedDealViewCrossTenant — list isolation, retrieve/update/delete other-org 404
    • TestBulkUpdateCloseDate / TestBulkUpdatePipeline / TestBulkUpdateForecastCategory / TestBulkUpdateCustomField — happy path + per-deal DealActivity audit, missing/invalid params 400, cross-tenant 404; update_pipeline keeps a compatible stage vs lands on first stage, empty-pipeline 400, cross-org pipeline 404
    • TestStageConfigPermission / TestPipelinePermission — member reads allowed; member structural writes blocked 403 with explanatory detail (“admin”); admin/owner writes allowed

7.2 Required test matrix

#CaseTypeWhere
1Deal lifecycle validation (won/lost requirements, stage move stamping)backendexisting CRM suites
2Reorder within stagebackendTestDealReorder
3Deal analytics totals and by-stage statsbackendTestDealAnalytics
4Comment create/delete rulesbackendbackend/tests/test_comments.py (universal Comment, F81) + deal-activity provenance in TestDealActivityOrigin — the old TestDealComment was removed with the DealComment model on 2026-06-21
5Bulk operations tenant safety and per-deal audit loggingbackendTestBulkOperations
6Company timeline merge + pagination capbackendTestCompanyTimeline
7Custom fields validationbackendTestCustomFields
8Name resolution and unresolved namesbackendTestNameResolution, TestUnresolvedNames, TestExtractionResolution
9View switcher across four viewse2eCRM views block
10Board reorder flowe2eCRM board block
11Deal detail comments + activity rendere2edeal detail block
12Table exporte2etable block
13Scheduled CRM activity workflowbackend + e2erequired when implemented
14Deal-contact rolesbackend + e2erequired when implemented
15Deal line items/productsbackend + e2erequired when implemented
16Forecast-category and saved-view semanticsbackend + e2erequired when implemented
17Permission-denied UX for restricted CRM actionse2erequired when implemented
18Mixed-currency analytics and forecast honestybackend + e2erequired when implemented
19No-next-activity warning semanticsbackend + e2erequired when implemented
20Native deal-stage route, returned truth, required-field explanation, role denial, and UI wiringKMP API/model/sourceF50.AC013.* registered proofs

8. Current Behavior

Confirmed current behavior

  • CRM core record model is shipped
  • Four pipeline views are shipped
  • Deal reorder is shipped
  • Analytics are shipped
  • Health indicators are shipped
  • Deal comments are shipped
  • Company timeline is shipped
  • Bulk operations are shipped
  • Scheduled next activities are shipped on the shared mobile deal-detail surface, including a visible recovery path when marking the next activity done fails.

Confirmed missing or weaker-than-top-CRM behavior

  • Native cross-deal activity agenda is present in shared KMP and typed Android/iOS routes; an exact-artifact Android emulator lifecycle now covers localized Mine/All plus complete/reschedule failure recovery, while iOS runtime, named-device, and accessibility-service proof remain open
  • No deal-contact roles
  • No products / line items
  • No saved views
  • No forecast categories
  • No richer conversion analytics
  • Custom fields are validated in backend but weak in frontend UX
  • No explicit next-step discipline on every open deal
  • No deal-contact role model
  • Line-item quote structure shipped (F50 Phase B); reusable product catalog shipped on web (2026-06-17)
  • No saved operational views
  • No explicit forecast category semantics

9. Error States

#ScenarioUser-visibleInternalNotes
1Bulk operation partially succeedspartial-success summaryper-deal errors returnedcurrent contract supports this
2Concurrent reorder conflictboard order may drift until refreshposition conflict risktop-CRM polish would need stronger reconciliation
3Comment deletion by non-author403permission checkshipped
4Invalid or missing bulk params400request validationshipped
5Stage delete with active dealsblockedreferential protection in logicshipped from F27 foundation
6Mixed-currency analytics interpreted as one currencymisleading totalsinsufficient normalization semanticsopen managerial trust issue
7Open deal with no next activitycurrently not a first-class errorproduct gap, not code exceptionbenchmark-critical gap
8Custom fields defined but not operable in UIbackend accepts schema but user cannot fully leverage itproduct gapopen
9Deal has several real stakeholders but only one contact can be operationally trackedusers fall back to notes or external docsmissing relationship structurebenchmark-critical gap
10Large quote represented as one number onlyvalue becomes opaque and hard to challengemissing line-item structureserious sales-ops gap
11Restricted action silently unavailableconfusing UXpermission surface under-specifiedopen
12Marking the next activity done fails on a poor connectionthe current next activity remains visible, the failure is explained inline, and the exact activity can be retried without reselecting itcompletion request must retain its target until successshared mobile recovery contract

10. Diagrams & Docs Touched

  • backend/DIAGRAMS.md
    • CRM entity model
    • deal lifecycle and activity logging
    • future activity/contact-role/line-item relationships
  • frontend/DIAGRAMS.md
    • deal view switching
    • company timeline
    • future activity-centric CRM workflow
  • GLOSSARY.md
    • may need entries for forecast category, next activity, deal role, line item
    • may also need entries for saved view and product catalog item

11. Implementation Audit (2026-06-15)

Verdict: Lumi already has a surprisingly strong CRM core. It is not a toy. But it is still not honestly comparable to a top CRM in the ways that matter operationally. The missing center of gravity is next-step discipline plus richer deal structure. Without those, the pipeline is visible but not yet fully governable.

What the current implementation gets right

  1. The CRM data spine is real

    • Companies, contacts, pipelines, stages, deals, activities, comments, timelines, and custom field definitions all exist as first-class records.
  2. Pipeline mechanics are stronger than a simple board

    • Position ordering, stage aging, probabilities, analytics, and bulk ops are already beyond “good enough CRM.”
  3. Company relationship memory is a real differentiator

    • The company timeline and 360 view already make Lumi more relational than many lightweight CRMs.
  4. The implementation is grounded in tenant safety

    • Cross-org scoping is present across the surface.

Where spec and code disagree, ordered by priority

  1. [P0] No first-class CRM activities / next-step engine

    • This is the biggest gap vs Pipedrive-class CRM behavior.
    • Without explicit activities, an open deal can still be structurally “alive” while operationally forgotten.
  2. [P0] Deal records are too thin for quote-driven work

    • No deal-contact roles
    • No line items / products
    • No structured quote composition
  3. [P1] No forecast categories

    • Weighted pipeline exists, but managerial forecasting semantics are still primitive.
  4. [P1] Saved views are missing

    • Multi-view exists, but repeatable operational filtering is not yet first-class.
  5. [P1] Custom field UX lags backend support

    • Validation exists; true business usability is weaker.
  6. [P2] Analytics are useful but not yet managerial-complete

    • No conversion analytics
    • No stage-duration rollups
    • No owner performance matrix
  7. [P2] Mixed-currency revenue truth is still thin

    • Better than before, but totals and forecasting semantics remain under-specified for heterogeneous currencies.
  8. [P2] Permission semantics are still too implicit

    • The CRM should make ownership and action authority legible, not merely enforce them in scattered ways.
  9. [P3] No saved operational views yet

    • Not fatal at current scale, but a real gap once teams use the CRM every day.

Sequenced rework roadmap (to reach benchmark-grade)

The gaps above are not a flat backlog. They have a dependency order. Build in this sequence so each phase makes the next one cheaper, and so the CRM is always honestly better at the end of a phase than at its start.

Phase A — Next-step discipline (unblocks everything operational)

  1. ✅ (backend, 2026-06-16) CRMActivity model + /api/crm-activities/ CRUD (see §4.3, §5.4). Model with derived is_overdue, at-least-one-of (deal|company|contact) serializer validation, cross-tenant rejection on every FK, query filters (deal/company/contact/owner/status/activity_type/overdue/upcoming), complete action, owner-defaults-to-requester. DealSerializer exposes annotated next_activity_at / has_overdue_activity / has_open_activity (N+1-safe). Migration 0008_crmactivity. Tests: backend/tests/test_crm_activities.py.
  2. ✅ (web, 2026-06-16) “Next activity” surfaced on deal card, table row, and detail panel. NextActivityCard (detail panel) renders the soonest scheduled activity with type icon/subject/due/owner, DESIGN.md-aligned overdue treatment (amber rail + shared overdue badge), Complete + Reschedule actions, an inline schedule form (subject · type · due_at · owner), and a list of further scheduled + done activities; NextActivityIndicator shows a compact due-date / overdue chip on kanban cards and the table row. Hooks: useCrmActivities family (src/hooks/queries/useCrmActivities.ts) — mutations invalidate both crm-activities and deals lists/details so the annotated Deal fields refresh. Tests: frontend/src/test/CrmActivities.test.tsx.
  3. ✅ (web, 2026-06-16) “No next activity” warning state on every open deal (§2.3, §6.6). NextActivityIndicator reads has_open_activity and shows an amber shared warning badge on cards/rows; the detail panel shows an amber-railed warning + Schedule CTA. (iOS/Android shells: open.)
  4. ✅ (web deal-health, 2026-06-16; visual classification refined 2026-06-28; Brief glue shipped 2026-07-02) Overdue-activity feeds deal health and the morning Brief (F12 glue — see sub-item below). getDealHealth() (DealsPage.tsx) now folds the annotated activity signals into the existing health priority: has_overdue_activity → stale (amber warning — a missed scheduled next step is a defect, but not a destructive danger state), and an open deal with has_open_activity === false → the no_activity health state. DealHealthBadge (frontend/src/pages/crm/DealHealthBadge.tsx) gained hasOverdueActivity / hasOpenActivity props and renders both “Overdue activity” and “No next activity” through the shared amber warning badge recipe; wired on both the kanban card (DealsPage) and the table row (TableView). i18n: overdue_activity (deals namespace, en + vi + all-locale seed). Tests: frontend/src/test/DealAnalytics.test.tsx (overdue-activity → degraded health; getDealHealth contract mirror).
    • ✅ Daily-Brief glue (F12) — shipped (2026-07-02). Stuck open deals (overdue scheduled activity, no scheduled activity, or overdue unpaid deposit) surface as deal BriefItems in generate_morning_brief (apps/ai/services.py). Deposit-overdue (high) > overdue-activity (high) > no-activity (medium) priority; one item per deal, rule-based confidence 0.7, related_deal FK provenance. Tests: backend/tests/test_brief_deals.py (TestBriefDealDeposit, deal_overdue_activity, deal_no_activity). Cross-link: F12.
    • Exit test: every open deal either shows a scheduled next activity or is visibly flagged, and an overdue activity degrades its health, and stuck deals feed the morning Brief. (✅ on web 2026-06-16; Brief glue ✅ 2026-07-02; native shells open.)

Phase B — Deal structure (makes the record trustworthy) 5. ✅ (backend, 2026-06-16) DealContactRole model + /api/deals/<id>/contacts/ (§4.3, §5.4). Org + deal FK CASCADE, contact FK CASCADE, UniqueConstraint(deal, contact, role); serializer rejects cross-company contacts and cross-tenant contacts/deals; exposes contact_name + role_display. Migration 0009_dealcontactrole_deallineitem. Tests: backend/tests/test_crm_deal_structure.py. (Web UI shipped 2026-06-16: DealContactRoles in the deal detail panel — role chips, contact picker scoped to the deal’s company, add/remove; hooks in src/hooks/queries/useDealStructure.ts; tests frontend/src/test/DealStructure.test.tsx.) 6. ✅ (backend, 2026-06-16) DealLineItem model + /api/deals/<id>/line-items/ with deterministic rollup to Deal.amount (§2.12). Read-only line_total; recompute_deal_amount helper (apps/crm/views.py) recomputes Deal.amount = Σ line_total (quantized 2 dp) on every line-item create/update/delete, never zeroing a manual amount when no items exist, never via a Deal.save signal (no recursion). Tests: backend/tests/test_crm_deal_structure.py. (Web UI shipped 2026-06-16: DealLineItems editable table in the deal detail panel — add/edit/delete rows, server line_total per row, Σ rollup footer; line-item mutations invalidate the deal detail so the rolled-up amount refreshes. Tests frontend/src/test/DealStructure.test.tsx.) 7. ✅ (web, 2026-06-16) Custom-field editing surfaced in company/deal forms (close the backend/frontend gap, §2.5). CustomFieldsEditor (src/components/crm/CustomFieldsEditor.tsx) fetches /field-definitions/ for the entity type and renders text/number/date/choice inputs honoring required and the locale label; wired into the deal (DealsPage) and company (CompaniesPage) create forms, saved via the normal PATCH custom_fields. Hook useFieldDefinitions. Tests: frontend/src/test/DealStructure.test.tsx.

  • Exit test: a quote-driven deal can be fully represented in Lumi with no side spreadsheet. (✅ on web 2026-06-16: contact roles + line items + custom fields all editable in the deal surface.)

Phase C — Managerial truth (makes the pipeline governable) 8. ✅ (backend, 2026-06-16) Deal.forecast_category (migration 0010_deal_forecast_category) + PATCH /api/deals/<id>/forecast-category/ + GET /api/deals/forecast/ per-currency rollups by category with weighted pipeline (omitted excluded, null probability→0) (§2.7, §5.4). Tests: backend/tests/test_crm_forecast.py. 9. ✅ (backend + web, 2026-06-16) Conversion analytics: GET /api/deals/conversion/ — stage-to-stage progression counts, average stage duration (from stage_changed DealActivity days_in_prev_stage), win-rate by owner + by pipeline; plus GET /api/deals/aging/ — per-stage stale counts (past stage.expected_days) + avg days in stage. Web UI shipped 2026-06-16: DealAnalyticsPanel (frontend/src/pages/crm/DealAnalyticsPanel.tsx) — a calm, dense, expandable section below PipelineStats on the deals board surfacing win-rate by owner + by pipeline, average stage duration + stage-to-stage progression, and per-stage stale counts + avg days-in-stage; resolves owner/pipeline/stage ids to names client-side, shows the conversion response’s basis note (honest approximation), and defines loading/empty/error states. Hooks useDealConversion/useDealAging (already shipped). i18n: analytics_* keys (deals namespace, en + vi + all-locale seed). Tests: frontend/src/test/DealAnalytics.test.tsx. 10. ✅ (backend, 2026-06-16) Currency-honest analytics: /api/deals/forecast/ is currency_mode: per_currency (never merges currencies); /api/deals/analytics/ gained by_currency + weighted_by_currency breakdowns alongside the unchanged legacy merged keys (§2.7, §9 row 6). - Exit test: a manager can run a forecast review from Lumi without a spreadsheet. (Backend shipped 2026-06-16. Web shipped 2026-06-16: forecast view + per-currency-honest analytics + forecast-category control — ForecastView.tsx, ForecastCategoryControl.tsx, extended PipelineStats.tsx; hooks useDealForecast/useDealConversion/useDealAging/useSetForecastCategory in src/hooks/queries/useDeals.ts; tests frontend/src/test/DealForecast.test.tsx. Conversion/aging UI surfaces (win-rate, stage-aging panels) shipped 2026-06-16 as DealAnalyticsPanel (see item 9). Native shells: open.)

Phase D — Operator ergonomics (makes daily use fast) 11. ✅ (backend + web, 2026-06-16) Saved views (/api/deal-views/) — named, filterable, shareable (§2.1, §6.3). SavedDealView model (migration 0011_saveddealview) + /api/deal-views/ CRUD (IsOrgMember). Tenant- and visibility-scoped: a member sees their own views plus is_shared=True views read-only; create stamps owner+org; update/delete owner-only (non-owner editing a shared view → 403 with detail). Web: SavedViewsBar (frontend/src/pages/crm/SavedViewsBar.tsx) on the deals board — built-in client-side presets + user saved views (apply filters/sort/view_mode, save-current + share toggle, owner-only edit/delete, shared read-only, 403 surfaced); hooks useDealViews/useCreateDealView/useUpdateDealView/useDeleteDealView. Tests frontend/src/test/SavedViews.test.tsx. 12. ✅ (backend + web, 2026-06-16) Wider bulk-edit vocabulary (§2.8). /api/deals/bulk/ gained update_close_date, update_pipeline (org-validated; keeps a stage compatible with the destination pipeline else lands on its first stage), update_forecast_category, update_custom_field (validated against the org’s deal FieldDefinitions). Each new action keeps the per-deal result contract + DealActivity audit, validates params (400), and is tenant-scoped (404 for cross-org deals/pipeline). Web: DealsBulkBar (frontend/src/pages/crm/DealsBulkBar.tsx) — multi-select toolbar with per-action input; partial-failure errors[] rendered inline by {deal_id, error}. Tests frontend/src/test/DealsBulkBar.test.tsx. 13. ✅ (backend + web, 2026-06-16) Explicit, legible permission semantics for stage config and out-of-scope edits (§2.11). StageViewSet/PipelineViewSet reads stay open to members; structural writes require admin/owner via IsPipelineStructureAdmin, returning 403 with detail “Only an admin can change pipeline structure.” Bulk edits remain tenant-scoped (explicit cross-tenant tests). Web: CrmSettingsPage reads the org role; non-admins see an info notice + disabled structural controls (tooltip), and a residual 403 surfaces the server detail in a toast. Tests frontend/src/test/CrmSettingsPermission.test.tsx. - Exit test: a daily CRM user has repeatable one-tap operational views and safe bulk edits. (Backend + web shipped 2026-06-16: saved-views bar, wider bulk-edit toolbar, and permission-legible stage config are all live.)

Each phase ships with backend tests (≥99% coverage), an E2E block, registry/diagram updates, and a spec audit refresh in the same PR — no phase is “done” until §11 here reflects it.

Benchmark parity scorecard

Honest self-assessment of where F50 stands against a Pipedrive-class operating CRM. Scores are 0 (absent) to 5 (benchmark-grade). This is a judgment call, not a measured metric — it exists to stop the spec from quietly drifting back to “all green.”

DimensionPipedrive-class barLumi nowTargetGap
Pipeline views555kanban/table/calendar/timeline + saved views shipped on web; native shells pending
Pipeline mechanics545stage policy, cross-pipeline rules
Next-step activities555backend CRMActivity engine + web next-activity card/indicator/“no next activity” warning + overdue→deal-health glue + activity-agenda/calendar mode + daily-Brief (F12) glue all shipped; native shells remain
Deal history / audit534broaden field-level coverage + provenance
Deal-contact roles534DealContactRole backend + web role chips/add/remove shipped (2026-06-16); shared Android/iOS Deal Detail now lists localized roles and supports company-scoped add plus confirmed exact remove, with Android emulator mechanics proved 2026-08-09; iOS/named-device/store proof remains open
Products / line items544DealLineItem backend + web editable quote table with Σ rollup (2026-06-16), reusable ProductCatalogItem catalog + line-item prefill shipped on web (2026-06-17), and quote value-mix breakdown shipped (2026-06-18). Shared Android/iOS Deal Detail now renders the server-authored total and line items read-only; Android emulator mechanics passed on exact APK a52cb2fd…162ae (2026-08-12). Native editing/catalog/print/send plus iOS/named-device/store proof remain pending.
Forecasting524forecast categories + rollups (Phase C)
Conversion analytics544backend /deals/conversion/ + /deals/aging/ and web DealAnalyticsPanel (win-rate by owner/pipeline, stage duration + progression, per-stage aging, honest basis note) shipped (2026-06-16, Phase C); native shells pending
Saved views555SavedDealView backend + /api/deal-views/ CRUD and web SavedViewsBar (built-in presets + user views, owner-only edit, shared read-only) shipped (2026-06-16, Phase D)
Bulk operations555wider edit vocabulary (close date / pipeline / forecast category / custom field) backend + web DealsBulkBar (per-action input, inline partial-failure errors) shipped (2026-06-16, Phase D)
Account timeline555explicit CRM-tracked email follow-ups now render as first-class timeline events; general mailbox sync remains out of scope
Permissions legibility555stage/pipeline structural writes admin-only with legible 403 detail; web CrmSettingsPage notice + disabled controls + 403 toast shipped (2026-06-16, Phase D)

Weighted read: the core (views, mechanics, history, timeline) is already strong; the remaining deficit is concentrated in activities, deal structure, forecasting, and native-shell parity. That is why §1 frames the headline gap as next-step discipline plus record richness, not “more dashboards.”

Roadmap to Undeniable — the next 11 (2026-06-16)

Phases A–D shipped the benchmark roadmap (activities, deal structure, forecasting, operator ergonomics) plus the Company-360 People tab. The list below is the next tier — ranked by leverage toward the wedge (the owner cannot run their day without it), grounded in the current code, not feature-count. Each rides the rails (spec → TDD → ≥99% backend / 0 TS errors → registry/diagram/changelog in the same PR). Status legend: 🔲 not started · 🚧 in progress · ✅ done.

#ItemWhy undeniableCurrent stateThe moveCross-link
U1Custom-field configuration UI in CRM SettingsCustom fields aren’t a real user power until a business user can define them without the API✅ (web, 2026-06-16) CrmSettingsPage now has a Custom Fields section: lists FieldDefinitions filterable by entity_type, an add-field form (entity_type · slug-ified key · en/vi label · type text/number/date/choice · required switch · chip-based choices editor for choice), inline edit, and delete-with-confirm. Admin-gated via the page’s canEditStructure (non-admins see read-only disabled controls + the existing admin-only notice; residual 403 detail surfaced via the page toast). Hooks useCreate/Update/DeleteFieldDefinition invalidate the fieldDefinitions query. Tests: frontend/src/test/CrmCustomFields.test.tsx.§2.5, §6.1, §6.3(4)
U2Pipeline in the morning Brief (overdue / no-next-activity deals)The thesis is “the CEO runs the day from the Brief”; today the briefs app has zero CRM references✅ (backend, 2026-06-16) BriefItem.related_deal FK + ("deal","Deal") type added (migration briefs.0006). generate_morning_brief section 8 queries open deals annotated Exists(scheduled activity) / Exists(overdue scheduled), surfaces those with an overdue activity (priority=high) or no scheduled next activity (priority=medium), overdue-first, tenant-scoped, select_related company/owner/stage, capped by remaining_slots(). Rule-based confidence_score=0.7; related_deal FK is provenance (no fabricated EvidenceSource — consistent with commitment/decision items). Suggestions deal_overdue_activity/deal_no_activity (en/vi/th). Serializer exposes related_deal + related_deal_title; brief prefetch includes related_deal__company. Tests: tests/test_brief_deals.py (14). ✅ (web, 2026-06-16) BriefPage renders deal items in a new Pipeline section tier (below operations, so deals never outrank critical risks); title uses related_deal_title and links to /deals (the board — no per-deal route); TrendingUp icon matching the CRM nav; reuses the shared item card (priority border, ceo_action_suggestion). BriefItem type gained related_deal/related_deal_title. i18n brief.section_pipeline/view_deal (en+vi + 60-locale seed, parity green). Test: BriefPage deal-item render + /deals link.F12 §4/§5, §2.3, §4.3(1)
U3Excel/CSV import of companies + contacts + dealsThe competitor is Excel; an owner won’t re-type 200 customers✅ (full, web wired, 2026-06-16) POST /api/companies|contacts|deals/import/ (IsOrgMember), body {rows, dry_run?}, ≤1000 rows → 400. Per-row validate+create with the shared contract {dry_run, created, skipped, errors, results:[{row,status,id,detail}]}; rows independent (one failure never aborts the rest); dry_run rolls back a savepoint (writes nothing). Companies dedup by name_normalized (existing → skipped/reuse id; else create, default is_customer=true); contacts/deals resolve company_name within the org (unknown → row error, never auto-created); deals resolve pipeline (id-or-name, else first active) + stage (name, else first by order). All resolution tenant-scoped (Rule 1, explicit cross-tenant tests). Web: CrmImportPage (/crm/import, sidebar Business → Import) 4-step wizard — entity pick → paste TSV/upload CSV (parsed by src/lib/parseTable.ts, dependency-free, tab/comma auto-detect, quoted/escaped/multiline + CRLF/BOM) → column-map (auto-match + required guards) → dry-run preview (counts + per-row table) → import. useCrmImport(entity) mutation; >1000 rows blocked. Native .xlsx parse deferred (paste-from-Excel covers it). (Native iOS/Android shells: open.)Guided upload → column-map → bulk create (companies/contacts/deals), dry-run + per-row errors§2.10, F70
U4Báo giá (quote document) rendered from line items + F71 confirmThe quote is the contractor’s real artifact; confirmation drives Loop-2 acquisition✅ (full, 2026-06-16) Backend: GET /api/deals/<id>/quote/ → build_deal_quote (apps/crm/quote.py): structured báo giá (line items + Decimal-exact subtotal/discount/tax/total in the deal currency + stored amount; empty items → zero totals + amount). build_deal_quote_summary(deal) produces the F71 ConfirmationRequest.shared_summary snapshot. On confirm/decline of a deal confirmation, apps/confirmations/views.py logs a DealActivity (origin="confirmation_link") on the deal — Loop-2 provenance, guarded to deal subjects. Tests: tests/test_crm_quote.py, tests/test_confirmations.py::TestDealConfirmationProvenance. Web (2026-06-16): DealQuoteSection.tsx on the deal panel — “View quote” opens a document-like dialog (company + title, line-items table, subtotal/discount/tax/total in the deal currency), a Print button (scoped @media print layout), and a “Send for confirmation” button reusing the F71 SendConfirmationDialog (subjectType="deal", company pre-filled, role client) that reveals the copyable public_url. dealsApi.quote + useDealQuote. Test: src/test/F50Quote.test.tsx.Render a quote from DealLineItem → no-login confirm link → result lands on the deal§2.12, F71
U5Deposits & handover milestones (đặt cọc → thi công → nghiệm thu)Fit-out cash flow runs on deposits/milestones, not one close date✅ (full, web wired, 2026-06-16) Deal gains first-class deposit_amount · deposit_due_date · deposit_paid · kickoff_date (thi công) · handover_date (nghiệm thu), auto-exposed writable by DealSerializer (__all__); derived read-only is_deposit_overdue (open ∧ ¬paid ∧ due_date < today). Brief glue: the U2 stuck-deal source in generate_morning_brief now also surfaces a deal with an overdue unpaid deposit as a high-priority deal BriefItem (confidence 0.7, related_deal provenance) carrying the new deal_deposit_overdue suggestion (vi/en/th). No-duplicate rule: each stuck deal yields exactly ONE item; the single most urgent suggestion wins — deposit-overdue (high) > overdue-activity (high) > no-activity (medium) — so a deal that is both deposit-overdue and activity-overdue surfaces once with the deposit suggestion, and deposit-overdue deals rank first. Migration 0013. Web UI: DealDeposit (pages/crm/DealDeposit.tsx) renders a compact “Deposit & milestones” section on DealDetailPanel — deposit amount (deal currency), due date, paid checkbox, kickoff + handover dates, ”—”/“Set” affordances for empties, view→edit→save via useUpdateDeal (PATCH only the deposit/milestone fields; cleared amount → null). Overdue treatment: when is_deposit_overdue ∧ ¬paid, the due date renders in --color-risk-red with an “Overdue” chip; the kanban card (DealsPage) shows a calm red “Deposit overdue” chip in its chip row for the same condition. Tests: TestDealDepositFields (test_crm.py), TestDealDepositBriefItems (test_brief_deals.py), DealDeposit.test.tsx (frontend — field render, em-dash empties, overdue treatment shown/hidden by paid, edit+save calls dealsApi.update with changed fields, cleared amount → null). i18n: deals namespace, en+vi authored + all 60 other locales seeded. Native iOS/Android shells: open.First-class milestone dates + deposit amount on the deal; feed health + Brief§2.5
U6Native mobile parity for the core act-flowThe owner is on-site on a phone; F50 surfaces are web-only🚧 shared core, partial runtime proof. Android/iOS share the Deals board and Deal Detail act-flow, including next activity and localized stakeholder roles. The shared board exposes explicit All/active-pipeline scope with stale-response protection; a failed refresh preserves the last grouped board behind a compact explicit Retry. Deal Detail independently loads role assignments, opens an existing stakeholder’s typed Contact Detail route, offers company-scoped contact selection plus six localized roles, preserves the add draft on failure, and confirms exact-role removal with exact retry custody. Lifecycle-owned Android emulator runs proved Deals failure/recovery plus stakeholder add/remove and server convergence on exact installed artifacts; iOS stakeholder runtime, named-device, accessibility, signed-store, provider, and production proof remain open.iOS + Android: pipeline scope → deal detail → stakeholder contact → next activity + stakeholder add/removeF30, F47
U7Stage policy / required-field enforcement on stage moveManager trusts pipeline state without calling around🚧 backend + web + shared native source. Stage.required_fields (JSON list) gates stage ENTRY through both serializer and move paths; admin-only stage configuration and web blocked-move recovery remain shipped. Shared Android/iOS Deal Detail now wires a localized capability-gated stage action to the exact tenant-scoped move route, publishes returned deal/stage truth, keeps prior truth on required-field rejection, and hides mutation from viewers. F50.AC013 covers API/model/source seams; F50.GAP013 keeps Android/iOS runtime, named-device, accessibility, store, deployed, and production proof open.Per-stage required fields (e.g. deposit before “đặt cọc”, quote before Won)§2.2
U8One-tap unresolved-name → company + dealTurns AI check-ins into CRM growth — no spreadsheet can✅ (full, web wired, 2026-06-16) POST /api/companies/resolve/ creates/reuses the Company by normalized name AND backfills the dangling Risk/Commitment/Task company FKs (atomic, tenant-scoped) so the name drops out of /unresolved/; optional create_deal opens a Deal on the first stage of the given/default pipeline (skipped, never 500, if no pipeline). Web: CompaniesPage UnresolvedStrip chips call companiesApi.resolve/useResolveUnresolved — a primary Create (create_deal:false) toasting “Created {name} · linked {n} records” from the summed backfilled counts, and a small + deal affordance (create_deal:true) toasting “Created {name} + deal”; on success the chip drops and the companies list + unresolved query invalidate. (Native iOS/Android shells: open.)One-tap convert an unresolved string into Company + open Deal§2.10, F08
U9Activity agenda / “my day” inboxThe operator’s cockpit: every overdue + upcoming action in one place🚧 (web + shared Android/iOS source; Android emulator mechanics proved 2026-08-09) Web ActivityAgendaPage remains shipped at /crm/agenda (sidebar “Agenda”): Overdue section first with amber warning treatment, then Upcoming grouped Today/Tomorrow/Later, across all deals; My/All owner toggle defaulting to the signed-in member; each row reuses the Phase A activity vocabulary (type icon · due · owner) with Complete + inline Reschedule; loading/error/empty states. Shared native KMP mirrors the bounded agenda behavior, uses compact accessible icon actions, and both shells route /crm/agenda to it. A lifecycle-owned Android emulator run now proves localized Mine/All plus exact complete/reschedule failure recovery and server custody; iOS runtime, named-device, accessibility-service, store, provider, and production proof remain open.A daily agenda across all deals (overdue first), beside the Brief§6.6(3)
U10Server-side saved-view filtersRepeatable management queries that scale + truly share✅ (full, web wired, 2026-06-16) /api/deals/ honors the full saved-view vocabulary server-side — owner, status, forecast_category, health (overdue/no_activity/stale/close_soon), close_window (this_week/this_month/overdue) — all tenant-scoped + ANDed, invalid enum/uuid → no filter; reuses existing annotations + DB-side date math (no N+1). Web: DealsPage builds /deals/ query params from selectedPipelineId + the active SavedDealView.filters (stage/owner/forecast_category/health/close_window) and passes them to useDealsList, so saved views filter server-side; built-in presets my_overdue→health=overdue+owner=me, no_next_activity→health=no_activity, stale_14d→health=stale, closing_this_week→close_window=this_week map to params, with deposit_expected staying client-sidePush filter evaluation to /api/deals/ query params§2.1
U11Collaboration that survives turnover: @mention · note visibility · provenanceThe record outlives the one salesperson who knew the account✅ full (backend + web, 2026-06-16). Comment storage consolidated 2026-06-21: the DealComment model was deleted (migration 0018) and deal comments now live on the universal F81 Comment model in apps/comments (target_type="deal"); visibility/private read rules were preserved, and comment origin is now the narrower user|system|ai (see §4.3(6)/§5.1).@mention → mention NotificationEvent on comment create; comment visibility (shared/private) with manager/author read rules; origin on the comment + DealActivity. Web: DealCommentComposer with @-trigger member autocomplete + shared/private toggle; Private + origin badges in the comment list§2.4, §2.6, §4.3(6), F35, F36, F81

Build order: U1 → U2 first (both backend-ready or near-wiring, highest daily-habit leverage), then U10 (finish Phase D), U9, U7, U8 (sharpen the operating loop), then the larger surfaces U3/U4/U5 (adoption + the contractor artifact, each warrants a Rule-6 nod), and U6/U11 last. Update this table’s status column as each lands.

12. Open Questions / Decisions

  1. Should scheduled CRM activities be built before products/line items?

    • My judgment: yes. Next-step discipline matters more than quote granularity if the goal is operational control.
  2. Should line items live in CRM or in a later quoting/invoice surface?

    • My judgment: lightweight line items belong in CRM if they directly explain Deal.amount. Full quoting docs do not.
  3. Should forecast categories be manual or rule-derived?

    • My judgment: manual first. Rule-derived categories create false certainty too early.
  4. Should contacts become more central inside the deal before email integration exists?

    • Yes. Deal-contact roles are valuable even without an email stack.
  5. How much HubSpot/Pipedrive admin power should Lumi absorb?

    • Only enough that the contractor can configure their process without engineer help. No sprawling admin labyrinth.
  6. Should products/line items land before a lightweight quote document surface exists?

    • My judgment: yes, if line items are treated as deal structure first, document rendering second.
  7. Should scheduled CRM activities and F31 follow-ups stay separate?

    • My judgment: yes. CRM activities are operator-planned; F31 follow-ups are chase requests emitted from Lumi’s management loop.

13. Bug / Change Log

2026-08-23 — Native Deal Detail wires the stage transition verb

Shared Android/iOS Deal Detail now exposes the existing tenant-scoped stage move through one localized 48dp action for users with crm.deal.edit. The current stage is disabled, returned server truth replaces the deal and resolved label, and a U7 required-field rejection preserves the prior deal while showing the server explanation inline. Viewers receive no affordance. Registered API/model/source proofs cover route custody, role denial, localized semantics, and the composition boundary; F50.GAP013 retains every runtime and release evidence class.

2026-08-22 — Native Deals board exposes the health saved-view vocabulary

Type: Shared Android capability (CRM-04 UX)

Summary:

  • The ?health= saved-view vocabulary (F50 U10) existed server-side and on web saved views, but the native Deals board had no way to ask “which deals are neglected?” — a read verb that lived only on web (CRM-G3).
  • The board now renders one compact health chip row (All deals / Overdue activity / No activity / Stale / Closing soon) over the existing pipeline- scope machinery: same stale-response guard, same repository seam, vi+en copy.
  • Runtime hardening found during proof: LazyList item keys must be Bundle- safe strings — keying chips by StringResource object crashed composition on Android (IllegalArgumentException in SaveableStateHolder).

Tests:

  • CrmScreenModelTest.selectHealthReloadsDealsWithTheHealthVocabulary / .refreshKeepsUsingTheSelectedHealthBucket
  • .maestro/critical/109_android_deals_pipeline_scope.yaml health journey (no_activity keeps fixture deals, overdue honestly empties, all restores)
  • scripts/run-android-deals-pipeline-proof.sh

Proof boundary:

  • Guarded Android emulator proof on identical built/installed prodDebug APK; iOS parity, named-device, accessibility-service, store, deployed evidence remain open.

2026-07-14 — Mobile: keep Edit Deal Save above the keyboard (FRM-10)

Type: UX fix (shared KMP form layout)

Summary:

  • Edit Deal now uses the same weighted scroll + in-flow Save CTA pattern as the keyboard-safe task and project edit forms.
  • Added stable screen/action tags and a source guard against bottom-overlay Save regressions.

Scope boundary:

  • Source hardening only. FRM-10 populated edit, validation, and named-device keyboard evidence remain PM-owned mobile-audit work.

Tests:

  • git diff --check
  • PYENV_VERSION=system python3 scripts/check_docs_truth.py
  • bash scripts/check-platform-parity.sh

2026-07-02 — Docs: correct stale file paths (batch 2)

Type: Documentation Fix

Summary:

  • frontend/src/pages/crm/DealCommentComposer.tsx (§13 entry 2026-06-16) — file was deleted 2026-06-21 when deal comments were consolidated into the universal Comment model (F81). §13 entry annotated to reflect deletion; comment behaviour now lives in src/components/comments/CommentThread.
  • frontend/src/test/DealCommentComposer.test.tsx (same §13 entry) — deleted with the component. §13 entry annotated; coverage moved to frontend/src/test/CommentThread.test.tsx.
  • frontend/src/pages/DealsPage.tsx (§13 entry 2026-06-16 health-redesign) — page moved to frontend/src/pages/business/DealsPage.tsx during the IA restructure. Reference corrected.
  • No code changes.

2026-07-02 — Spec drift fix: align deal-comment endpoints + provenance to the shipped Comment consolidation

Type: Spec correction (docs-only, no code change)

  • Deal comments now use the universal F81 Comment model. The DealComment model was deleted in migration 0018_alter_crmactivity_activity_type_delete_dealcomment.py (2026-06-21) and its rows backfilled into apps/comments (migration 0003). §5.1 updated: the nested /api/deals/<id>/comments/ + DELETE /api/deals/<id>/comments/<comment_id>/ rows were replaced with the polymorphic endpoint — GET /api/comments/?target_type=deal&target_id=<id> (list, keyset-paginated), POST /api/comments/ (create), PATCH/DELETE /api/comments/<id>/, and POST /api/comments/<id>/resolve/. Contract verified against apps/comments/urls.py + apps/comments/views.py.
  • Provenance clarified (§4.3(6)). The full user\|system\|import\|confirmation_link vocabulary lives on DealActivity; the universal Comment uses the narrower user\|system\|ai. Investigation confirmed nothing creates a Comment with import/confirmation_link origin — apps/comments/views.py coerces out-of-vocab origins to user; the F71 confirm/decline path (apps/confirmations/views.py) writes a DealActivity with origin="confirmation_link", not a Comment; and the backfill migration collapsed legacy import/confirmation_link comment origins to system. No code gap.
  • §11 U11 audit row annotated with the 2026-06-21 consolidation; stale DealComment model references pointed at the universal Comment.
  • Test-class references corrected. TestDealComment (§7 test plan + §7.2 matrix row 4) and TestDealCommentCollaboration (§13 historical entry) no longer exist — they were removed with DealComment. Deal-activity provenance is now tests/test_crm_extensions.py::TestDealActivityOrigin; comment CRUD/visibility is backend/tests/test_comments.py. Historical entry superseded with a dated note (record preserved).

Type: Spec correction

  • Updated §11 Phase A item 4 to mark the Daily-Brief (F12) glue as ✅ shipped — the implementation in apps/ai/services.py (generate_morning_brief) already surfaces stuck open deals (overdue deposit / overdue activity / no activity) as deal BriefItems with related_deal provenance. Tests in backend/tests/test_brief_deals.py confirm all three conditions. Spec previously said ”🔲 OPEN / deferred” despite code shipping this before the spec was last updated.
  • Updated benchmark parity scorecard: Next-step activities row now 4→5 (Brief glue no longer outstanding on web).
  • Updated §2 AC (overdue-activity AC) to remove “remains open” language.

2026-06-28 — UI polish: CRM activity overdue treatment follows DESIGN.md

Type: UI Polish

  • Updated the web CRM activity agenda and deal-detail next-activity card so overdue activities use Lumi’s warning vocabulary: amber attention rail, shared StatusBadge status="overdue", and no red card wash.
  • Changed row-level Complete actions from accidental primary-button styling to a calm success-outline action; the destructive action stays low-weight until the confirmation dialog.
  • Extended the same correction to the broader CRM activity discipline surfaces: NextActivityIndicator no-next/overdue chips, DealHealthBadge overdue/no-next badges, deal-detail no-next warning panel, and deal-detail Mark Won / Mark Lost terminal actions.
  • Reclassified has_overdue_activity from red critical card health to amber stale health. Very stale stages, deposits, terminal loss, and destructive confirmations still keep red.
  • Added component regression assertions in ActivityAgendaPage.test.tsx, CrmActivities.test.tsx, DealAnalytics.test.tsx, and DealDetailPanel.test.tsx so overdue/no-next states do not regress to red-soft and action buttons do not regain filled status styling.

2026-08-04 — Preserve Business Forecast summary failure truth

Type: Mobile reliability / data truth

  • The shared Business home keeps a failed Forecast summary request distinct from a valid empty forecast, showing the unavailable row and retryable partial-data state instead of a calm blank.
  • A total Business summary outage remains on the existing error path so refresh retains the last successful snapshot. Retry preserves it while loading, and stale partial warnings are not replayed as current request truth. Shared JVM/source proof is present; named Android/iOS runtime evidence remains open.

2026-08-09 — Keep quote quantities and prices readable

Type: Web CRM bug fix

  • The deal-detail line-item table now preserves intrinsic column width and scrolls horizontally inside a narrow panel instead of compressing every numeric editor into unreadable slivers.
  • Quantity inputs retain at least 64 px and unit-price inputs at least 112 px, the smallest token widths covering the three-digit and nine-digit values reproduced on development. Discount and tax editors retain the same compact numeric treatment; all numeric fields use tabular figures.
  • No API, persistence, rollup calculation, tenancy, authorization, or save behavior changed.
  • F50.AC003.LINE_ITEM_VISIBILITY registers the focused component regression. Deployed visual confirmation remains separate from repository proof.

2026-08-03 — UX: Native Deals pipeline scope (U6, Android)

Type: UX / Feature

  • Added an explicit All pipelines / active-pipeline chip row to the shared Android Deals board. The row appears only when more than one active pipeline exists, keeping single-pipeline workspaces calm while making mixed-stage views intentional.
  • Selecting a pipeline updates stage grouping and calls the existing tenant-scoped GET /api/deals/?pipeline=<id> path. Deal responses are cancelled/ignored when they no longer match the selected scope, so a fast chip change cannot paint stale rows over the new board.
  • Added stable business_lane_deals, crm_pipeline_selector, crm_pipeline_all, and crm_pipeline_<id> automation seams, a two-pipeline JVM regression, and the lifecycle-owned .maestro/critical/109_android_deals_pipeline_scope.yaml proof with its isolated runner. Emulator mechanics are evidence only; named-device, signed-store, and production proof remain open.

2026-08-05 — UX: Keep the account visible on native deal cards

Type: Mobile CRM hierarchy refinement (F50 U6)

  • Native deal cards now resolve the existing DealResponse.company id against the already-loaded, tenant-scoped company snapshot and show the company name as one compact secondary line beneath the deal title. Unknown or stale ids are omitted rather than rendered as UUIDs.
  • Titles and company names each stay to one ellipsized line, preserving amount, status, and next-activity signals without turning the card into a dense form. No API, query, or navigation path changed.
  • DealsScreenCompanyContextSourceTest.dealCardsResolveCompanyNameAndKeepItCompact and the lifecycle-owned pipeline flow’s company assertion cover the new hierarchy. Named-device, iOS, browser, and production CRM evidence remain open.

2026-08-06 — UX: Keep native deal dates readable at a glance

Type: Mobile CRM hierarchy refinement (F50 U6)

  • Native Deals cards now reuse the shared humanized due-date copy for nextActivityAt, including the device-local time when the API value is a valid instant. Expected close dates use compact dd/MM/yyyy metadata in the card and compact deal dialog instead of exposing ISO transport strings.
  • Invalid date values remain visible for recovery/debugging rather than becoming misleading blank states. No API, query, persistence, tenancy, or authorization behavior changed.
  • LumiDatesTest and DealsScreenDateSourceTest cover the formatter and presentation boundary; F50 proof F50.AC005.DATE_COPY records the contract. Rendered geometry, named-device, signed store, and production evidence remain open until a controlled mobile runtime is available.

2026-08-10 — Native Deals refresh recovery preserves pipeline truth

Type: Mobile CRM reliability and hierarchy refinement (F50 U6)

  • When a refresh fails after the Deals board has loaded, the last correctly grouped pipeline and deal cards remain visible behind one compact warning row with an explicit localized Retry. A successful retry removes only the warning; it does not clear or re-scope the board.
  • Repeated model failures preserve the same grouped deal IDs. The Android proof owns two pipelines, two deals, a bounded two-request GET /api/deals/ outage, exact built/installed APK custody, proxy trace, disposable fixture cleanup, retained failure/recovery frames, and emulator shutdown.
  • On 2026-08-10 the guarded Pixel 7 API 35 lifecycle passed with matching prodDebug APK SHA-256 c92883e83f4d36ca0ba1afced35538f843e4c8f2b980e0edd2f48e7d7949f520: both pipeline groups stayed visible through the outage, Retry reached the forwarded recovery request, and cleanup was 0:0:0:0:0. This is Android emulator mechanics only; named-device, screen-reader-service, signed-store, deployed, and production evidence remain open.

2026-08-10 — Native Deal Detail recovery preserves the operating record

Type: Mobile CRM reliability refinement (F50 U6)

  • A populated Deal Detail failure now keeps the last usable deal, activity history, next activity, stakeholders, and comments visible behind one compact localized warning with explicit Retry. Repeated retry and activity logging reconcile against the same current-or-stale snapshot; an initial failure with no deal remains a blocking error.
  • Activity unavailability no longer masquerades as a confirmed “no next activity” result. The shared surface reports the unavailable section while retaining any previously loaded activity context, and it never exposes raw transport copy.
  • Guarded Android flow 163 passed on a Pixel 7 API 35 emulator with matching built/installed prodDebug APK SHA-256 18cd48a5f57fa02814d4b7a0c22f711fa90ecf8b7f4e417e7c231b1483200737. Two bounded deal-detail 503s and two bounded activity-list 503s each preserved the titled record and scheduled next activity; explicit Retry reached forwarded 200 responses, fixture cleanup reached 0:0:0:0, proof ports closed, and the sole emulator shut down. Retained failure/recovery frames and the request trace live under docs/tests/evidence/CRM-05/. This is Android emulator mechanics only; iOS runtime, named-device, screen-reader-service, store, deployed, and production proof remain open.

2026-06-25 — Feature: Native Android parity for the core CRM act-flow (U6, Android)

Type: Feature

  • §11 U6 (→ still 🚧 until verified-on-device; Android half now implemented, iOS already shipped): the on-site owner can run the core deal act-flow natively on Android via the shared Compose Multiplatform module — browse deals grouped by pipeline stage with a per-deal next-activity indicator, open a deal, see its next-activity card, and mark it done. Mirrors the iOS DealsViewModel/DealDetailScreen behavior exactly.
  • DTOs (android/shared/.../api/LumiDtos.kt): extended DealResponse with the three U6 DealSerializer SerializerMethodFields — next_activity_at (ISO string, nullable), has_overdue_activity, has_open_activity (bool) — plus computed isOpen/needsAttention mirroring iOS. New StageResponse (id/name/order/probability/expected_days), PipelineResponse (id/name/is_active/nested stages), and CRMActivityResponse (activity_type/subject/notes/due_at/completed_at/owner/owner_name/status/is_overdue). All optional/defaulted (additive — existing contract fixtures still deserialize). Field names verified 1:1 against backend/apps/crm/serializers.py (DealSerializer, StageSerializer, PipelineSerializer, CRMActivitySerializer).
  • API client (LumiApiClient.kt): deals(pipeline) gained the ?pipeline= filter; new pipelines() → /api/pipelines/, dealActivities(dealId) → /api/crm-activities/?deal=<id>, completeActivity(id) → POST /api/crm-activities/<id>/complete/. All reuse the existing authHeader()/orgHeader() stack.
  • Pure logic (presentation/crm/DealsGrouping.kt): stage-ordered grouping (empty stages dropped, unknown-stage deals trail under “Other”, no-stages fallback groups by stage string), next-activity selection (soonest scheduled, nil-due last, none → null), and detail ordering. Kept Compose/coroutine-free for commonTest.
  • Screen-model trio: extended CrmScreenModel/CrmRepository (new pipelines/deal/dealActivities/completeActivity methods; dealGroups derived state; best-effort loadStages), and added DealDetailScreenModel following the F49 TaskDetailScreenModel pattern (independent deal/activities load, nextActivity, completeActivity → refresh).
  • Screens (shared/.../ui/crm/): rewrote DealsScreen to group by stage with a NextActivityIndicator (red Danger “Overdue” badge / amber Warning “No next activity” badge / else next due or close date) and a per-deal tap; added DealDetailScreen (status/amount/stage header + “Next activity” card with Mark done + the remaining activities). Lumi primitives + tokens only (no raw colors).
  • Navigation (android/app/.../ui/LumiApp.kt, SharedKmpViewModel.kt, new DealsNavKey/DealDetailNavKey): “Deals” in the Business + Workspace lanes now routes to a dedicated DealsScreen (Companies stays on CrmNavKey); a deal tap → DealDetailNavKey; the search deep-link deal route opens detail when an id is present.
  • i18n: 10 new keys authored in en + vi (crm_deals_stage_other, crm_deals_next_activity, crm_deals_activity_overdue, crm_deals_activity_none, crm_deal_next_activity, crm_deal_no_activity_message, crm_deal_activities, crm_deal_activity_due, crm_deal_activity_owner, crm_deal_mark_done). No hardcoded strings; en/vi parity verified (10/10).
  • Tests: android/shared/.../commonTest/.../crm/CrmDealActFlowTest.kt mirrors CrmDealTests.swift — needsAttention classification (overdue-wins / open-no-activity / calm / won / lost), stage grouping (order / empty-drop / Other leftovers / no-stage fallback), next-activity selection (soonest / nil-due-last / done-cancelled-ignored / empty / detail ordering), and the DealDetailScreenModel load + mark-done flow. Existing CrmScreenModelTest fake updated to the widened repository contract.
  • Build verification — BLOCKED (environment). The Android Gradle build cannot run here: AGP artifacts from dl.google.com are denied by the egress policy, so :shared:jvmTest / assembleProdDebug could not be invoked. Substituted rigorous static verification: (a) every DTO field name checked 1:1 against the backend serializers; (b) every Lumi primitive (LumiCard/LumiBadge+LumiTone.Danger/Warning, LumiSectionHeader, LumiPrimaryButton, LumiEmptyState, LumiRefreshable, LumiSkeletonList, LumiPageTitle) and every Res.string.* key confirmed to exist in the shared module (en+vi); (c) the screen-model trio mirrors the shipped TaskDetailScreenModel/ChaosImportScreenModel lifecycle; (d) the next-activity/overdue/grouping logic matches the iOS unit-test expectations recorded in this spec’s 2026-06-16 U6 entry. Must be compiled + :shared:jvmTest run on a host with AGP access before U6 flips to ✅.

2026-06-17 — Feature/UI: CRM-wide New Deal shortcut

Type: Feature/UI

  • §2.10 keyboard-driven capture is now shipped on web. Cmd/Ctrl+Shift+D opens the deal-create flow from the CRM work surfaces that matter operationally: /deals, /companies, and /contacts.
  • Implemented as one shared hook: frontend/src/hooks/useCrmNewDealShortcut.ts. The handler listens at the document level, routes to /deals?new=1, and preserves active context when the user is already sitting on a company or contact record by carrying company / contact search params forward.
  • Safety rule: the shortcut is ignored while focus sits inside input, textarea, select, or contenteditable, so typing never gets hijacked.
  • Verification: frontend/src/test/CrmNewDealShortcut.test.tsx.

2026-06-16 — Feature: Native iOS parity for the core CRM act-flow (U6, iOS)

Type: Feature

  • §11 U6 (→ 🚧, iOS shipped / Android still open): the on-site owner can now run the core deal act-flow natively on iOS — browse deals, see each deal’s next activity, and mark it done — closing the iOS half of U6. Android remains open.
  • Models (ios/Lumi/Networking/APIClient.swift): DealResponse (id, title, company, amount string-Decimal, currency, stage, status, next_activity_at, has_overdue_activity, has_open_activity; computed needsAttention), PipelineResponse + nested StageResponse, CRMActivityResponse (activity_type, subject, due_at, owner_name, status, is_overdue). Decoded with the shared .convertFromSnakeCase decoder; all optional/null-tolerant (no force-unwraps on network data).
  • APIClient methods: fetchDeals(pipelineId:) → /api/deals/?pipeline=<id>, fetchPipelines() → /api/pipelines/, fetchDealActivities(dealId:) → /api/crm-activities/?deal=<id>, completeActivity(id:) → POST /api/crm-activities/<id>/complete/. All reuse the existing generic get/post helpers (auth + X-Organization-ID handled by APIClient; no ad-hoc URLSession).
  • Screens (ios/Lumi/UI/Workspace/CrmDealScreens.swift): DealListScreen — deals grouped under their pipeline stage (in stage order; empty stages dropped, unknown-stage deals trail under “Other”); each row shows title, amount+currency, and a next-activity indicator (red “Overdue”/“No next activity” via needsAttention, else the next due date). DealDetailScreen — status/amount/stage header, a “Next activity” card (soonest scheduled activity) with a Mark done button that calls completeActivity and refreshes, plus the remaining activities (scheduled first). Both screens use the four Loadable states + pull-to-refresh per mobile-ux-patterns.md. Pure DealsViewModel holds the grouping / next-activity / needs-attention logic.
  • Navigation: new Deals row in the Workspace manifest’s Company group (ios/Lumi/UI/MainTabView.swift) — reachable in 2 taps (Workspace → Deals). RuleOfTwoTests updated: Company group now [hub, messages, teams, customers, deals], total destinations 20 → 21.
  • i18n (ios/Lumi/Resources/Localizable.xcstrings): 9 new keys (Deals, No deals, Other, Next activity, No next activity, Overdue, Mark done, Activities, Activity) with vi translations (en = source key). No hardcoded strings.
  • Tests: ios/UnitTests/Workspace/CrmDealTests.swift — decode contracts for all four models (snake-case + null tolerance), needsAttention classification (overdue-wins / open-no-activity / calm / closed), stage grouping (order, empty-drop, leftovers trailing, no-stage fallback), and next-activity selection (soonest scheduled, nil-due-last, none-scheduled → nil).
  • Build verification — BLOCKED (environment). xcodebuild could not build the iOS app: this host has Xcode 26.5 but no iOS 26.5 simulator runtime installed (only iOS 18.2), and the Lumi scheme reports zero eligible iOS Simulator destinations (“iOS 26.5 is not installed. Please download and install the platform from Xcode > Settings > Components.”). Every destination form (name=iPhone 16, explicit UDID, OS=18.2, generic/platform=iOS Simulator) was rejected for the same reason — this is a pre-existing toolchain gap, not a code defect (it reproduces on the unmodified tree). ** BUILD SUCCEEDED ** could NOT be obtained here; the change must be compiled/tested on a host with a matching iOS simulator runtime before it is considered verified. The code follows the existing model/screen/test patterns exactly.

Type: Feature/UI

  • §2.12 / §11 U4 (→ ✅ full): the web render/print + send surface for the U4 quote, closing the gap the backend U4 entry left open. The data layer (GET /api/deals/<id>/quote/) is now consumed by the deal detail panel.
  • API + hook: dealsApi.quote(id) → GET /deals/<id>/quote/ (typed DealQuote in types/index.ts); useDealQuote(id, enabled) (hooks/queries/useDeals.ts) — lazy, fetched only when the quote dialog opens; queryKeys.deals.quote(id).
  • Quote view: new DealQuoteSection.tsx (frontend/src/pages/crm/) adds a calm “Quote” section to DealDetailPanel (between line items and deposits). “View quote” opens a document-like dialog: header (company + deal title), a line-items table (item + optional description / qty / unit price / discount % / tax % / line total), then a right-aligned subtotal / discount / tax / total block, all formatted in the deal currency via formatCurrency. Empty line items → a gentle “Add line items to build the quote.” hint (the line-items editor is the panel section just above). A Print button calls window.print() after setting html[data-quote-printing]; a scoped @media print block in index.css (design tokens only, no stock palette) hides everything except the [data-quote-print] region so only the quote sheet prints; [data-quote-no-print] hides the dialog chrome/actions on print.
  • Send for confirmation: the dialog’s “Send for confirmation” button reuses the existing F71 SendConfirmationDialog (no F71 surface rebuilt) with subjectType="deal", subjectId={deal.id}, role client, and the company name pre-filled as the external-party label (new optional defaultPartyLabel prop, backward-compatible). On create it reveals the copyable public_url. POSTs via the existing confirmationsApi.create (subject_type:"deal", accepted by the backend confirmations serializer).
  • i18n: 12 new deals-namespace keys (quote_section, quote_view, quote_title, quote_empty, quote_subtotal, quote_discount, quote_tax, quote_total, quote_print, quote_send_confirmation, quote_copy_link) authored in en + vi (Báo giá, Tạm tính, Chiết khấu, Thuế, Tổng cộng, In, Gửi xác nhận, Sao chép liên kết, “Thêm hạng mục để tạo báo giá”) and seeded into all 60 other locales with the English value. src/test/i18n.test.ts deals parity stays green.
  • Tests: frontend/src/test/F50Quote.test.tsx — quote dialog renders line items + the total from a mocked dealsApi.quote response; empty line items → the hint; “Send for confirmation” calls confirmationsApi.create with {subject_type:"deal", subject_id, party_label:"Khách A", party_role:"client"} and reveals the public URL. Mocks ../api/endpoints + ../stores/authStore (no MSW).
  • Gates: tsc --noEmit clean; F50Quote.test.tsx + F71Confirmation.test.tsx + i18n.test.ts all green (8338 tests pass, incl. deals parity across all 56 languages); check-design-tokens.mjs clean (only the 5 allowlisted LanguageSwitcher hits).
  • Deferred: the confirmation currently shares the deal title (via the F71 dialog’s existing shared-fields flow), not the full quote line-item summary; passing build_deal_quote_summary as shared_fields on create is a small follow-up. Value-mix breakdown (labor/material/subcontractor) remains out of U4 scope; the reusable product catalog (§2.12) shipped on web 2026-06-17.

2026-06-16 — Feature: Báo giá (quote) endpoint + F71 confirmation glue (U4, backend)

Type: Feature

  • §2.12 / §11 U4 (→ 🚧): the báo giá data layer + F71 confirmation glue for U4. Web quote render/print UI remains open.
  • Quote endpoint: GET /api/deals/<id>/quote/ — new @action(detail=True) on DealViewSet (apps/crm/views.py, IsOrgMember, tenant-scoped via get_object). Returns the structured quote {deal, title, company:{id,name}, currency, line_items:[…line_total per row], subtotal (Σ qty·unit_price), discount_total, tax_total, total (Σ line_total), amount}. Built by build_deal_quote in the new apps/crm/quote.py. All money Decimal-exact, quantized 2 dp (HALF_UP), in the deal’s currency; per-line breakdown mirrors DealLineItem.line_total so totals reconcile exactly. Empty line items → all totals "0.00" + the deal’s stored amount.
  • Confirmation glue (summary): build_deal_quote_summary(deal) (apps/crm/quote.py) returns the ConfirmationRequest.shared_summary snapshot — {subject_type:"deal", kind:"quote", title, company (name), currency, total, line_items}. Chosen path (lower-risk): the summary is returned from the quote endpoint (via the same quote builder) for the frontend to pass as shared_summary on F71 confirmation create. The F71 create serializer was not modified — it always builds a non-empty shared_summary from shared_fields (default ["title"]), so the “auto-populate when empty” branch would never fire through the API and would risk the existing F71 tests; keeping the change in crm + frontend-passed is additive and leaves F71 untouched.
  • Provenance hook (confirm): apps/confirmations/views.py _apply_response — when a confirmed/declined ConfirmationRequest has a deal subject, log a DealActivity (action="note_added", origin="confirmation_link", actor=None, metadata {decision, external_party, external_party_role, note}) on that deal. Guarded to deal subjects only (no row for commitment/task/approval subjects); tenant-scoped via the confirmation’s organization_id. Ties U11 provenance to the Loop-2 confirmation.
  • Tests: backend/tests/test_crm_quote.py (quote totals with discount+tax, empty→zero+amount, sort order, cross-tenant 404, summary shape) + backend/tests/test_confirmations.py::TestDealConfirmationProvenance (confirm/decline deal → one DealActivity origin="confirmation_link"; non-deal subject → none). Suite -k "crm or confirmation": 474 passed. apps/crm/quote.py + apps/confirmations/views.py 100% covered; ruff E,F,W clean.

2026-06-16 — Feature/UI: CRM import wizard — paste/upload → map → dry-run → import (U3, web)

Type: Feature/UI

  • §11 U3 (§2.10): wires the shipped U3 backend into a guided UI, completing U3 to ✅ full (web). New CrmImportPage (frontend/src/pages/crm/CrmImportPage.tsx) at route /crm/import, linked from the sidebar Business group (“Import”, lucide FileUp, new sidebar.crm_import key).
  • Parser: frontend/src/lib/parseTable.ts — a small, dependency-free CSV/TSV parser (no xlsx/papaparse installed). Auto-detects tab-vs-comma from the first line, honours RFC-4180 quoting (quoted fields, "" escapes, embedded commas/tabs/newlines), CRLF/LF endings, and a leading UTF-8 BOM; first non-empty row = headers; empty rows skipped; missing trailing cells filled blank.
  • Wizard: 4 steps — (1) pick entity (company/contact/deal) + paste TSV from Excel or upload a .csv; (2) column mapping (one shadcn Select per target field, case-insensitive auto-match, required-field guards); (3) dry-run preview (useCrmImport(entity) → POST dry_run:true) showing created/skipped/error counts + a per-row results table; (4) real import (dry_run:false) with a final summary + “Import another”. >1000 rows blocks import with a clear message. Loading/empty/error states throughout.
  • API/hook: companiesApi.import / contactsApi.import / dealsApi.import (POST /{entity}/import/) added to frontend/src/api/endpoints.ts; CrmImportRequest/CrmImportResponse/CrmImportEntity types in frontend/src/types/index.ts; useCrmImport(entity) mutation in frontend/src/hooks/queries/useCrmImport.ts (invalidates the entity’s lists after a real import).
  • Deferred: native .xlsx parsing (no dependency available offline) — paste-from-Excel yields TSV and covers the migration path.
  • i18n: 61 new companies keys (import_*) + 1 new sidebar key (crm_import) added to en + vi and seeded across all 56 shipped locales (parity test green).
  • Tests: frontend/src/test/parseTable.test.ts (16 cases — quoted/escaped/multiline fields, tab vs comma, CRLF, BOM, empty/missing cells, override) and frontend/src/test/CrmImportPage.test.tsx (3 cases — paste → map → dry-run preview shows counts then import calls the API with dry_run:false; row errors surfaced; empty paste toasts an error).
  • Diagrams: frontend/DIAGRAMS.md — added /crm/import to the route tree (both diagrams).

2026-06-16 — Feature: Bulk CRM import — companies, contacts, deals (U3, backend)

Type: Feature

  • §11 U3 (§2.10): an owner migrating from a spreadsheet can bulk-create companies/contacts/deals. The frontend parses CSV/Excel client-side and POSTs JSON rows; this PR builds the validate+create endpoints with dry-run + per-row results. Moves U3 🔲 → 🚧 (backend shipped; frontend parse/column-map is the remaining follow-up).
  • Three @action(detail=False, methods=["post"], url_path="import") actions (IsOrgMember), one per viewset: POST /api/companies/import/, /api/contacts/import/, /api/deals/import/. Body {rows:[...], dry_run?:bool}, capped at 1000 rows (else 400).
  • Shared contract (_import_response): always 200 with {dry_run, created, skipped, errors:[{row,error}], results:[{row, status:"created"|"skipped"|"error", id:"<uuid|null>", detail}]}. Rows processed in order, each independent — a per-row error never aborts the rest. dry_run opens a savepoint and rolls it back, so nothing is written and every created row reports id:null.
  • Companies: dedup by name_normalized within the org → an existing match is skipped (reuses its id), else created via the existing CompanySerializer (default is_customer=true when no relationship flag given).
  • Contacts: resolve company_name by normalized name within the org (never auto-created — company import runs first); unknown company → row error "Unknown company '<x>'". Created via ContactSerializer.
  • Deals: resolve company (unknown → error), pipeline by id-or-name else the org’s first active pipeline, stage by name within that pipeline else its first stage by order; no pipeline/stage at all → error. Creates an open deal, stamping stage_changed_at. Invalid amount → row error.
  • Tenant isolation (Rule 1): every lookup and create is scoped to request.organization_id; a company_name/pipeline/stage from another org is never matched. Explicit cross-tenant tests on all three endpoints.
  • Helpers added to apps/crm/views.py: IMPORT_ROW_CAP, _looks_like_uuid, _first_error (flattens DRF serializer errors into one per-row message), _import_response.
  • Tests: backend/tests/test_crm_import.py — 26 tests (TestCompanyImport, TestContactImport, TestDealImport) covering create/dedup/skip, bad-row error continuation, counts, dry-run-writes-nothing, contact/deal company resolution + unknown-company error, pipeline+stage resolve/defaults/fallback, no-pipeline error, row cap → 400, non-dict row / non-list rows, serializer-error surfacing, and cross-tenant isolation. All new lines covered.
  • Diagrams: backend/DIAGRAMS.md — added the bulk-import endpoints + contract under the CRM section.

2026-06-16 — Feature/UI: Deal comment composer — @mention autocomplete + note visibility (U11, web)

Type: Feature/UI

  • §11 U11 (§2.4, §2.6): surfaces the U11 backend on the deal detail panel, completing U11 to ✅ full. New DealCommentComposer component (frontend/src/pages/crm/DealCommentComposer.tsx — deleted 2026-06-21; deal comments now use the universal CommentThread component from src/components/comments/) replaces the single-line comment input on DealDetailPanel.
  • @mention autocomplete: typing @ in the textarea opens a member popover (active org members, filtered by typed text, anchored to the textarea); selecting one inserts @Name and records the member id. Ids whose @Name token survives in the final body are sent as the write-only mentions[] field. Enter submits (when no mention popover is open); Shift+Enter newlines.
  • Visibility toggle: a shared/private control (default shared) sends visibility on submit. The comment list now renders a “Private” badge on visibility==='private' notes and a muted origin badge for any non-user origin (system/import/confirmation).
  • Types: DealComment gains visibility? + origin? (frontend/src/types/index.ts). The existing useCreateDealComment mutation + dealsApi.createComment already pass an arbitrary payload, so mentions/visibility flow through unchanged.
  • i18n: 7 new deals keys (comment_send, comment_mention_label, comment_visibility_shared/_private, comment_origin_system/_import/_confirmation_link) added to en + vi and seeded across all locales (parity green).
  • Tests: frontend/src/test/DealCommentComposer.test.tsx (deleted 2026-06-21 with the DealCommentComposer component; comment behaviour now covered by frontend/src/test/CommentThread.test.tsx) — selecting a mention + posting calls onSubmit with the member id in mentions and the chosen visibility; default visibility is shared; suspended members are excluded from suggestions; a private comment renders the Private badge and an import comment renders an origin badge in DealDetailPanel.

2026-06-16 — Feature: Deal collaboration — @mention notifications + note visibility + provenance (U11, backend)

Type: Feature

  • §11 U11 (§2.4, §2.6): makes a deal’s collaboration record survive turnover. DealComment gains visibility (shared|private, default shared) and origin (user|system|import|confirmation_link, default user); DealActivity gains the same origin (shared ORIGIN_CHOICES in apps/crm/models.py). Migration 0014_dealactivity_origin_dealcomment_origin_and_more.
  • @mentions: POST /api/deals/<id>/comments/ accepts a write-only mentions field = list of OrganizationMember UUIDs (frontend resolves @name → id). DealViewSet._notify_mentions fires create_notification_event(member=<target>, category="mention", title="<author> mentioned you on a deal", body=<140-char excerpt>, module="crm", route="/deals", payload_json={"deal","comment"}, dedupe_key="crm-mention-<comment>-<member>") for each active member of the same org. Skips the author, skips malformed/non-existent/cross-org ids silently (a bad mention never fails the comment), dedupes per (comment, member). A non-list mentions is ignored.
  • Note visibility: the comment LIST queryset is org-scoped first (Rule 1), then a visibility filter is ANDed on via _is_org_manager (role ∈ owner/admin/manager) — managers/admins/owner see every comment; everyone else sees shared comments plus their own private ones. Deletion rules unchanged (author-only). visibility accepted on create (out-of-vocab coerced to shared); visibility+origin exposed read in the serializer; mentions write-only.
  • Tests: 15 new cases in tests/test_crm_extensions.py::TestDealCommentCollaboration — mentions fire one NotificationEvent per active member (category/member/route/dedupe/payload asserted); author not self-notified; invalid + cross-tenant + inactive + non-list mention ids skipped (no error, no event); private comment hidden from a non-manager non-author, visible to its author and to a manager; shared visible to all; visibility defaults to shared; invalid visibility coerced; origin defaults user on comment + activity and persists a non-user value.
    • [Superseded 2026-07-02] TestDealCommentCollaboration no longer exists: it was removed with the DealComment model when comments were consolidated into the universal F81 Comment (migration 0018, 2026-06-21). Deal-activity provenance is now covered by tests/test_crm_extensions.py::TestDealActivityOrigin; comment create/delete/visibility behavior is covered by backend/tests/test_comments.py.
  • Gates: pytest tests/test_crm_extensions.py tests/test_crm.py → 235 passed; every new models.py/serializers.py/views.py line covered (term-missing shows only pre-existing untested analytics/bulk lines); ruff check --select E,F,W --ignore E501 clean.

2026-06-16 — Feature/UI: Deposits & handover milestones on the deal panel (U5, web)

Type: Feature/UI

  • §11 U5 (§2.5): surfaces the U5 backend fields on the deal detail panel. New DealDeposit component (frontend/src/pages/crm/DealDeposit.tsx) renders a compact “Deposit & milestones” section between line items and comments on DealDetailPanel: deposit amount (formatted in the deal’s currency via formatCurrency), deposit due date, a “Deposit paid” checkbox, and kickoff (thi công) + handover (nghiệm thu) dates. Read view shows ”—” for empty values and a single “Set”/“Edit” affordance; edit view is a calm 2-col labeled grid that saves via useUpdateDeal (PATCH with only the deposit/milestone fields; a cleared amount → null, empty dates → null). Overdue treatment: when is_deposit_overdue ∧ ¬deposit_paid, the due date renders in --color-risk-red with an “Overdue” chip (--color-red-soft bg). Deal TS type (types/index.ts) gains the six fields (deposit_amount, deposit_due_date, deposit_paid, kickoff_date, handover_date, is_deposit_overdue).
  • Kanban card (pages/business/DealsPage.tsx): a calm red “Deposit overdue” chip joins the existing chip row for open deals where is_deposit_overdue ∧ ¬deposit_paid — no new row, no layout churn.
  • i18n: 17 new deals-namespace keys, authored in en + vi (Đặt cọc & mốc công việc, Số tiền cọc, Hạn đóng cọc, Đã đóng cọc, Quá hạn cọc, Ngày khởi công, Ngày bàn giao) and seeded into all 60 other locales with the English value. src/test/i18n.test.ts deals parity stays green (165 deals checks pass).
  • Tests: frontend/src/test/DealDeposit.test.tsx — renders deposit/milestone fields from a deal, em-dash for empties, overdue treatment shown when overdue+unpaid and hidden when paid, edit+save calls dealsApi.update with the changed fields, cleared amount → null. Mocks ../api/endpoints per the repo’s reliable pattern (no MSW).
  • Gates: tsc --noEmit clean; DealDeposit.test.tsx + deals i18n parity green (only pre-existing namespace-count/tasks/projects/sidebar/hq failures remain, not from this change); check-design-tokens.mjs clean (5 allowlisted LanguageSwitcher hits only).

2026-06-16 — Feature: Deposits & handover milestones on deals + Brief glue (U5, backend)

Type: Feature

Summary:

  • §11 U5 (§2.5): a fit-out deal runs on a deposit and milestone dates, not one close date. Deal (apps/crm/models.py) gains five fields — deposit_amount (Decimal 18,2, nullable), deposit_due_date (Date, nullable), deposit_paid (Bool, default false), kickoff_date (Date, nullable — thi công start), handover_date (Date, nullable — nghiệm thu). Migration 0013_deal_deposit_amount_deal_deposit_due_date_and_more. All five are auto-exposed writable by DealSerializer (fields="__all__"); a derived read-only is_deposit_overdue property (open ∧ ¬deposit_paid ∧ deposit_due_date < today, never stored — mirrors CRMActivity.is_overdue) is added as a ReadOnlyField on the serializer.
  • Brief glue (F12 ∩ F50, U2+U5): the operationally-stuck open-deal source in generate_morning_brief (apps/ai/services.py) now treats an overdue unpaid deposit as a third stuck condition alongside overdue-activity and no-next-activity. Each stuck deal still yields exactly ONE deal BriefItem (confidence 0.7, related_deal provenance, Rule 1 tenant-scoped query). No-duplicate rule: the single most urgent suggestion wins, in order — deposit-overdue (priority high) > overdue-activity (high) > no-activity (medium). So a deal that is both deposit-overdue and activity-overdue surfaces once, with the deposit suggestion; deposit-overdue deals also rank first (Python-side sort on the derived is_deposit_overdue, then stage_changed_at/created_at). New template deal_deposit_overdue in apps/ai/_templates._BRIEF_FMT (vi: “Đặt cọc quá hạn — thu cọc từ {company}”, en: “Deposit overdue — collect the deposit from {company}”, th provided).
  • Tests (TDD, red→green): TestDealDepositFields (test_crm.py) — field round-trip via API, defaults null/false, is_deposit_overdue property across overdue/paid/future/no-date/won, serialized read-only field. TestDealDepositBriefItems (test_brief_deals.py) — overdue-unpaid→high item, paid/future/won→no item, deposit+overdue-activity→ONE item deposit wins, deposit ranks first, cross-tenant isolation. TestDealBriefTemplates extended for deal_deposit_overdue (vi/en). 220 passed across test_crm.py + test_brief_deals.py + test_crm_activities.py; every new line covered; ruff (E,F,W) clean.
  • Web milestone/deposit form UI: shipped 2026-06-16 (see changelog entry below). A health=deposit_overdue saved-view filter: open (follow-up).

2026-06-16 — Feature/UI: Stage policy surfaced — required-field editor + board move-blocked reason (U7, web)

Type: Feature/UI

Summary:

  • Closes §11 U7 (§2.2) on the web front end, wiring the shipped backend stage-entry policy into two surfaces so it’s usable, not just enforced:
    • CRM Settings — required-field editor. Stage type gains required_fields?: string[] (src/types/index.ts). CrmSettingsPage StageRow now renders a calm “Required fields” chip-toggle row under name/days/win-%: options are the three built-ins (amount/expected_close_date/owner, localized via new crmSettings:required_field_* keys) plus the org’s deal custom fields (from useFieldDefinitions('deal'), labeled by locale). Toggling marks the row dirty; Save persists through the existing onSave(stage, { required_fields }) → useUpdateStage. Admin-gated: members see the toggles disabled (read-only) like every other stage control. Any required key not matching a known option is still shown as its raw key so admin changes never silently drop it.
    • Board move-blocked reason. DealsPage shared helpers missingRequiredFields(err) + toastMoveError(...): a drag (dealsMoveMutation.onError), a kanban “Move to” button (DealCard.moveStage), or the detail-panel move (handleMoveStage) that hits the backend 400 with missing_required_fields now toasts deals:move_blocked (“Can’t move to {stage}: add {fields}.”) with keys mapped to localized labels (built-ins via crmSettings, custom via the deal field defs, unknown → raw key), then invalidates/refetches deals so the card snaps back to its original column. Non-400 failures keep the generic error_move.
  • i18n: 6 new keys — crmSettings:{required_fields, required_field_amount, required_field_expected_close_date, required_field_owner} and deals:move_blocked (interpolated {{stage}}/{{fields}}). en + vi authored (vi: Trường bắt buộc · Giá trị · Ngày dự kiến chốt · Người phụ trách · “Không thể chuyển sang {{stage}}: cần điền {{fields}}.”); seeded with the English value across the other 60 locales to keep src/test/i18n.test.ts all-locale parity green.
  • Rails: shadcn + Lumi semantic tokens only (chips use border-primary/bg-primary/10/text-primary/border-border/bg-card/text-muted-foreground); check-design-tokens.mjs clean (only the 5 allowlisted LanguageSwitcher hits). Tests: src/test/StagePolicy.test.tsx (renders built-in + custom options, persists a selection via onSave, non-admin disabled) and src/test/StageMoveBlocked.test.tsx (a 400 with missing_required_fields produces the specific toast; non-400 falls back to generic) — both green; tsc --noEmit zero errors.

2026-06-16 — Feature: Stage policy / required-field enforcement on stage entry (U7, backend)

Type: Feature

Summary:

  • Closes §11 U7 (§2.2) on the backend: a deal can’t ENTER a stage until that stage’s required fields are filled, so a manager can trust pipeline state without calling around. New Stage.required_fields = JSONField(default=list, blank=True) (migration 0012_stage_required_fields) — a list of field keys required to enter the stage. StageSerializer is fields="__all__", so the field is writable by admins automatically via the existing IsPipelineStructureAdmin gate (non-admins still 403; members read it).
  • Shared validator: missing_required_fields(amount, expected_close_date, owner, custom_fields, stage) in apps/crm/serializers.py returns the list of stage.required_fields keys not satisfied. Built-in keys amount/expected_close_date/owner check the deal column (missing if null); any other key is missing unless it maps to a non-empty value in Deal.custom_fields. Empty required_fields (default) returns [] → never gates.
  • Enforced on BOTH stage-entry paths against the post-update values (incoming data falling back to instance), so a single request that both moves the deal and fills the field succeeds:
    • DealSerializer.validate (PATCH/POST): when the incoming stage differs from the current one (or on create into a gated stage), a violation raises ValidationError({stage: "Cannot move to '<stage>': missing <keys>.", missing_required_fields: [...]}) → 400.
    • DealViewSet.move: before saving the new stage, the same helper runs; a violation returns 400 {detail: "Cannot move to '<stage>': missing <keys>.", missing_required_fields: [...]}. No logic duplication.
  • Rails: queries stay tenant-scoped (deal/stage already org-scoped; no new cross-tenant surface). Tests: TestStageRequiredFields (15) in backend/tests/test_crm.py — both paths blocked without the field + succeed once set, required custom field enforced, empty required_fields never blocks, admin can set / member cannot (403), create-into-gated-stage blocked + succeeds with field, owner required. Full CRM gate suite (260) green; every added line covered; ruff (E,F,W) clean.
  • Open: web stage-config UI (a required-field editor on CrmSettingsPage) + native shells.

2026-06-16 — Feature/UI: Unresolved-name strip wired to resolve + backfill (U8, web)

Type: Feature/UI

Summary:

  • Closes §11 U8 on the web (§2.10): CompaniesPage’s UnresolvedStrip chips now call the shipped POST /api/companies/resolve/ instead of the bare companiesApi.create({name, is_customer:true}), which created the company but left the dangling Risk/Commitment/Task rows unlinked so the name reappeared. Resolving now both creates/reuses the company AND backfills those FKs, so the chip’s name drops out of /unresolved/.
  • API + hook: added companiesApi.resolve({name, create_deal?, pipeline?}) → POST /companies/resolve/, ResolveUnresolvedResponse type ({company, backfilled:{risks,commitments,tasks}, deal}), and useResolveUnresolved() mutation invalidating queryKeys.companies.lists() + unresolved() on success.
  • UI: each chip keeps a calm primary Create link (create_deal:false) that toasts “Created {name} · linked {n} records” (n = summed backfilled counts) plus a small secondary + deal link (create_deal:true) that toasts “Created {name} + deal”; success removes the chip; the dismiss (X) is unchanged; per-chip disabled-while-pending preserved. Reuses the existing amber chip tokens — no new stock-palette classes.
  • i18n: 3 new companies keys (unresolved_add_deal, unresolved_resolved, unresolved_resolved_deal) with {{name}}/{{count}} interpolation, added to en + vi (vi: ”+ giao dịch”, “Đã tạo {{name}} · liên kết {{count}} bản ghi”, “Đã tạo {{name}} + giao dịch”) and seeded across all other locales; src/test/i18n.test.ts companies parity green.
  • Tests: frontend/src/test/UnresolvedStrip.test.tsx (4) — Create calls resolve with create_deal:false; success toasts the summed linked-records count and removes the chip; + deal calls resolve with create_deal:true and toasts the deal message; error keeps the chip. tsc --noEmit clean; check-design-tokens.mjs no new violations.

2026-06-16 — Feature: Unresolved-name → company resolve + backfill (U8, backend)

Type: Feature

Summary:

  • Closes §11 U8 on the backend (§2.10): new POST /api/companies/resolve/ action on CompanyViewSet (IsOrgMember). Body {name, create_deal?, pipeline?}. In a single transaction.atomic(): get-or-create the Company by name_normalized (reuses an existing same-normalized-name company so resolving never duplicates; new companies are is_customer=True), then backfill — set the company FK on every dangling row in the org (company__isnull=True) whose name matches, across Risk.related_customer_name, Commitment.customer_name, and Task.customer_name.
  • The backfill match uses Lower(Trim(<field>)) == name.strip().lower(), identical to how unresolved groups the dangling strings — so a padded value like " Mystery Corp " resolves too, and the name then drops out of /companies/unresolved/.
  • Optional create_deal=true opens an open Deal for the company on the first stage (by order, then created_at) of the supplied pipeline (or the org’s first active, else first, pipeline), stamping stage_changed_at. If no pipeline/stage exists the deal is skipped and deal returns null — never a 500.
  • Rails: every query tenant-scoped by request.organization_id (company lookup/create, the three backfill updates, pipeline/stage lookup, deal create) — a name from org A never touches org B’s rows (explicit cross-tenant test). Empty/missing name → 400. Returns {company, backfilled:{risks,commitments,tasks}, deal}.
  • Tests: backend/tests/test_crm.py::TestResolveUnresolvedName (9) — create+backfill across all three models and drop-out of unresolved; reuse an existing same-normalized company; create_deal opens a first-stage open deal; default-pipeline fallback; no-pipeline → deal null (no 500); empty name → 400; missing name → 400; cross-tenant isolation (same name in org B untouched); idempotent (second resolve reuses the company, backfills 0). Full tests/test_crm.py green (141); new view lines covered; ruff (E,F,W) clean.

2026-06-16 — Feature/UI: Activity Agenda “my day” page (U9, web)

Type: Feature/UI

Summary:

  • Closes §11 U9 on the web (§6.6 item 3): new ActivityAgendaPage (frontend/src/pages/crm/ActivityAgendaPage.tsx) at route /crm/agenda, with a “Agenda” sidebar item in the Business group (lucide CalendarClock). A single daily surface for the operator: an Overdue section first (visually aligned 2026-06-28 to amber warning treatment, ?overdue=true&status=scheduled), then Upcoming (?upcoming=true&status=scheduled) grouped Today / Tomorrow / Later, across all deals.
  • A My / All owner toggle defaults to the signed-in member (derived the same way as DealsPage’s myMemberId — useMembersList + authUser.id); “Mine” adds ?owner=<member-id>, “All” drops it. Falls back to “All” until the member id is known.
  • Each row reuses the Phase A activity vocabulary (type icon via the exported ACTIVITY_TYPE_ICONS, type label, due time, owner, amber overdue rail + shared overdue badge) with Complete (useCompleteCrmActivity) + inline Reschedule (datetime-local → useUpdateCrmActivity, ISO due_at), plus a “View deal” link to /deals (no /deals/:id route exists). Loading skeletons, an error state, and a “No activities — you’re clear” empty state.
  • i18n: 15 new deals keys (agenda_title, agenda_description, agenda_filter_mine/all, agenda_section_overdue/upcoming, agenda_group_today/tomorrow/later, agenda_empty, agenda_empty_hint, agenda_error, agenda_reschedule_save/cancel, agenda_view_deal) + 1 sidebar key (agenda); en + vi authored, all 60 other locales seeded with the English value (all-locale parity green for these namespaces).
  • Tests: frontend/src/test/ActivityAgendaPage.test.tsx (5) — renders overdue + upcoming; Complete calls the API for the right activity; empty state; default “Mine” scopes both queries to the member; “All” toggle drops the owner filter. Mocks ../api/endpoints (no MSW), per CompanyPeople/SavedViews pattern. tsc --noEmit clean; design-tokens clean (5 allowlisted LanguageSwitcher hits only); i18n parity clean for deals + sidebar.
  • Diagrams: frontend/DIAGRAMS.md route tree + TanStack route map gain /crm/agenda.

2026-06-16 — Feature/UI: Wire saved views to filter server-side (U10, web)

Type: Feature/UI

Summary:

  • Closes §11 U10 on the web (§2.1): DealsPage now drives /api/deals/ filtering from the active saved view instead of filtering client-side. New buildDealsQueryParams (in frontend/src/pages/crm/SavedViewsBar.tsx) composes the query-param object — { pipeline, stage?, owner?, forecast_category?, health?, close_window? }, only set keys included — from selectedPipelineId + the active SavedDealView.filters, and DealsPage passes it straight to useDealsList. Pipeline is always driven by selectedPipelineId (a saved view that names a pipeline hoists it to the selector; the stored filters.pipeline is otherwise ignored).
  • Built-in presets map to server params via builtInPresetServerParams: my_overdue→health=overdue + owner=<me> (health alone when member unknown), no_next_activity→health=no_activity, stale_14d→health=stale, closing_this_week→close_window=this_week. deposit_expected has no clean server param and stays client-side (applyBuiltInPreset in viewDeals); the now-redundant client-side filtering for the moved presets was removed so filters aren’t double-applied. Applying a view sets activeFilters + view_mode (+ pipeline if named); Clear resets to the current pipeline. No preset labels changed; sort handling unchanged.
  • No new user-facing strings (pure wiring) → no locale changes.
  • Tests: frontend/src/test/SavedViews.test.tsx — builtInPresetServerParams (each preset incl. my_overdue with/without member, deposit_expected → null) and buildDealsQueryParams (no pipeline → undefined; pipeline-only when no view; built-in preset expansion; deposit_expected adds nothing; saved-view filters → params ignoring stored pipeline; only-set-keys). 21 passed. tsc --noEmit clean; design-tokens clean (5 allowlisted LanguageSwitcher hits only).

2026-06-19 — Visual QC: Activity Agenda empty state polish (U9, web)

Type: Polish/UI

Summary:

  • Screenshot-first pass for /crm/agenda (frontend/screenshots/22-crm-agenda/01-02-*.png) showed the shipped empty state was technically correct but too inert: a large blank card with no useful recovery path, and “Mine” vs “All” felt visually identical when there were no activities.
  • ActivityAgendaPage now uses the shared EmptyState primitive with two concrete recovery actions: See team agenda switches the owner scope from Mine → All in place, and Open deals links to /deals so the operator can schedule the next step directly from the source list. This keeps the page calm while removing the dead-end feeling.
  • i18n: replaced the old singular agenda_view_deal copy with agenda_open_deals, and added agenda_show_team in en + vi.
  • Tests: frontend/src/test/ActivityAgendaPage.test.tsx now also asserts the empty state exposes the /deals link and that the team-agenda action flips the header owner toggle to All.

2026-06-19 — Visual QC: CRM import layout pass (U3, web)

Type: Polish/UI

Summary:

  • Screenshot-first pass for /crm/import (frontend/screenshots/23-crm-import/01-02-*.png) showed the wizard was functionally complete but visually too loose: the page read as a large blank sheet with floating controls, and the column-mapping step left field labels and selects feeling disconnected.
  • CrmImportPage now groups the active step inside a single bordered card so the import flow reads as one guided task instead of several unrelated controls. Step 1 keeps the entity picker, paste area, and CSV action in one contained panel; Step 2 now uses a clearer two-column mapping layout with row dividers so each target field stays visually paired with its selected source column.
  • Screenshot coverage: frontend/tests/e2e/screenshot-flow.spec.ts now captures 23-crm-import with both the wizard overview and the mapping step.
  • Verification: frontend/src/test/CrmImportPage.test.tsx green (3), pnpm exec tsc --noEmit clean, node scripts/check-design-tokens.mjs clean.

2026-06-19 — Visual QC: CRM settings hierarchy pass (U1/U7, web)

Type: Polish/UI

Summary:

  • Screenshot-first pass for /crm/settings (frontend/screenshots/24-crm-settings/01-*.png) showed the page had all the right controls but read like a single long admin ledger: every section landed with similar weight, there was no quick orientation for the lower half of the page, and the stage/config areas relied on scroll persistence more than page hierarchy.
  • CrmSettingsPage now uses the shared PageHeader and adds a compact in-page section index so a manager can jump directly to pipelines, stages, custom fields, or the product catalog. The top two configuration surfaces now get SectionHeader treatment with explicit counts, which gives the long screen stronger editorial rhythm before the lower cards begin.
  • The pipeline and stage cards were also tightened so the headline hierarchy lives outside the card chrome rather than being repeated inside every section. Empty pipeline/stage cases now use the shared EmptyState primitive instead of raw muted paragraphs.
  • Screenshot coverage: frontend/tests/e2e/screenshot-flow.spec.ts now captures 24-crm-settings.
  • Verification: src/test/CrmSettingsNav.test.tsx, CrmSettingsPermission.test.tsx, CrmCustomFields.test.tsx, and StagePolicy.test.tsx all green (11 tests total); pnpm exec tsc --noEmit clean; node scripts/check-design-tokens.mjs clean.

2026-06-16 — Feature: Server-side saved-view filter vocabulary on /api/deals/ (U10, backend)

Type: Feature

Summary:

  • Closes §11 U10 on the backend: DealViewSet.get_queryset now evaluates the full SavedDealView.filters vocabulary server-side so saved views filter at scale instead of in the browser (§2.1). New optional, tenant-scoped, ANDed query params (all reuse the existing org scope + annotations; no N+1, no per-row queries):
    • ?owner=<member-id> → owner_id. A malformed uuid is treated as no filter (never a 500); a member id from another org matches nothing (Rule 1).
    • ?status=open|won|lost → status (invalid value → no filter).
    • ?forecast_category=pipeline|best_case|commit|omitted → forecast_category (validated against FORECAST_CHOICES; invalid → no filter).
    • ?health=… derived from existing state, reusing the has_overdue_activity/has_open_activity Exists annotations: overdue=has a past-due scheduled CRMActivity; no_activity=open deal with no scheduled activity; stale=open deal whose stage_changed_at is older than the stage’s expected_days (default 14 days when null), computed DB-side per row via Coalesce(F("stage__expected_days"), 14) * timedelta(days=1); close_soon=open deal with expected_close_date within the next 7 days.
    • ?close_window=… filters expected_close_date: this_week=current ISO week (Mon–Sun); this_month=current calendar month (Dec→Jan year rollover handled); overdue=< today AND status open.
  • Web wiring of SavedDealView.filters → these query params remains pending (kept the §11 U10 row as backend-shipped).
  • Tests: backend/tests/test_crm_saved_views.py — TestDealFilterOwner (incl. cross-tenant empty + malformed-uuid no-500), TestDealFilterStatus, TestDealFilterForecastCategory, TestDealFilterHealth (each health value, incl. stale-with-expected_days + stale-default-14), TestDealFilterCloseWindow (each window + Dec year rollover), TestDealFiltersCombined (filters AND; cross-tenant isolated). Every new line covered; full CRM suite 269 passed; ruff clean.

2026-06-16 — Feature/UI: Deal brief items rendered in the morning Brief (U2, web)

Type: Feature/UI

Summary:

  • Closes §11 U2 on the web: a stuck CRM deal now appears in the CEO’s morning Brief with its server-localized next-step suggestion and a link to the deals board (F12 ∩ F50).
    • BriefPage (frontend/src/pages/intel/BriefPage.tsx): added a new section_pipeline SECTION_TIERS entry (types: ['deal']) placed below decisions/risks/operations, mirroring the backend ranking so a deal never visually outranks a critical risk. deal item_type mapped to the TrendingUp lucide icon (consistent with the CRM/deals nav). Link resolver getEntityRoute returns /deals for any item with related_deal (the board — no per-deal route exists in App.tsx; none invented). The deal title prefers related_deal_title, falling back to item.title. The deal reuses the shared BriefItemCard — priority border, ceo_action_suggestion block, confidence pip — no bespoke card.
    • BriefItem type (frontend/src/types/index.ts) gained related_deal?: string | null + related_deal_title?: string | null.
    • i18n: new brief.section_pipeline (“Pipeline” / vi “Bán hàng”) + brief.view_deal (“View deal” / vi “Xem giao dịch”); en+vi authored, remaining 60 locales seeded with the English value so src/test/i18n.test.ts all-locale parity stays green. The suggestion text itself comes from the server (not hardcoded).
  • Tests: frontend/src/test/BriefPage.test.tsx — new case asserts a deal item renders its related_deal_title + ceo_action_suggestion, links to /deals, and falls under the Pipeline section. All 10 BriefPage tests + brief parity green.
  • Verification: tsc --noEmit 0 errors; check-design-tokens no new violations (only 5 allowlisted LanguageSwitcher hits). No backend changes.

2026-06-16 — Feature: CRM deals surfaced in the morning Brief (U2, backend)

Type: Feature

Summary:

  • Closes §11 U2 on the backend: an operationally-stuck open deal now becomes a deal BriefItem, so the CEO runs the day from the Brief (F12 ∩ F50).
    • BriefItem gained related_deal FK (crm.Deal, on_delete=SET_NULL, related_name="brief_items") and a ("deal","Deal") item_type. Migration apps/briefs/migrations/0006_briefitem_related_deal_alter_briefitem_item_type.py.
    • apps/ai/services.generate_morning_brief section 8: tenant-scoped query of status="open" deals annotated Exists(scheduled activity) and Exists(overdue scheduled activity), filtered to deals with an overdue activity or no scheduled next activity. Overdue-activity deals rank first (priority="high"); no-next-activity deals follow (priority="medium"). select_related("company","owner__user","stage") (no N+1); fills only remaining_slots() so it never crowds out decisions/approvals/risks/commitments/tasks (soft cap 12 honored).
    • Rule-based confidence_score=0.7 (never 1.0); the related_deal FK is the provenance — no fabricated EvidenceSource (consistent with commitment/decision brief items). Suggestions from new _templates keys deal_overdue_activity / deal_no_activity (en/vi/th; vi fit-out vocabulary “Đặt lịch theo dõi…”, “Báo giá/đặt cọc đang chờ…”).
    • BriefItemSerializer exposes related_deal (id, via __all__) plus a lightweight related_deal_title for the frontend link; BriefViewSet brief prefetch includes related_deal__company.
  • Tests: backend/tests/test_brief_deals.py (14) — overdue → high+related_deal; no-activity → medium; future scheduled activity → none; scheduled-without-due / done activity branches; won/lost → none; overdue-before-no-activity ordering; cross-tenant isolation; soft-cap; non-empty suggestions; template + serializer exposure. Full brief/ai/crm suite green (829).
  • Diagrams: backend/DIAGRAMS.md ER gains BriefItem }o--o| Deal. F12 spec §4/§5/§13 note the new deal source + related_deal.
  • Open: web Brief UI link to the deal (separate frontend task); native shells.

2026-06-16 — Feature/UI: Custom-fields management section in CRM Settings (U1, web)

Type: Feature/UI

Summary:

  • CrmSettingsPage (frontend/src/pages/crm/CrmSettingsPage.tsx) gained a Custom Fields section below Stages, closing §11 U1: admins can now define FieldDefinitions without the API.
    • Lists existing definitions filterable by entity_type (all / company / contact / deal), each row showing the locale label, key, type badge, and a Required badge; inline Edit (label_en/label_vi/type/required + choices) and Delete-with-window.confirm.
    • Add-field form: entity_type select, slug-ified key, en + vi labels, field_type select (text/number/date/choice), required Switch, and a chip-based ChoicesEditor (add/remove → choices_json: string[]) shown only for choice.
    • Admin gating reuses the page’s canEditStructure; non-admins get read-only disabled controls + the existing admin-only Alert; a residual server 403 surfaces its detail via the page’s shared notifyError toast.
    • Empty / loading / error states for the list.
  • Hooks (frontend/src/hooks/queries/useDealStructure.ts): useCreateFieldDefinition, useUpdateFieldDefinition, useDeleteFieldDefinition — each invalidates queryKeys.fieldDefinitions.all on success (keeps CustomFieldsEditor in record forms in sync). Existing useFieldDefinitions unchanged.
  • i18n: 35 new cf_* keys in the crmSettings namespace, added to en + vi and seeded (English value) across all other locale dirs to keep all-locale parity green.
  • Tests: frontend/src/test/CrmCustomFields.test.tsx (mocks ../api/endpoints per the reliable CRM-component pattern) — lists field definitions; add-field calls create with the entered/slug-ified values; a non-admin sees disabled controls + the admin-only notice.
  • Diagrams: frontend/DIAGRAMS.md route tree — /crm/settings now notes the custom-fields manager.
  • Backend untouched (FieldDefinitionViewSet + fieldDefinitionsApi already shipped).

2026-06-16 — Fix: field-definition reads opened to members; writes stay admin

Type: Fix

Summary:

  • FieldDefinitionViewSet (apps/crm/views.py) had permission_classes = [IsOrgMember, IsOrgOwnerOrAdmin], so even listing field definitions required admin. The Phase B CustomFieldsEditor renders custom fields for ALL members via GET /api/field-definitions/, so non-admin members got a 403 and custom fields silently failed to render for them.
  • Mirrored the StageViewSet/PipelineViewSet pattern: base permission_classes = [IsOrgMember] plus a get_permissions that requires admin (IsPipelineStructureAdmin) only on write actions (create/update/partial_update/destroy). Reads (list/retrieve) are now member-open; non-admin writes still return 403 with the “Only an admin can change pipeline structure.” detail. Org-scoped queryset unchanged (Rule 1).
  • Tests (tests/test_crm.py::TestCustomFields): non-admin member can LIST + retrieve (200, was 403); member cannot update/delete (403); admin can update + delete; cross-tenant isolation on list.

2026-06-16 — Docs: “Roadmap to Undeniable” (next 11) added to §11

Type: Documentation Change

Summary:

  • Added a §11 “Roadmap to Undeniable — the next 11” table after the benchmark scorecard: the next tier of CRM work beyond Phases A–D, ranked by wedge leverage, each with current state + the concrete move + cross-links (U1 custom-field config UI, U2 Brief glue, U3 Excel/CSV import, U4 báo giá + F71, U5 deposits/milestones, U6 native mobile, U7 stage policy, U8 unresolved→company, U9 activity agenda, U10 server-side saved-view filters, U11 @mention/visibility/provenance).
  • Grounded each row in the current code (e.g. U1: backend FieldDefinitionViewSet + fieldDefinitionsApi exist but no admin UI; U2: BriefItem has no related_deal FK).

Reason:

  • Capture the post-benchmark roadmap on the rails so the highest-leverage gaps (daily-habit Brief glue, real custom-field power, migration path, the báo giá artifact) are tracked, not lost.

Impact:

  • Documentation only. Status column drives the next build pass; each item flips as it ships.

2026-06-16 — Feature/UI: Company-360 People tab (web)

Type: Feature / UI

Summary:

  • Added a People tab to Company360 (frontend/src/pages/business/CompaniesPage.tsx) between Overview and Timeline, with a contact-count badge. Works for any company — customer, supplier, or partner. Closes the §2.9/§2.5 gap where a company’s people were invisible from the company.
  • New component frontend/src/pages/crm/CompanyPeople.tsx: calm dense contacts list (name, title, tappable tel:/mailto: + zalo), inline Add person form (name required; title/phone/zalo/email optional) that POSTs with company prefilled, per-row Edit and Delete (delete guarded by confirm), plus empty ("No people yet — add the first contact at {company}"), loading, and error states. TanStack Query throughout — no useEffect+fetch.
  • New hooks in frontend/src/hooks/queries/useDealStructure.ts: useCompanyContacts(companyId) (?company= scoped), useCreateContact, useUpdateContact, useDeleteContact — all mutations invalidate queryKeys.contacts.byCompany(companyId) (new key). Reuses the existing contactsApi; no endpoint duplication.
  • i18n: 19 new companies-namespace keys added to en + vi and seeded across all 60 other locales (English fallback) to keep src/test/i18n.test.ts parity green. MSW (src/mocks/handlers.ts): GET /api/contacts/ now honors ?company=, plus new POST/PATCH/DELETE /api/contacts/ handlers. Test: frontend/src/test/CompanyPeople.test.tsx (lists company contacts, ?company=-scoped request, add POSTs with company id, delete after confirm, empty state).
  • Note: this environment’s node_modules/@radix-ui/* is not installed (no network/offline cache), so component vitest cannot resolve shadcn UI imports — pre-existing CompanyTimeline.test.tsx fails identically; not introduced by this change. tsc surfaces zero errors in the changed files; companies i18n parity is clean; check-design-tokens shows only the 5 allowlisted LanguageSwitcher hits.

2026-06-17 — Feature/UI: standalone Contacts workspace + contact-origin deal creation

Type: Feature / UI

Summary:

  • Closed the F50 gap where CRM contacts only existed inside Company 360 and had no first-class operating surface. Added frontend/src/pages/business/ContactsPage.tsx and routed it at /contacts from frontend/src/App.tsx; Business navigation now includes Contacts in Sidebar.tsx.
  • The page is intentionally operational, not decorative: search box, relationship filter (all|customer|supplier|partner), dense org-wide contacts list, and a detail pane selected by ?contact=<id>. The detail pane supports edit, delete, jump back toward Companies, and a primary New deal from contact action.
  • Contact create/edit/delete now reuse the same TanStack Query mutation layer as Company 360 via widened hooks in frontend/src/hooks/queries/useDealStructure.ts: useContact(id?), plus generic useCreateContact(companyId?), useUpdateContact(companyId?), useDeleteContact(companyId?). Invalidations now cover both org-wide and company-scoped contact caches so the standalone page and Company 360 stay coherent.
  • Deal capture from contact context is now real: DealsPage reads ?company= + ?contact= during create flow; CreateDealForm preselects the company, and after dealsApi.create(...) it immediately calls dealsApi.addContact(newDealId, { contact, role: 'decision_maker' }). That gives the new deal an explicit stakeholder from the first save instead of relying on operator memory.
  • i18n: added sidebar.contacts plus the new companies-namespace strings for the Contacts workspace in en + vi, then seeded the remaining locales so parity checks stay green.
  • Tests: frontend/src/test/ContactsPage.test.tsx (detail pane + create flow), frontend/src/test/ContactDealPrefill.test.tsx (contact-origin deal creation assigns decision_maker), and frontend/src/test/CrmSettingsNav.test.tsx (route wiring for /contacts).

2026-06-16 — Feature: contacts list filterable by company (Company-360 People tab support)

Type: Feature

Summary:

  • GET /api/contacts/ (ContactViewSet.get_queryset, backend/apps/crm/views.py) gained three tenant-scoped, optional list filters so the web Company-360 can list a company’s people: ?company=<id> (filters company_id), ?search=<str> (case-insensitive name/email via filters.SearchFilter, search_fields=["name","email"], mirroring CompanyViewSet), and ?relationship=customer|supplier|partner (filters on the contact’s company flag company__is_customer/is_supplier/is_partner, mirroring CompanyViewSet’s relationship semantics).
  • All filters are ANDed onto the existing org scope (Rule 1): a ?company= id belonging to another org returns empty, never that company’s contacts. Absent/unrecognized params apply no filter (full org-scoped list). Serializer, model, and other viewsets unchanged; no migration.
  • Tests (backend/tests/test_crm.py::TestContact): ?company= returns only that company’s contacts; cross-tenant ?company= returns empty; ?search= matches by name; ?relationship= filters by company flag (parametrized over customer/supplier/partner); no-param returns all org contacts. 126 test_crm.py tests pass; every added line covered; ruff clean.

Type: Fix

Summary:

  • Bug: CrmSettingsPage.tsx (pipeline & stage configuration — the page that defines the stages every deal sits in) was orphaned: added in 548ac7d7 but never wired into App.tsx, with nothing linking to it (only its own test imported it). It was completely unreachable in the web app — the #1 CRM navigation gap.
  • Fix (routing): lazy import + <Route path="/crm/settings" element={<CrmSettingsPage />} /> in frontend/src/App.tsx, beside /crm/approvals.
  • Fix (nav): sidebar Business group gains a “CRM Settings” item (SlidersHorizontal icon, new sidebar.crm_settings key) visible to all members — the page itself renders the permission-legible disabled state for non-admins (Phase D). The deals-board PageHeader gains a calm gear icon-link to /crm/settings (buttonVariants + react-router <Link>, aria-label/title “Configure pipelines & stages”, new deals.configure_pipelines key).
  • CRM cross-link audit: deal → company link added in DealDetailPanel.tsx (company name in the panel header links to /companies, fed by a new companyNames prop from DealsPage). Company → deals link with company-prefill (/deals?new=1&company=<id>) already existed in CompaniesPage.tsx (Company360) — unchanged. ViewSwitcher.tsx Forecast/Calendar/Timeline buttons gained aria-label/title (icon-only on mobile, previously unlabeled) so the Forecast mode + view modes are discoverable.
  • Orphan audit: every other pages/crm/*.tsx and pages/business/*.tsx file confirmed either routed in App.tsx (Approvals/Companies/Deals) or a sub-component imported into DealsPage/DealDetailPanel. CrmSettingsPage was the only orphaned top-level page.
  • i18n: 2 new keys (sidebar.crm_settings, deals.configure_pipelines) in en + vi; all 60 other locales seeded with English (all-locale parity green for sidebar + deals).
  • Tests: frontend/src/test/CrmSettingsNav.test.tsx — asserts /crm/settings resolves to CrmSettingsPage, and the deals header exposes a link to /crm/settings.
  • Verification: tsc --noEmit 0 errors; targeted vitest green (CrmSettingsNav + CrmSettingsPermission + i18n parity for sidebar/deals); check-design-tokens only the 5 allowlisted LanguageSwitcher hits. Pre-existing tasks/projects i18n failures + the one namespace-files config test are unrelated and untouched.

2026-06-16 — Phase A/C (web): overdue-activity → deal health + conversion/aging analytics panel

Type: Feature / UI

Summary:

  • Closes the two remaining web gaps in F50: (1) overdue activity now drives deal health (Phase A item 4, web portion — the daily-Brief F12 glue stays explicitly open), and (2) the conversion + aging analytics hooks finally have a UI (Phase C item 9 UI deferral).
  • Deal health (pages/business/DealsPage.tsx getDealHealth): folds the annotated activity signals into the existing health priority — has_overdue_activity → amber stale on an otherwise-fresh deal (a missed scheduled next step is a defect, but not destructive danger), and an open deal with has_open_activity === false → the no_activity state. Critical very-stale-stage and overdue-deposit semantics remain red.
  • DealHealthBadge.tsx: new hasOverdueActivity / hasOpenActivity props render “Overdue activity” and “No next activity” through the shared amber warning badge recipe; wired on the kanban card (DealsPage) and table row (TableView.tsx).
  • DealAnalyticsPanel.tsx (new): calm, dense, expandable section below PipelineStats on the deals board (TASTE.md — no decorative cards). Surfaces win-rate by owner + by pipeline, average stage duration + stage-to-stage progression, and per-stage stale counts + avg days-in-stage; resolves owner/pipeline/stage ids → names client-side (props from DealsPage); shows the conversion basis note for honesty; loading (skeleton) / empty / error states defined. Uses the already-shipped useDealConversion / useDealAging hooks.
  • i18n: overdue_activity + analytics_* keys (deals namespace) in en + vi; all 60 other locales seeded with English (all-locale parity green for deals).
  • Tests: frontend/src/test/DealAnalytics.test.tsx (overdue-activity → degraded health, getDealHealth contract mirror, badge rendering; panel renders win-rate + per-stage aging + basis, plus empty/error states).
  • Verification: tsc --noEmit 0 errors; targeted vitest 21/21 (DealAnalytics + DealForecast) green; deals i18n parity clean (only pre-existing tasks/projects + the one namespace-files config test fail, unrelated); check-design-tokens only the 5 allowlisted LanguageSwitcher hits.
  • Still open (not claimed): daily-Brief (F12) glue (cross-app, deferred), native shells.

2026-06-16 — Phase D (web): saved-views bar + wider bulk-edit toolbar + permission-legible stage config

Type: Feature / UI

Summary:

  • Types (frontend/src/types/index.ts): SavedDealView, DealViewMode, SavedDealViewSort, SavedDealViewFilters, BulkDealActionType, BulkDealActionResult.
  • API/hooks: dealViewsApi (list/create/update/delete) in api/endpoints.ts; queryKeys.dealViews; hooks useDealViews/useCreateDealView/useUpdateDealView/useDeleteDealView (invalidate dealViews.list); useBulkDealAction re-typed to BulkDealActionType + returns BulkDealActionResult.
  • Saved-views bar (pages/crm/SavedViewsBar.tsx): horizontal bar above the deals board. Built-in client-side presets — My overdue (has_overdue_activity & owner=me), Closing this week (≤7d to close), No next activity (open & !has_open_activity), Stale >14d, Deposit expected (commit/best_case) via pure applyBuiltInPreset. User saved views: click to apply (filters/sort/view_mode), “Save current view” dialog (name + share Switch), owner-only edit/delete; non-owned shared views are read-only (no edit/delete controls); 403 surfaced via toast. Loading / empty / error states.
  • Wider bulk-edit (pages/crm/DealsBulkBar.tsx): multi-select toolbar replacing the old stub ViewSwitcher bulk Select. Action picker → per-action input: set close date (<input type=date>), change pipeline (select), set forecast category (select), set custom field (key select from deal FieldDefinitions + typed/choice value). Partial failures render errors[] inline ({deal_id, error}) in a destructive Alert.
  • Permission legibility (pages/crm/CrmSettingsPage.tsx): reads the org role from useAuthStore; non-admins see an info notice (“Only an admin can change pipeline structure.”) and all structural controls (create/rename pipeline, add/save/reorder/delete stage) render disabled with a tooltip; a residual 403 surfaces the server detail in a toast.
  • DealsPage.tsx wires SavedViewsBar + DealsBulkBar; built-in preset filtering applies to all views (kanban/table/calendar/timeline) via viewDeals; ViewSwitcher no longer owns bulk actions.
  • MSW: /api/deal-views/ CRUD + a partial-failure /api/deals/bulk/ handler + fixtureDealViews (own + shared).
  • i18n: new deals keys (saved-views + wider-bulk) and crmSettings keys (permission_notice*) in en + vi; all other locales seeded with English (all-locale parity green for both namespaces).
  • Tests: frontend/src/test/SavedViews.test.tsx, DealsBulkBar.test.tsx, CrmSettingsPermission.test.tsx. Diagrams: frontend/DIAGRAMS.md route note + new §9b sequence.
  • Verification: tsc --noEmit 0 errors; targeted vitest 14/14 + deals/crmSettings i18n parity green; check-design-tokens only the 5 allowlisted LanguageSwitcher hits.

2026-06-16 — Phase D: saved views + wider bulk vocabulary + permission legibility (backend)

Type: Feature

Summary:

  • Model (apps/crm/models.py): SavedDealView (db_table crm_saved_deal_views) — org FK; owner FK (OrganizationMember, CASCADE); name; view_mode (default kanban); filters JSONField (default dict); sort JSONField (default list); is_shared bool; ordering ["name"]. Migration 0011_saveddealview. SavedDealViewSerializer exposes all fields; owner+organization are read-only (stamped server-side).
  • ViewSet (apps/crm/views.py): SavedDealViewViewSet (ModelViewSet, IsOrgMember, AuditDestroyMixin) at /api/deal-views/. Queryset is org-scoped and visibility-scoped — Q(owner=member) | Q(is_shared=True) — so a member sees their own views plus org-shared ones (read-only). perform_create stamps owner=requesting member + org; perform_update/perform_destroy raise PermissionDenied (“Only the view’s owner can edit or delete it.”) for non-owners → 403 detail. Registered in apps/crm/urls.py (basename deal-view).
  • Wider bulk (DealViewSet.bulk): added update_close_date (params.expected_close_date YYYY-MM-DD), update_pipeline (params.pipeline_id — org-validated; keeps the current stage if it already belongs to the destination pipeline, else lands on that pipeline’s first stage by order; empty pipeline → 400), update_forecast_category (params.forecast_category ∈ Deal.FORECAST_CHOICES), update_custom_field (params.key+params.value, validated via validate_custom_fields against the org’s deal field defs; bad key/value → 400). Each keeps the per-deal success_count/error_count/errors contract + logs a per-deal DealActivity; each is tenant-scoped (cross-org deals → 404).
  • Permission legibility (apps/crm/views.py): new IsPipelineStructureAdmin (subclass of IsOrgOwnerOrAdmin, message = "Only an admin can change pipeline structure."). StageViewSet/PipelineViewSet gained get_permissions — reads (list/retrieve) open to members; structural writes (create/update/partial_update/destroy) require admin/owner → 403 with explanatory detail. (Previously these viewsets required admin/owner even for reads; reads are now correctly open to members per §2.11.)

Tests:

  • backend/tests/test_crm_saved_views.py — 45 tests across TestSavedDealViewModel, TestSavedDealViewCRUD, TestSavedDealViewCrossTenant, TestBulkUpdateCloseDate, TestBulkUpdatePipeline, TestBulkUpdateForecastCategory, TestBulkUpdateCustomField, TestStageConfigPermission, TestPipelinePermission. Cross-tenant on every new endpoint + bulk action. Full CRM gate suite (324 tests) green; new lines covered (remaining uncovered apps/crm/views.py lines are the pre-existing move action + the per-deal except Exception defensive branches shared by all bulk actions; models.py:191, serializers.py:115/211 pre-existing). ruff (E,F,W) clean.

Impact:

  • Backend only. Closes §2.1 saved-views box, §2.8 wider-bulk box, §2.11 stage-config + out-of-scope-edit boxes, and §11 Phase D items 11–13 on the backend. Web UI (saved-views bar, bulk-edit menu, permission-blocked states) remains open.

2026-06-16 — Phase C (web): forecast view + forecast-category control + currency-honest analytics

Type: Feature / UI

Summary:

  • Types (src/types/index.ts): ForecastCategory union + Deal.forecast_category; DealForecast (currency_mode: per_currency, by_category, weighted_pipeline), DealConversion, DealAging, CurrencyTotals; PipelineAnalytics.by_currency + weighted_by_currency.
  • API (src/api/endpoints.ts): dealsApi.forecast / conversion / aging / setForecastCategory. Query keys (queryKeys.deals.forecast/conversion/aging).
  • Hooks (src/hooks/queries/useDeals.ts): useDealForecast(pipelineId, period), useDealConversion(pipelineId), useDealAging(pipelineId), useSetForecastCategory (invalidates deals lists/details + forecast + analytics). TanStack Query only.
  • Forecast view (src/pages/crm/ForecastView.tsx): new “Forecast” mode in ViewSwitcher, grouped by Commit / Best Case / Pipeline / Omitted with per-currency totals on separate lines (never summed) + per-currency weighted pipeline; month period selector; loading / empty / error states. Persisted with the other deals view preferences. Renders before the stage-empty guard so it works regardless of board stages.
  • Forecast-category control (src/pages/crm/ForecastCategoryControl.tsx): shadcn Select on the deal detail panel (open deals) + read-only chip on the kanban card; PATCHes on change with toast + query invalidation.
  • Currency-honest analytics: PipelineStats.tsx renders per-currency pipeline + weighted lines when more than one currency exists, else keeps the compact merged display.
  • i18n: new deals keys (en + vi: dự báo / chốt chắc / khả năng cao / tiềm năng / loại trừ / tỷ lệ thắng / thời gian trung bình mỗi giai đoạn), seeded across all other ~60 locales with English fallback; deals namespace stays parity-clean.
  • MSW: added /deals/forecast/, /deals/conversion/, /deals/aging/, PATCH /deals/<id>/forecast-category/ handlers.

Tests:

  • frontend/src/test/DealForecast.test.tsx (11): forecast/conversion/aging hooks + setForecastCategory mutation; ForecastView renders two currencies separately and asserts they are NOT summed (no 150,012,000); empty + error + period-change states; ForecastCategoryControl PATCHes; PipelineStats per-currency vs single-currency display.

Verification:

  • tsc --noEmit 0 errors; targeted vitest 27/27 (DealForecast 11, PipelineStats 3, DealComments 3, DealStructure 10); deals i18n parity green; check-design-tokens no new violations (5 allowlisted LanguageSwitcher hits only).

Deferred:

  • Conversion win-rate + stage-aging UI surfaces (Phase C item 9) — hooks shipped, no dedicated panel yet. Saved views / bulk forecast-category edit / permissions belong to Phase D.

2026-06-16 — Phase C: managerial truth (forecast categories + conversion analytics + currency honesty) (backend)

Type: Feature

Summary:

  • Model (apps/crm/models.py): Deal.forecast_category — FORECAST_CHOICES (pipeline/best_case/commit/omitted), default pipeline, manual in v1. Migration 0010_deal_forecast_category. DealSerializer (fields="__all__") exposes it writable.
  • ViewSet (apps/crm/views.py, DealViewSet):
    • forecast_category (PATCH /api/deals/<id>/forecast-category/) — body {forecast_category}, validates against Deal.FORECAST_CHOICES, saves (signal-suppressed), returns {id, forecast_category} (200); invalid/missing choice → 400; tenant-scoped via get_object.
    • forecast (GET /api/deals/forecast/?pipeline=&period=YYYY-MM) — open + org-scoped; currency_mode: per_currency; by_category always returns all four keys, each {count, value:{<cur>:…}}; weighted_pipeline = Σ amount × stage.probability/100 per currency (omitted excluded, null probability→0); period filters expected_close_date within the month (invalid YYYY-MM → 400). Currencies are never summed into one number.
    • conversion (GET /api/deals/conversion/?pipeline=) — win-rate by owner + by pipeline (won/(won+lost), decided-only), stage-to-stage progression counts + average stage duration derived from stage_changed DealActivity (from_stage_id/to_stage_id/days_in_prev_stage); a basis field documents the approximation. Aggregate queries (no N+1).
    • aging (GET /api/deals/aging/?pipeline=) — per-stage count, stale_count (open deals with days_in_current_stage > stage.expected_days), avg_days_in_stage; open + org-scoped.
    • analytics (existing) — added by_currency ({cur:{count, pipeline_value}}) + weighted_by_currency per-currency breakdowns; all pre-existing keys (total_open_deals, weighted_pipeline_value, by_stage, …) unchanged for web-UI backward compat.

Tests:

  • backend/tests/test_crm_forecast.py — 33 tests across TestForecastCategoryField, TestForecastCategoryAction, TestForecastEndpoint, TestConversionEndpoint, TestAgingEndpoint, TestAnalyticsCurrencyHonesty. Cross-tenant on every new endpoint. Full CRM suite (279 tests) green; every new line covered (remaining uncovered apps/crm/views.py lines — move action, bulk except branches — and models.py:191 are pre-existing). ruff (E,F,W) clean.

Impact:

  • Backend only. Closes §2.7 forecast/conversion/currency-honesty boxes and §11 Phase C items 8–10 on the backend. Web forecast view + conversion dashboards remain open (Phase C UI / Phase D).

2026-06-16 — Phase B: deal structure UI (contact roles + line items + custom fields) (web)

Type: Feature, UI

Summary:

  • Types (src/types/index.ts): DealContactRole, DealContactRoleType, DealLineItem added (FieldDefinition already existed).
  • API (src/api/endpoints.ts): contactsApi, fieldDefinitionsApi; extended dealsApi with contacts/addContact/removeContact and lineItems/addLineItem/updateLineItem/deleteLineItem. Query keys added (src/api/queryKeys.ts): dealContacts, dealLineItems, contacts, fieldDefinitions.
  • Hooks (src/hooks/queries/useDealStructure.ts): useDealContacts, useAddDealContact, useRemoveDealContact, useDealLineItems, useAddDealLineItem, useUpdateDealLineItem, useDeleteDealLineItem, useContactsList, useFieldDefinitions. All line-item mutations invalidate the deal detail + lists so the server-rolled Deal.amount refreshes (TanStack Query only).
  • Components: src/pages/crm/DealContactRoles.tsx (role chips + add/remove, contact picker scoped to the deal’s company, empty state); src/pages/crm/DealLineItems.tsx (dense editable line-item table — name/qty/unit price/discount %/tax %/server line_total, add/edit/delete, Σ rollup footer + “amount derived” note); src/components/crm/CustomFieldsEditor.tsx (reusable per-field_type editor honoring required + locale label). All three wired into DealDetailPanel; CustomFieldsEditor also wired into the deal (DealsPage) and company (CompaniesPage) create forms, saved via the existing PATCH custom_fields.
  • MSW (src/mocks/handlers.ts): handlers + fixtures for /contacts/, /field-definitions/, /deals/<id>/contacts/, /deals/<id>/line-items/.
  • i18n: ~38 new deals-namespace keys (contact roles, role labels, line items, custom fields) in en + vi (natural Vietnamese), seeded across all 60 other locales with the English value (i18n parity green for deals/companies).

Tests:

  • frontend/src/test/DealStructure.test.tsx — 10 tests: contact-role chips render/empty/add/remove, line-item render + rollup total, add (triggers rollup mutation), delete, CustomFieldsEditor renders per type + marks required, onChange, and renders nothing with no defs. tsc --noEmit clean; check-design-tokens zero new violations.

Impact:

  • Web only. Closes §2.5 (custom-field editing + multi-stakeholder + line-item boxes), §2.12 (line-item UI boxes), and §11 Phase B items 5–7 on the web shell. Native (iOS/Android) shells remain open.

2026-06-16 — Phase B: deal structure (DealContactRole + DealLineItem) (backend)

Type: Feature

Summary:

  • Models (apps/crm/models.py): DealContactRole (db_table crm_deal_contact_roles) — org FK; deal FK CASCADE; contact FK CASCADE; role choices (decision_maker/champion/procurement/site_contact/finance/other); UniqueConstraint(deal, contact, role). DealLineItem (db_table crm_deal_line_items) — org FK; deal FK CASCADE; name/description; quantity/unit_price/discount_pct/tax_pct decimals; sort_order; ordering [sort_order, created_at]; read-only line_total = qty × price × (1 − discount_pct/100) × (1 + tax_pct/100) (Decimal-exact). Migration 0009_dealcontactrole_deallineitem.
  • Serializers (apps/crm/serializers.py): DealContactRoleSerializer — validates contact.company_id == deal.company_id (cross-company stakeholders rejected in v1) and cross-tenant contact; rejects duplicate (deal, contact, role); exposes contact_name + role_display. DealLineItemSerializer — read-only line_total.
  • ViewSet (apps/crm/views.py): nested @action routes on DealViewSet mirroring the existing comment pattern — contacts (GET/POST), delete_contact_role (DELETE contacts/<role_id>/), line_items (GET/POST), line_item_detail (PATCH/DELETE line-items/<item_id>/). All deal lookups org-scoped (Rule 1). Rollup helper recompute_deal_amount(deal) — recomputes Deal.amount = Σ line_total (quantized 2 dp) on every line-item create/update/delete; when no items exist, leaves the manual/last-computed amount untouched (never zeroes); suppresses the amount-change DealActivity signal; called only from the line-item mutations (never a Deal.save signal — no recursion).

Tests:

  • backend/tests/test_crm_deal_structure.py — 28 tests: line_total math, ordering, model __str__; contact-role add/list/remove, cross-company rejection (400), duplicate rejection (400), same-contact-different-role allowed, cross-tenant list/add/delete (two-org); line-item CRUD; rollup correctness (first item overrides manual amount, Σ line_total, patch & delete recompute, delete-all leaves last computed amount, manual amount never zeroed when no items); cross-tenant list/add/patch/delete (two-org); missing-item 404. Full CRM suite (246 tests) green; new code at 99% (only acceptable pre-existing uncovered lines remain).

Impact:

  • Backend only. UI (contact-role chips, line-items table in the deal detail panel) and custom-field editing (Phase B item 7) remain open. Satisfies §2.5 (multi-stakeholder + line-item boxes), §2.12 (rollup + deterministic recalc boxes), and §11 Phase B items 5–6.

2026-06-16 — Phase A: next-activity discipline UI (web)

Type: Feature, UI

Summary:

  • Types (frontend/src/types/index.ts): new CRMActivity / CRMActivityType / CRMActivityStatus; extended Deal with next_activity_at, has_overdue_activity, has_open_activity.
  • API (frontend/src/api/endpoints.ts): crmActivitiesApi = list/create/update/delete/complete against /api/crm-activities/. Query keys added (queryKeys.crmActivities).
  • Hooks (frontend/src/hooks/queries/useCrmActivities.ts): useCrmActivities (scope-gated query) + useCreateCrmActivity / useUpdateCrmActivity / useCompleteCrmActivity / useDeleteCrmActivity; every mutation invalidates both crm-activities lists and deals lists/details so the annotated next-step fields refresh.
  • UI: NextActivityCard (deal detail panel) — soonest scheduled activity with type icon/subject/due/owner, overdue styling, Complete + Reschedule actions, a strong “no next activity” warning + Schedule CTA when an open deal has none, an inline schedule form (React Hook Form + Zod via useZodForm: subject · type · due_at · owner), and a list of further scheduled + done activities. NextActivityIndicator — compact next-activity / amber “no next activity” badge on kanban cards (DealsPage) and the table row (TableView, new “Next activity” column). All strings via the deals i18n namespace (en + vi parity; Vietnamese fit-out vocabulary: cuộc gọi, khảo sát công trình, theo dõi báo giá, theo dõi đặt cọc).
  • MSW (frontend/src/mocks/handlers.ts): GET/POST/PATCH/complete/DELETE /api/crm-activities/ handlers + fixtureCrmActivities.

Tests:

  • frontend/src/test/CrmActivities.test.tsx — 8 tests (hooks: fetch, scope-gating, create, complete; card: renders next activity, no-next-activity warning for open deals, complete calls API, no warning for won deals). tsc clean (no new errors); design-tokens guard clean.

Impact:

  • Web only. Satisfies §2.3 boxes 1–2 (first-class activity + “no next activity” warning) and §11 Phase A items 2–3. Deferred at that time: overdue → deal-health badge + daily-Brief glue (Phase A item 4), calendar “activity mode”, native shells. The overdue→deal-health and activity-agenda follow-ups later shipped in subsequent entries below.

2026-06-17 — Phase A: calendar activity mode (web)

Type: Feature, UI

Summary:

  • CalendarView.tsx now accepts optional activities and adds a small mode toggle: Close dates vs Activities. In close-date mode the monthly grid behaves as before, grouping deals by expected_close_date. In activity mode it groups scheduled CRMActivity rows by due_at, rendering the activity subject pills separately from deal close dates and using a distinct empty state when the month has no scheduled activities.
  • DealsPage.tsx fetches scheduled /api/crm-activities/ rows through TanStack Query (queryKeys.crmActivities.list({status:'scheduled'})), then filters them to the currently visible open deals before passing them into CalendarView. This keeps the activity-mode calendar honest to the active pipeline/view filters without inventing a new backend pipeline filter that the API does not support.
  • i18n: new deals keys for the mode toggle + activity empty state added to en / vi, then seeded across the remaining locale files with English placeholders to satisfy the repo’s strict key-parity gate.

Tests:

  • frontend/src/test/CrmViews.test.tsx — added coverage for switching to activity mode, rendering scheduled CRM activities, and the activity-mode empty state.
  • pnpm --dir frontend exec tsc --noEmit — clean.
  • frontend/scripts/validate-i18n-keys.mjs still reports a pre-existing unrelated tasks.board_file_help gap across non-en/vi locales; the new deals keys introduced here no longer contribute to validator failures.

Impact:

  • Web only. Closes §2.3 box 3 (calendar activity mode) and removes one of the last visible benchmark gaps in the CRM’s next-step discipline. Still open in Phase A: F12 daily-Brief glue and native shells.

2026-06-16 — Phase A: CRMActivity next-step engine (backend)

Type: Feature

Summary:

  • Model CRMActivity (apps/crm/models.py, db_table crm_activities): organization FK; nullable deal (CASCADE) / company (CASCADE) / contact (SET_NULL); activity_type (call/meeting/site_survey/quote_followup/deposit_followup/task/other), subject, notes, due_at, completed_at, owner (OrganizationMember SET_NULL), status (scheduled/done/cancelled). Derived is_overdue property (never stored). Two indexes per §4.3. Migration 0008_crmactivity.
  • Serializer CRMActivitySerializer: read-only is_overdue, owner_name; per-FK cross-tenant validation; validate() enforces at-least-one-of deal/company/contact (400 otherwise).
  • ViewSet CRMActivityViewSet (ModelViewSet, IsOrgMember, AuditDestroyMixin): org-scoped select_related queryset; query filters deal/company/contact/owner/status/activity_type/overdue=true/upcoming=true; perform_create stamps org + defaults owner to the requesting member; @action complete sets status=done, completed_at=now. Routed at /api/crm-activities/ (basename crm-activity).
  • Deal next-step discipline: DealSerializer gains read-only next_activity_at, has_overdue_activity, has_open_activity, annotated in DealViewSet.get_queryset (Min + Exists subqueries, N+1-safe) with serializer fallbacks for un-annotated instances.

Tests:

  • backend/tests/test_crm_activities.py — 39 tests (model is_overdue, create per-link, at-least-one-of 400, cross-tenant rejection + list isolation, all query filters, ordering, complete, owner default, update/delete, Deal annotation values + serializer fallback paths). New CRM code lines at 99% under the full suite; no regressions introduced.

Impact:

  • Backend only. UI surfacing (next-activity card, “no next activity” warning, activity agenda) and Brief/deal-health glue remain open — §2.3 boxes 2–4 and §11 Phase A items 2–4 left unchecked.

2026-06-14 — Strategy v2 realignment (positioning + vertical vocabulary)

Type: Documentation Change

Summary:

  • §1: removed the “the CRM a CEO actually closes deals from” and “make CRM feel like a tool, not a database” generic-SaaS framing; repositioned F50 as the client/supplier/subcontractor relationship graph a fit-out contractor runs the business from, replacing the customer Excel sheet + lost-in-Zalo history (not a CRM the contractor doesn’t own); recast the stalled-deal example and pipeline as the fit-out sales/job lifecycle (khảo sát → báo giá → đặt cọc → thi công → nghiệm thu → bảo hành), with khách hàng / NCC (supplier) / thầu phụ (subcontractor) vocabulary.
  • §1: added a Loop 2 cross-link — external parties (client/supplier/subcontractor) are confirmed via F71 no-login links and are themselves future Lumi users (the acquisition loop); F50 owns the relationship graph + views, F71 owns the external no-login surface.

Reason:

  • Realign to competitive-strategy-v2.md and the registry Positioning doctrine (strategy v2, 2026-06-14): competitor is Zalo/WhatsApp + Excel + owner memory (not US SaaS); the data graph is the moat; Vietnamese-first vertical vocabulary; F71 is Loop 2 (company→company acquisition).

Impact:

  • Positioning / vocabulary only. No model, field, API, test, or status change. Any behavior change is tracked in §11 Implementation Audit, not claimed done here.

Tests:

  • None (documentation change).

Notes:

  • All code-level names (DealActivity/DealComment, §4 fields, §5 API paths, §7 test classes such as TestDealReorder/TestDealAnalytics/TestCompanyTimeline) left untouched. The §3 “import from HubSpot/Salesforce” line is a scope exclusion (no external-CRM import), not a competitor-positioning line, and was preserved.

2026-06-14 — Spec restructured to TEMPLATE.md canonical order (§1–§13)

Type: Documentation Change

Summary:

  • §9 Edge Cases (9 items) absorbed into §11 Implementation Audit note.
  • §12 Implementation Audit converted from bullet list + Implementation Order to canonical 4-column table (10 rows, all ✅).
  • Sections renumbered: §10 ES → §9, §11 Diags → §10, §12 Audit → §11, §13 OQ → §12, §14 BL → §13.

Reason:

  • Align with TEMPLATE.md canonical section order per SP fleet restructuring pass.

Impact:

  • Spec authors and future implementation agents.

2026-06-13 — Spec hardening: numbered edge cases, detailed error matrix, status → agreed

Type: Documentation Change

Summary:

  • Replaced §9 loose bullet points with 9 numbered edge cases (9.1–9.9) covering multi-stakeholder companies, bulk operation tenant scoping, imported activity provenance, concurrent deal reorder, health indicator false positives on new deals, empty pipeline analytics, calendar view with 200+ deals, CSV export respecting filters, and @mention without notification channel.
  • Replaced §10 two-sentence prose with 10-row detailed error matrix (user-visible/internal/log/retry).
  • Updated spec status from draft to agreed.

Reason:

  • Spec needed hardening per TEMPLATE.md. Edge cases were loose prose, error states lacked structured detail.

Impact:

  • Spec authors and future implementation agents.

Tests:

  • Spec-only change — no code tests affected.

2026-06-18 — Quote value-mix breakdown on deal line items

Type: Feature + Documentation Change

Summary:

  • Added DealLineItem.value_category with the bounded quote-mix taxonomy labor|material|subcontractor|service (default service for existing rows with no historical evidence).
  • GET /api/deals/<id>/line-items/ now returns both items and value_mix, where value_mix is grouped by category with amount and percentage.
  • DealLineItems.tsx now lets operators assign/edit the category on each line item and renders a compact value-mix breakdown below the table.
  • Updated backend/frontend diagrams and the F50 spec so the quote-structure contract no longer claims this gap is open.

Reason:

  • Large contractor quotes were still explainable only row by row, not compositionally. An owner could see the line items but not whether value was mostly labor, material, subcontractor, or service mix.

Impact:

  • Quote-driven deals now expose a fast commercial read on what the amount is made of without widening into full CPQ or product-catalog taxonomy.
  • Existing line items default to service; that is a conservative fallback, not historical reconstruction.

Tests:

  • cd backend && ./.venv/bin/python -m pytest tests/test_crm_deal_structure.py -q
  • cd frontend && pnpm exec vitest run src/test/DealStructure.test.tsx --reporter=dot

2026-06-13 (T3-E R10 — Deal Health Indicators)

Type: Feature

Summary:

  • getDealHealth() function in DealsPage.tsx: computes critical/stale/no_activity/close_soon/healthy status from days_in_current_stage, days_since_last_activity, expected_close_date.
  • DealCard border now reflects health: border-red-500 animate-pulse (critical), border-orange-400 (stale), border-gray-300 (no_activity), border-blue-400 (close_soon), border-gray-200 (healthy).
  • DealHealthBadge receives daysSinceLastActivity prop to display no-activity state.
  • Stale badge colors updated: critical → bg-red-50 text-red-700, stale → bg-amber-50 text-amber-700.
  • Deal type extended with days_since_last_activity?: number.
  • Table view (sortable columns) remains open — separate task.

Files: frontend/src/pages/business/DealsPage.tsx, frontend/src/types/index.ts, docs/specs/F50-crm-2.md. Tests: TSC 0 errors. No new unit tests (visual-only change).

2026-06-16 — Added benchmark-discipline layer beyond feature inventory

Type: Documentation Change

Summary:

  • Added §3.5 clarifying what top CRMs actually converge on beneath the surface: mandatory next-step discipline, richer record survivability, repeatable management views, and structurally explainable revenue.
  • Tightened the spec’s underlying standard from “more CRM features” to “the specific operating disciplines that make Pipedrive-class systems useful in the real day.”

Reason:

  • F50 already had a strong backlog, but the reason those requirements matter was still too implicit. Without that layer, future edits can relapse into feature-count thinking.

Impact:

  • Documentation only. No model, endpoint, UI, test, or registry-status change.

Tests:

  • None (documentation change).

2026-06-15 — F50 rewritten as benchmark-aware CRM spec, not just feature inventory

Type: Documentation Change

Summary:

  • Rewrote F50 to distinguish shipped CRM strengths from the requirements needed to be honestly comparable to top CRMs like Pipedrive.
  • Added a benchmark capability matrix covering activities, deal-contact roles, line items/products, saved views, forecast categories, analytics depth, and permissions.
  • Reframed the core gap: Lumi’s main CRM weakness is no first-class next-step activity model, not lack of more dashboards.
  • Preserved Lumi’s contractor wedge and avoided turning the spec into generic CRM bloat.
  • Tightened the spec further with explicit permission, product/line-item, saved-view, and benchmark-grade screen/state requirements.

Reason:

  • The previous spec described the current implementation well, but it was too narrow and too flattering relative to what top CRMs actually do.

Impact:

  • Future agents now have a stricter source of truth for what CRM 2.0 still owes before it can be called benchmark-grade.

Tests:

  • No new code tests; this is a spec-only rewrite grounded in current implementation and existing CRM test suites.

Notes:

  • This rewrite is intentionally sharper than the old version: strong shipped CRM core, but still materially short of a Pipedrive-class operating CRM until activities, richer deal structure, and forecast semantics are added.

2026-07-09 — Fix: DealDetailPanel content height — missing h-full on loading and loaded sheets (issue #54, PR #1431)

Type: Bugfix

Summary:

  • The CRM deal detail panel rendered inside the transformed AnimatedPage container instead of the browser viewport. PR #1431 added missing h-full classes, fixing inner scroll height but leaving position: fixed trapped below the app top bar.
  • Durable fix: keep h-full and portal both loading and loaded overlays to document.body.
  • Same containing-block class of defect as the workspace height collapse (2026-07-06) and the TaskDetailShell fix (issue #50, 488d6d40d).
  • Regression coverage: DealDetailPanel.test.tsx asserts h-full plus the body portal boundary; deep-links.spec.ts asserts y = 0 and viewport-height geometry; the screenshot flow captures the opened panel.
  • Postmortem: docs/postmortems/2026-07-09-deal-detail-panel-content-height.md.

2026-06-19 — Deals visual QC pass: board-first hierarchy, compact create state, honest screenshots

Type: UI Polish + Documentation Change

Summary:

  • Added the missing Deals screenshot set at frontend/screenshots/18-deals/ (01-deals-board.png, 02-deals-create-form.png, 03-deals-forecast-view.png) so this CRM surface is covered by the visual audit flow instead of being judged from memory.
  • Reworked the /deals page hierarchy so the working surface leads the first viewport again: KPI cards stay at the top, saved views and view switching sit directly above the board, and the stage chart + conversion/aging analytics move below the primary work area.
  • Tightened the inline create-deal form into a single compact band with actions aligned in the header row, so opening capture does not push the board far down the page.
  • Removed the empty saved-views placeholder from the quick-view bar; when no saved views exist, the row now stays focused on useful presets plus the save action instead of spending horizontal space on an inert status sentence.
  • Reduced the pipeline chart footprint and converted the KPI strip to a stable responsive grid so the overview reads as a dense operational summary rather than a stack of airy cards.

Reason:

  • The pre-pass screenshots showed a working CRM board whose visual hierarchy was backwards: chart and analytics chrome consumed too much vertical space, the inline create state displaced the board, and the saved-views empty copy added noise without helping an operator act.

Impact:

  • /deals now reads more like an operating surface and less like an analytics page that happens to contain a board.
  • The screenshot suite now captures the real CRM workflow states that matter for future QC: board, create, and forecast.

Tests:

  • cd frontend && pnpm test -- src/test/SavedViews.test.tsx src/test/DealAnalytics.test.tsx src/test/PipelineStats.test.tsx
  • cd frontend && pnpm exec tsc --noEmit
  • cd frontend && pnpm exec playwright test tests/e2e/screenshot-flow.spec.ts --project=chromium --reporter=list -g "18-deals"

2026-06-19 — Contacts visual QC pass: honest empty state, tighter create flow, full screenshot coverage

Type: UI Polish + Documentation Change

Summary:

  • Added the missing Contacts screenshot set at frontend/screenshots/19-contacts/ (01-contacts-list.png, 02-contacts-create-form.png, 03-contacts-detail.png) and updated the screenshot flow so it creates a contact when the demo org starts empty, guaranteeing a real detail-state capture instead of stopping at an empty shell.
  • Reworked /contacts around the real demo-state truth: when the org has no contacts, the page now shows one full-width onboarding empty state with a primary Add person action instead of two oversized empty panels competing for attention.
  • Added a filtered-empty variant with a clear-filters recovery action so the empty state distinguishes “nothing exists yet” from “your current filter returned nothing.”
  • Tightened the create-contact form into a compact command row plus denser field grid, which keeps capture near the top of the page without turning the first interaction into a long detour.
  • Kept the two-pane operating layout once contacts exist, so the list/detail split remains focused on selection and follow-up rather than decorative filler.

Reason:

  • The pre-pass screenshots showed that the live Contacts page was not actually broken; it was faithfully rendering a zero-data org. The problem was product quality: the empty state was visually wasteful, the page could not produce a meaningful detail screenshot without manual data creation, and the create flow occupied more space than the interaction warranted.

Impact:

  • /contacts now has a calm and useful first-run state, plus a screenshot flow that proves the page’s empty, create, and populated-detail states all exist and read clearly.

Tests:

  • cd frontend && pnpm test -- src/test/ContactsPage.test.tsx
  • cd frontend && pnpm exec tsc --noEmit
  • cd frontend && pnpm exec playwright test tests/e2e/screenshot-flow.spec.ts --project=chromium --reporter=list -g "19-contacts"

2026-07-10 — Feature: Activity Agenda rollup counts + team-by-owner breakdown (U9, web, W-CRM-05)

Type: Feature

Summary:

  • Closes the W-CRM-05 “agenda depth (recurring/team rollups) modest” gap noted in docs/tests/web/web-screen-audit.md. No recurrence field exists on CRMActivity (backend/apps/crm/models.py), so “recurring” rollups aren’t buildable honestly; “team rollups” is — every activity already carries a real owner_name (backend/apps/crm/serializers.py CRMActivitySerializer.get_owner_name), so both additions are pure client-side aggregation of data the endpoint already returns, no new endpoint, no invented numbers.
  • Added a compact rollup strip (three Badges: {{count}} overdue / {{count}} today / {{count}} upcoming) directly under the section list, using the already-fetched overdue/upcoming arrays and the existing Today/Tomorrow/Later grouping — surfaces “how many are due today” without opening the Upcoming section to find the Today bucket.
  • Added OverdueByOwner: when the All scope is active and the overdue list spans 2+ distinct owners, a “Overdue by teammate:” line with one badge per owner (Name · count, sorted descending) appears under the Overdue heading — the genuine team-rollup capability that was missing. Hidden in “Mine” scope and when overdue spans a single owner (would just repeat the section count).
  • i18n: 6 new deals keys (agenda_rollup_label, agenda_stat_overdue/today/upcoming, agenda_overdue_by_owner, agenda_unassigned); en + vi authored, all other locales seeded via propagate-new-i18n-keys.mjs.
  • Tests: frontend/src/test/crm/ActivityAgendaPage.test.tsx — new cases assert the rollup strip’s counts against real fetched data, and that the by-owner breakdown only renders in “All” scope with 2+ owners (and updates on scope toggle). 8/8 passed. tsc --noEmit clean; check-design-tokens.mjs clean; check-locale-parity.mjs clean.

14. Contract Graph reconciliation (2026-07-15)

The first Contract Graph migration records the shipped F50 CRM boundary without converting the benchmark roadmap into a completion claim. Executable backend evidence is split across the focused backend/tests/crm/ suites rather than the older monolithic test paths named in parts of this spec. The registered proofs cover next-step activity creation and tenant isolation, saved-view ownership, line-item rollups, dry-run import behavior, forecast currency separation, conversion, aging, and cross-tenant analytics.

The web contract records the shipped activity agenda, analytics, forecast, saved-view, line-item, quote, and deal-detail surfaces. Vitest and Playwright proof IDs are attached to real test nodes, but browser execution remains an explicit gap when the required browser binaries or seeded runtime are unavailable. Native parity, live providers, Redis, Sentry, and production behavior are not inferred from source, collection, or focused repository tests. Full accounting, invoicing, payment collection, external CRM synchronization, and autonomous follow-up remain outside this slice.

The contract also assigns the shipped product-catalog delete route, DELETE /api/products/{id}/, to ProductCatalogItemViewSet with the existing owner update/delete pytest proof. This remains repository evidence only.

The exact GET /api/products/ surface is also assigned to ProductCatalogItemViewSet with F50.AC001.PRODUCT_LIST; this does not establish browser, native, live-service, or production evidence.

Contract Graph reconciliation (2026-07-16): the OpenAPI-owned PUT /api/products/{id}/ full-replacement mutation is assigned to ProductCatalogItemViewSet and registered as F50.AC001.PRODUCT_PUT. This records source and test ownership only; backend execution, browser, native, live-service, and production evidence remain open.

2026-08-03 — Native mobile scheduled next-activity capture

The shared deal-detail surface previously let a mobile user create a CRM activity only as an undated history note. That was not enough for the next-step engine: an open deal could still look like it had no scheduled next action even after a user tapped “Log activity”. The activity create request now carries the existing backend due_at field. Android and iOS share a compact schedule form with an optional localized date picker and a 24-hour local time field; both values must be provided together and are converted to an RFC-3339 UTC instant before the tenant-scoped POST. An empty pair preserves the existing undated-history behavior. Invalid dates, times, or partial input stay in the form with a localized error and never send a malformed request. The returned activity is inserted into the detail list, so the same response immediately becomes the “Next activity” card without a second fetch.

The contract and test plan add the shared mobile act-flow proof. Emulator execution remains mechanics evidence only; named-device, accessibility, live-provider, and production behavior are still separate F50 gaps.

2026-08-04 — Native mobile mark-done recovery

The shared deal-detail screen previously cleared its in-flight marker after a failed POST /api/crm-activities/<id>/complete/ call but did not render the stored error, leaving the operator with no explanation or reliable retry target. Completion now retains the exact activity ID until success, renders the failure through the existing danger banner, and exposes a localized Retry action. No activity row is optimistically changed; a successful retry refreshes the authored server state. The proof remains emulator/simulator mechanics only until named-device and provider evidence are collected.

The exact POST /api/products/ create surface is now separately assigned to ProductCatalogItemViewSet with F50.AC001.PRODUCT_CREATE; its guarded backend execution remains an explicit gap and no browser, native, live-service, Redis, Sentry, or production evidence is inferred.

The exact GET /api/products/{id}/ surface is assigned to ProductCatalogItemViewSet with the dedicated F50.AC001.PRODUCT_DETAIL member retrieval proof; runtime evidence remains separate.

2026-08-05 — Native activity labels and iOS lifecycle proof

The native deal-detail activity surfaces no longer treat backend enum strings as display copy. All eight activity types now resolve through shared English/Vietnamese resources in the Log activity selector, Next activity card, and activity history rows, with a localized Other fallback for unknown values. DealActivityLabelSourceTest protects the resource contract.

The iOS half of the scheduled next-activity proof is now registered through .maestro/critical/111_ios_crm_next_activity_capture.yaml and scripts/run-ios-crm-next-activity-proof.sh. The runner owns an isolated Vietnamese fixture and delegates exact simulator/build/install/driver/shutdown custody to scripts/run-ios-maestro.sh. On 2026-08-05 the approved iOS 26.4 simulator completed the flow with build 184 (adc22c8636cb104456dbf5ddb0ce27e3cc6551224ef0c2b39c9384d86bdf0a5b), localized Gọi điện, server due_at=2026-08-05T16:59:00Z, and zero cleanup counts. F50.GAP005 remains open only for signed named-device, accessibility-service, provider, store, and production evidence.

The exact PATCH /api/deals/{id}/forecast-category/ surface is assigned to DealAnalyticsMixin.forecast_category with F50.AC002.FORECAST_CATEGORY_PATCH; repository proof remains separate from backend execution, browser, native, live-service, and production evidence.

The exact PATCH /api/deals/{id}/reorder/ surface is assigned to DealStageMixin.reorder with F50.AC001.DEAL_REORDER; repository proof remains separate from backend execution, browser, native, live-service, and production evidence.

2026-08-06 — Shared native activity agenda source

The F50 U9 native gap now has a bounded shared KMP source slice: Android and iOS resolve the typed /crm/agenda route to a tenant-scoped activity agenda with Mine/All owner scope, overdue-first ordering, Today/Tomorrow/Later grouping, localized compact rows, complete with exact retry, and date-only rescheduling that preserves the existing local time. Loading, stale/error, empty, and refresh states are explicit. Kotlin model/API tests plus Android/iOS route tests protect the source seams; no named-device, accessibility, or live rendering evidence is claimed yet.

2026-08-09 — Android activity agenda recovery proof and compact actions

The shared agenda rows now use 20dp Done and DateRange glyphs inside the standard 48dp LumiIconAction target, with localized accessibility labels and stable exact-activity tags. This removes the repeated text pills that dominated each phone card without changing the complete or reschedule workflows. The reschedule field also publishes its current ISO date in a stable automation tag so draft preservation can be asserted independently of localized display formatting.

.maestro/critical/150_android_crm_agenda.yaml and scripts/run-android-crm-agenda-proof.sh own a disposable Vietnamese tenant, one exact emulator, one built/installed prodDebug APK, a local one-shot failure proxy, server custody, fixture cleanup, and shutdown. On 2026-08-09 the guarded Pixel 7 API 35 lifecycle passed with matching APK SHA-256 80f96ac111aac6d199f3ab11be58ea6ef7fa990f67d58c4fa43bd56aee32c134: Mine hid the team row, All exposed it, an injected completion 503 preserved the exact overdue row until Retry, an injected reschedule 503 preserved the selected date until the second Save, the recovered row rendered under Later, and final custody was done:2026-08-11T03:00:00+00:00:scheduled. Retained frames are Mine, All, and recovered. This narrows Android emulator mechanics only; iOS, named-device, native screen-reader-service, store, provider, and production evidence remain open.

2026-08-12 — Native read-only quote summary

Shared Android/iOS Deal Detail now reads the existing tenant-scoped quote document and renders its server total plus compact quantity and line-total rows. Quote state is independent from the deal, activities, stakeholders, and comments; quote-only retry preserves stale commercial truth. Empty structured quotes remain valid and retain the server’s stored deal amount. Native catalog, line-item mutation, print, and external send are intentionally unchanged.

The existing stakeholder lifecycle was extended instead of adding a second emulator harness. On 2026-08-12 it installed matching built/installed prodDebug APK SHA-256 a52cb2fd6d067d65acd56048cf5eba8fe905b0f57dd3d26f05bb94d3d10162ae, rendered the 2 750 000 VND quote total with Panels and Installation rows, read back SERVER_QUOTE=2750000.00:Panels,Installation, cleaned the disposable fixture, and shut down the Pixel 7 API 35 emulator. The retained frame is CRM-05/2026-08-12-android-deal-quote.png. This is Android emulator mechanics evidence only; iOS, named-device, accessibility-service, signed-store, provider, deployed, and production proof remain open.

2026-08-12 — Native per-deal forecast category

Shared Android/iOS Deal Detail now places the server-authored forecast category in the first operating viewport. Effective crm.deal.edit capability exposes four compact localized choices; viewers keep the same truth without mutation controls. The exact nested PATCH publishes returned server truth without reloading activities, stakeholders, comments, or quote state. A failed write keeps the prior category and exact retry target, while capability failure removes mutation and pending retry authority even when an older write completes later. The Deal API now enforces crm.deal.edit on every non-read request (crm.deal.delete on canonical destroy), so a viewer cannot bypass the native control through the forecast-category endpoint or another Deal write path. Native month rollups, conversion charts, bulk editing, and saved forecast views remain outside this slice.

On 2026-08-12 the extended fixture-owned Pixel 7 API 35 lifecycle installed matching built/installed prodDebug APK SHA-256 3baa2a73eaccce0697ee82e827807cf2c735fdf435fa31c105f3deb2fdb98fbb, rendered Pipeline, selected Commit, published the Commit badge, and read back SERVER_FORECAST_CATEGORY=commit. It also preserved quote and stakeholder custody, retained the forecast frame, cleaned the disposable tenant, and shut down emulator-5554. This narrows Android emulator mechanics only; iOS, viewer and failed-mutation runtime, named-device, accessibility-service, signed-store, provider, deployed, and production proof remain open.

2026-08-09 — Shared native deal stakeholders and exact Android custody

The shared Android/iOS Deal Detail now uses the existing tenant-scoped nested deal-contact contract to render stakeholder names and localized roles in compact grouped rows. Add loads only contacts from the deal’s company and preserves the selected contact/role after failure. Remove requires confirmation, targets one exact role id, and retains that same retry target if the request fails. Stakeholder loading and stale/error state remain independent from activities and comments. All organization roles can read the stakeholder list; Add and Remove render and execute only when the effective capability set includes crm.deal.edit, so viewers remain read-only and capability lookup failure fails closed.

.maestro/critical/154_android_crm_stakeholders.yaml and scripts/run-android-crm-stakeholders-proof.sh own a disposable tenant, company, deal, contacts, one exact emulator, one built/installed prodDebug APK, server readback, screenshot, cleanup, and shutdown. On 2026-08-09 the guarded Pixel 7 API 35 lifecycle passed with matching APK SHA-256 1c4bfc40de58170cf4b84e728b552fa878c1aa4d2b9743d56c428ad689feaeb9: it rendered Lan as Champion, added Minh as Finance, confirmed removal of Lan, and converged to exact server custody Minh:finance. The retained frame is CRM-05/2026-08-09-android-deal-stakeholders.png. This narrows Android emulator mechanics only; iOS runtime, named-device, screen-reader-service, store, provider, production, viewer runtime, and runtime failure-injection evidence remain open.

2026-08-06 — Native activity dialog IME recovery

The first iOS scheduled-activity proof exposed a real interaction defect: the multiline note kept the iOS keyboard over the lower scheduling fields and submit action, while the generic hideKeyboard Maestro command was not supported by the KMP text bridge. A prior coordinate workaround was rejected because it tapped Lumi’s dismissible dialog scrim and could lose the draft. LumiDialog now clears focus when the user taps non-input content inside the card, preserving the dialog and its local note, date, and time state while releasing the IME. LumiDialogBackHandlerSourceTest protects the source contract, and the iOS flow asserts the dialog and draft remain visible before submitting.

On 2026-08-06 the approved iOS 26.4 simulator passed the full Vietnamese journey on build 185 (5920f269505bc6610b583422a30c2131ab7374a27797efc9786fb68f02951efe), rendered Gọi điện, read back server due_at=2026-08-06T16:59:00Z, and left zero fixture objects after shutdown. This narrows the mechanics gap only; signed named-device, accessibility-service, provider, store, and production evidence remain separate.

The same runner-owned fixture and scheduled activity journey passed on the reused Android emulator-5554 with the exact prodDebug APK SHA-256 4f2d3b9ee8d9e8538c2756afb0c0e83243e2e60c8e8e1149d204e462119ae808, the same server-authored due_at, and emulator shutdown/fixture cleanup. The retained Android screenshot is CRM-05/2026-08-06-android-next-activity.png. This narrows only emulator mechanics; signed named-device, accessibility-service, provider, store, and production evidence remain separate.

2026-07-19 — Shared mobile populated Forecast proof and localized value copy

The shared Android/iOS Forecast is now contract-owned by F50.AC004. Stable Business-row, screen, and Pipeline-category identifiers let the runner prove a disposable Vietnamese tenant without selecting localized navigation copy. One open 100M VND Pipeline deal at a 25% stage probability must render a 25M VND weighted headline and the unsummed 100M VND category value.

The proof exposed an English leak in Vietnamese: the numeric headline was assembled as 25M VND weighted in the presentation repository. Numeric values are now language-neutral; the shared UI supplies the already-localized weighted-pipeline label. The Business summary adds that localized label explicitly, preserving meaning without embedding copy in the value formatter.

Pixel 7 API 35 passed with identical built/installed prodDebug APK SHA-256 d1a4e982b6a69f536099815094297ceae085d5bf98b0170cf19646854a3a1053, and the runner removed the disposable organization and user. This is emulator evidence only; named Android floor-device, signed named-iPhone, empty/error/refresh, accessibility, and PM scoring remain open.

On 2026-07-20 the same populated contract passed on the single approved iOS 26.4 simulator through scripts/run-ios-forecast-populated-proof.sh. Installed build 151 SHA-256 fa45fdfb767c09c63169dd992adc4fdb134861831d1072f2d507b65e4e357a0a rendered the localized 25M VND weighted headline and 100M VND Pipeline category, then cleanup verified organization:user:company:pipeline:deal 0:0:0:0:0. The journey also caught and fixed a separate shell P1: the iOS billing bridge dropped the stable alert level and locale, leaking English server copy into Vietnamese. Positive Vietnamese and negative English assertions now protect that seam. This remains simulator mechanics evidence, not signed named-iPhone proof.

2026-07-16 — Performance: state-conditioned Deals startup

Type: Performance + Documentation Change

Summary:

  • The default Kanban keeps saved-view reads, presets, apply/clear behavior, and pipeline KPIs available immediately, while create/edit/delete mutation dialogs load on the corresponding user intent.
  • Scheduled activities are requested only for Calendar. Conversion/aging analytics mount at their below-board viewport boundary and all pipeline analytics wait for a selected pipeline instead of issuing an unscoped first request.
  • The pipeline picker mounts its shadcn Select from pointer-down or keyboard intent. The read-only forecast chip and exact page primitives no longer pull editable selector or unrelated page-barrel dependencies into Kanban startup.
  • No endpoint, saved-view permission, currency-honesty rule, URL-state behavior, or visible capability changes.

Reason:

  • The merged-main floor trace measured /deals as the sole remaining P0 route above the empirical 300 kB JavaScript target. Resource timing showed eager action-only UI and duplicate hidden-data requests on the default Kanban visit.

Tests:

  • @lcg:F40.AC005.DEALS_CUSTODY plus focused saved-view, analytics, URL-state, CRUD, forecast, selector, bundle, and seeded browser proof. The focused production graph is 257.8 kB / 44 files; a three-run seeded floor subset is 291.8 kB browser JavaScript. PM still owns canonical merged-main scoring.