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:
CalendarViewdeal chips are draggable in close-date mode, day cells are drop targets, andDealsPagepersists the newexpected_close_datethrough 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:
SavedDealViewmodel +/api/deal-views/CRUD — a member sees their own views plus org-shared ones, owner-only edit/delete,is_sharedexposes read-only org-wide. Web UI shipped 2026-06-16, Phase D:SavedViewsBaron 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).)
- Given
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_atupdates 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-inamount/expected_close_date/ownercheck the deal column, any other key checksDeal.custom_fields. Enforced via a sharedmissing_required_fields()helper on BOTH theDealSerializer.validate(PATCH/POST) path and theDealViewSet.moveaction; 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 successfulPOST /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 underF50.AC013; runtime, named-device, accessibility-service, store, deployed, and production evidence remain open underF50.GAP013.
- Native mobile contract: shared Android/iOS Deal Detail exposes a localized 48dp stage action only when the effective capability set contains
- 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.)
- Given deals within a stage, when they render on the board, then they sort by
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:
CRMActivitymodel +/api/crm-activities/CRUD +completeaction. UI shipped 2026-06-16:NextActivityCardin 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:
NextActivityIndicatoron the kanban card and table row reads the annotatedhas_open_activityfield 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:
CalendarViewnow has a deals-vs-activities toggle.DealsPagefetches 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 infrontend/src/test/CrmViews.test.tsxcover 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:
getDealHealthmapshas_overdue_activity→ amberstalewhile preservingcriticalfor true danger states such as unpaid overdue deposits and very stale stages, and an open deal withhas_open_activity === false→no_activity;DealHealthBadgerenders “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.)
- 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:
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
DealActivityrows 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
DealActivityrows 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 throughstage_changedrather 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(sharedORIGIN_CHOICES, defaultuser) persist provenance on every note/event.)
- Given deal create, stage change, amount change, owner change, or terminal close, when that mutation happens, then
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:
CustomFieldsEditorfetches/field-definitions/for the entity type and renders text/number/date/choice inputs honoringrequiredand the locale label; wired into the deal and company create forms, saved via the normal PATCHcustom_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:DealContactRolesin 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 deterministicDeal.amountrollup. UI shipped 2026-06-16, Phase B web:DealLineItemseditable table with add/edit/delete rows, serverline_totalper 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:
Dealgainsdeposit_amount,deposit_due_date,deposit_paid,kickoff_date(thi công),handover_date(nghiệm thu) — all writable viaDealSerializer. An open deal with an overdue UNPAID deposit (deposit_due_date < today ∧ ¬deposit_paid) now surfaces in the morning Brief as a high-prioritydealBriefItem (suggestiondeal_deposit_overdue, “thu cọc từ {company}”), ranking first among stuck-deal reasons; derivedis_deposit_overdueproperty exposed read-only. Web milestone/deposit form UI shipped 2026-06-16:DealDepositsection onDealDetailPanel— amount/due-date/paid + kickoff/handover dates, view→edit→save viauseUpdateDeal, red overdue treatment + kanban “Deposit overdue” chip whenis_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-onlymentions= list ofOrganizationMemberUUIDs; 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@Nameand records the member id; the chosen ids are sent asmentions[]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, defaultshared). The comment LIST is org-scoped first, then filtered — managers/admins/owner see every comment; everyone else seessharedcomments plus their ownprivateones. Deletion stays author-only. Web shipped 2026-06-16, U11: the composer has a shared/private toggle (default shared) sent asvisibility; the list renders a “Private” badge on private notes and a muted origin badge for non-userorigins (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_categoryfield +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.tsxsets 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.editauthority 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.
- Native mobile contract: shared Android/iOS Deal Detail shows the server-authored forecast category in the first operating viewport. Users with effective
- 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/iscurrency_mode: per_currencyand never merges currencies;/api/deals/analytics/gainedby_currency+weighted_by_currencybreakdowns alongside the legacy merged keys. Web shipped 2026-06-16:ForecastViewrenders each currency on its own line andPipelineStatsswitches to per-currency stat lines when more than one currency exists — never a merged total. Tested infrontend/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 yetcopy. - 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.
- 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
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 conversionbasisnote 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/gainedupdate_close_date,update_pipeline,update_forecast_category,update_custom_field— each tenant-scoped, param-validated (400 on missing/invalid), with a per-dealDealActivity. Probability/labels remain stage-derived / out of scope. Web UI shipped 2026-06-16, Phase D:DealsBulkBarmulti-select toolbar — action picker + per-action input (date picker / pipeline select / forecast select / custom-field key+value), with partial-failureerrors[]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:
DealsBulkBarkeeps 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.tsxPeople tab inCompany360—useCompanyContacts(companyId)(TanStack Query,?company=scoped), add-person form POSTs withcompanyprefilled, 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-classemailtimeline event, alongside the already-shipped mergedDealActivityaudit events,ConfirmationRequestresponses, confirmation response uploads, and task-file attachments. Meetings continue to flow throughCRMActivity. 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:
/contactsstandalone contacts workspace — searchable/filterable contact list, detail pane selected via?contact=<id>, create/edit/delete contact form, and aNew deal from contactaction. That action routes to/deals?new=1&company=<companyId>&contact=<contactId>;DealsPageconsumes both params so the create-deal form preselects the company and, afterPOST /api/deals/, immediately attaches the source contact asdecision_makervia/api/deals/<id>/contacts/. Tests:frontend/src/test/ContactsPage.test.tsx,frontend/src/test/ContactDealPrefill.test.tsx, route wiring infrontend/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: shareduseCrmNewDealShortcutbound on/deals,/companies, and/contacts; it routes to/deals?new=1and 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;CompaniesPagewires 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 byname_normalized(existing →skipped, reuses id; else create, defaultis_customer=true); contacts/deals resolve theircompany_namewithin the org (unknown → row error, never auto-created); deals resolve pipeline (id-or-name, else first active) + stage (name, else first by order).dry_runrolls back a savepoint and writes nothing. All resolution tenant-scoped (Rule 1). Web shipped 2026-06-16, U3:CrmImportPageat/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→ POSTdry_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.xlsxparsing is deferred (noxlsx/papaparsedep) — 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.visibilitysupportsshared|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/PipelineViewSetreads stay open to members; structural writes require admin/owner viaIsPipelineStructureAdmin, returning 403 withdetail“Only an admin can change pipeline structure.” Web UI shipped 2026-06-16, Phase D:CrmSettingsPagereads the org role; non-admins see an explanatoryinfonotice and all structural controls (create/rename pipeline, add/save/reorder/delete stage) render disabled with a tooltip; a 403 still surfaces the serverdetailin 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_amountrollsDeal.amount= Σline_totalon every line-item create/update/delete; a manual amount with no line items is never overwritten. UI shipped 2026-06-16, Phase B web:DealLineItemstable 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-fetchesline_totalfrom 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, defaultservice) and/api/deals/<id>/line-items/now returns bothitemsandvalue_mix(amount + percentage per category).DealLineItems.tsxrenders 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-scopedname/sku/description/unit_price/is_active, conditional(organization, sku)unique constraint (blank SKU allowed),(organization, is_active, name)index;/api/products/ModelViewSet — reads open toIsOrgMember, writes gated byIsPipelineStructureAdmin,?active=true|falsefilter. Web shipped 2026-06-17:ProductCatalogSectionon/crm/settings(admin-gated, mirrors the custom-fields section) for create/edit/delete;DealLineItemsgains 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.tsxadds 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 printlayout keyed offhtml[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 theConfirmationRequest.shared_summarysnapshot — 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, aDealActivitywithorigin="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 F71SendConfirmationDialogwithsubjectType="deal",subjectId={deal.id}, the company name pre-filled as the external party label and roleclient; on create it reveals the copyablepublic_urlso the owner can send it via Zalo/email. No F71 surface rebuilt.)
- 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:
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
| Capability | Pipedrive / top-CRM pattern | Lumi current state | Product decision |
|---|---|---|---|
| Multiple deal views | Board, list/table, calendar, forecast/report views | board, table, calendar, timeline | keep and deepen |
| Scheduled activities | first-class calls/meetings/tasks with due dates and next-step discipline | missing | high-priority gap |
| Deal history | structured activity feed, often field-level audit on key changes | strong partial DealActivity | broaden change coverage |
| Deal-contact roles | several contacts tied to one deal, each with a role | missing | important gap |
| Products / line items | products or line items per deal | missing | important gap for quotes/proposals |
| Custom fields in UI | admin-defined fields rendered and editable in record forms | backend-only validation, weak UI exposure | close the gap |
| Saved filters/views | named saved views, often sharable | missing | add if the pipeline becomes heavier operationally |
| Forecast categories | commit / best case / pipeline / omitted | missing | important gap for managerial use |
| Conversion analytics | stage conversion, duration, owner performance | missing beyond weighted pipeline | phase 2 |
| Automation | workflow automation / reminders | missing | only after core CRM truth is solid |
| Account timeline | unified company history across events | good partial | extend with more event types |
| Permissions | role-sensitive editing / visibility on notes and records | basic tenant and owner semantics | needs tightening |
| Contact roles on deals | decision-maker / champion / site contact semantics | missing | high-value contractor CRM gap |
| Products / catalog | reusable products and quote structure | ProductCatalogItem + /api/products/ + settings UI + line-item prefill shipped (web, 2026-06-17) | shipped; native shells pending |
| Forecast semantics | clear forecast categories and period rollups | missing | important manager-grade gap |
| Saved operational views | named views for stale, overdue, closing-this-week, etc. | missing | needed 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:
-
Every open deal has a clear next action or is visibly unowned
-
Every manager can trust pipeline state without calling around
-
Every company record can tell the relationship story in one place
-
Revenue discussions are based on structured deal evidence, not memory
-
The CRM gets better than Excel at the exact workflows this contractor actually runs
-
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:
- A next step for every open deal. Missing activity is a defect.
- Records that survive staff turnover. Contacts, quote structure, activity history, and ownership retain knowledge.
- Repeatable management slices. Saved views, forecast categories, stale filters, and close-window slices answer recurring queries.
- Explainable revenue. A deal without line items, a role map, or a next step is only a hopeful number.
- 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_fieldsContact: company, phone, zalo, email, title, custom_fieldsStage: 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-inamount/expected_close_date/ownercheck the deal column, any other key checksDeal.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-onlyis_deposit_overdueproperty (open ∧ ¬paid ∧ due_date < today — never stored, mirrorsCRMActivity.is_overdue), custom_fields. Migration0013_deal_deposit_amount_deal_deposit_due_date_and_more. All five fields auto-exposed writable byDealSerializer(__all__);is_deposit_overdueadded as aReadOnlyField.DealActivity: action + metadata JSON,origin(F50 U11, 2026-06-16 — provenance:user/system/import/confirmation_link, defaultuser; sharedORIGIN_CHOICES)DealComment: linear comments +visibility(F50 U11, 2026-06-16 —shared/private, defaultshared; private notes visible only to author + managers/admins/owner) +origin(sameORIGIN_CHOICES, defaultuser). Migration0014_dealactivity_origin_dealcomment_origin_and_more.
4.2 Confirmed current model strengths
Stageis 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()
overdueis derived (is_overdue), not a stored status, so the clock never goes stale.- Constraint: at least one of
deal/company/contactmust be set (enforce in serializer). - Brief-glue (F12): an open deal with no
scheduledfuture 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_idunless 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.amountfrom the sum ofline_totalon write; when absent,Deal.amountis entered directly. Never silently overwrite a manual amount with an empty line-item set. Each line item also carries a lightweightvalue_categoryso 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
Leadentity 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
| Method | Path | Auth/Role | Purpose |
|---|---|---|---|
| CRUD | /api/companies/ | member / viewer read | company records |
| GET | /api/companies/<id>/signals/ | viewer | company 360 aggregated signals |
| GET | /api/companies/<id>/timeline/ | viewer | merged company timeline |
| GET | /api/companies/unresolved/ | viewer | unresolved extraction/company strings |
| POST | /api/companies/resolve/ | member | one-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 read | contacts. 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/ | admin | custom field definitions |
| CRUD | /api/pipelines/ | member / viewer read | pipelines |
| CRUD | /api/stages/ | member / viewer read | stages. 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 read | deals. 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/ | member | position within stage |
| POST | /api/deals/<id>/move/ | member | stage/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/ | viewer | pipeline 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/ | member | set forecast bucket (commit/best_case/pipeline/omitted); invalid choice → 400 (F50 Phase C) |
| GET | /api/deals/forecast/ | viewer | per-currency forecast rollups by category + weighted pipeline; ?pipeline=&period=YYYY-MM; never merges currencies (F50 Phase C) |
| GET | /api/deals/conversion/ | viewer | stage progression counts, avg stage duration, win-rate by owner + by pipeline; ?pipeline= (F50 Phase C) |
| GET | /api/deals/aging/ | viewer | per-stage stale counts (deals past stage.expected_days) + avg days in stage; ?pipeline= (F50 Phase C) |
| GET | /api/deals/<id>/activities/ | viewer | deal activity timeline |
| GET | /api/comments/?target_type=deal&target_id=<deal-id> | member / viewer read | Deal 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/ | member | create 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>/ | author | edit own comment |
| DELETE | /api/comments/<id>/ | author | delete own comment |
| POST | /api/comments/<id>/resolve/ | member | resolve/unresolve a root comment ({resolved: bool}) |
| POST | /api/deals/bulk/ | member | bulk 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/ | viewer | flat read-only activity feed |
| CRUD | /api/crm-activities/ | member | scheduled 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/ | member | mark activity done (sets status=done, completed_at=now) |
| GET/POST | /api/deals/<id>/contacts/ | member / viewer read | list 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>/ | member | remove a deal-contact role |
| GET/POST | /api/deals/<id>/line-items/ | member / viewer read | list 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>/ | member | edit / 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 read | Bá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/ | member | named 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/ | member | Bulk 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/ | member | Bulk 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/ | member | Bulk 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:
| Method | Path | Purpose |
|---|---|---|
| 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_overdueis computed server-side fromdue_atvs now, so clients never disagree on staleness.- Activity write requires at least one of
deal/company/contact; reject with400otherwise. - Saved views are tenant-scoped;
is_shared=trueexposes them org-wide read-only, edits stay withowner. | 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.tsxCompaniesPage.tsxfrontend/src/pages/crm/ViewSwitcher.tsxTableView.tsxCalendarView.tsxTimelineView.tsxPipelineStats.tsxDealHealthBadge.tsxDealDetailPanel.tsxDealTimeline.tsxCompanyTimeline.tsxCompanyPeople.tsx(2026-06-16 — People tab inCompany360: 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/useDeleteContactinuseDealStructure.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 intoApp.tsx). Hosts pipeline + stage config and (2026-06-16, U1) a Custom Fields section: define/edit/deleteFieldDefinitions per entity_type (company/contact/deal) — key (slug-ified), en/vi label, type, required, and a chip choices editor forchoice— admin-gated viacanEditStructure, reusing the page’s permission notice + 403 toast handler. HooksuseCreate/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á fromGET /deals/<id>/quote/— company + title, line-items table, subtotal/discount/tax/total in the deal currency — with a Print button (scoped@media printlayout) and a “Send for confirmation” button that reuses the F71SendConfirmationDialogwithsubjectType="deal")components/crm/CustomFieldsEditor.tsx(F50 Phase B, 2026-06-16 — renders admin-defined custom fields perfield_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, honestbasisnote; 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
-
No next activity discipline
- This is the biggest practical miss relative to top CRMs.
-
Deal records are still thinner than quote-driven sales work needs
- No contact-role assignment
- No line items/products
- Limited quote structure
-
Saved operational views are missing
- Managers often need “my overdue closes”, “stale >14d”, “this week’s deposits”, etc.
-
Custom fields are not a real user-facing power yet
- Backend validation exists, but that is not the same as a useful admin UX
-
Deal records lack commercial anatomy
- A total amount without line-item structure is too thin for serious quote-driven selling
-
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:
-
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 compactNextActivityIndicator. (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
-
Saved views bar
- examples:
My overdue,Closing this week,No next activity,Stale >14d,Deposit expected
- examples:
-
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 theuseCompleteCrmActivity/useUpdateCrmActivityhooks.
- 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
-
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 (hooksuseDealConversion/useDealAgingshipped against the backend, no dedicated UI yet).
-
Stage policy / required-field config + board reason (U7) — ✅ shipped 2026-06-16
- Config:
CrmSettingsPageStageRowcarries 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 existingonSave(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 genericerror_move.
- Config:
-
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
.csvfile upload; (2) column mapping — one shadcnSelectper 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, POSTsdry_run:true, shows created/skipped/error counts + a per-row results table (status badge + detail/error); (4) import — POSTsdry_run:false, shows the final summary and offers “Import another”. Parsing is dependency-free (src/lib/parseTable.ts);>1000rows blocks the import with a clear message. Loading (button-pending), empty (“no rows found”), and error (toast) states throughout. Native.xlsxparsing is deferred (noxlsx/papaparsedependency) — paste-from-Excel yields TSV and covers the migration path.
- A calm 4-step flow: (1) pick entity (company / contact / deal) + provide data via a paste textarea (TSV from Excel) or a
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:
dealscompanies
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.pyTestCompanyTestContactTestCustomFieldsTestCompany360TestPipelineTestDealTestStageViewSetTestDealTerminalStateTestNameResolutionTestExtractionResolutionTestUnresolvedNamesTestCompany360FullTestBriefDealItemsTestDealActivityTestDealAnalyticsTestStageFields
backend/tests/test_crm_extensions.pyTestDealActivityTestDealActivityOrigin— deal-activity provenance (origindefaultsuser, persists non-user values; replaces the removedTestDealCommentafter the 2026-06-21 comment consolidation)TestDealAnalyticsTestDealReorderTestDealActivityOwnerAndStatusTestCompanyTimelineTestBulkOperationsTestCoverageEdgePaths
backend/tests/test_crm_activities.py(F50 Phase A — next-step engine)TestCRMActivityModel—is_overduetrue/false/no-due/done,__str__TestCRMActivityCreate— create against deal/company/contact, at-least-one-of 400, owner default + explicit +owner_nameTestCRMActivityCrossTenant— cross-tenant deal/company/contact rejection, list isolation, retrieve 404TestCRMActivityFilters— overdue/upcoming/status/activity_type/deal/company/contact/owner filters, ordering by due_atTestCRMActivityComplete— complete action sets done+completed_at, cross-tenant 404TestCRMActivityUpdateDelete— patch subject, deleteTestDealNextStepFields—next_activity_at/has_overdue_activity/has_open_activityannotation values + serializer fallback paths
backend/tests/test_crm_forecast.py(F50 Phase C — managerial truth)TestForecastCategoryField— defaultpipeline, serializer exposes + writableTestForecastCategoryAction— PATCH valid (200,{id, forecast_category}), invalid choice 400, missing field 400, cross-tenant 404TestForecastEndpoint— 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-tenantTestConversionEndpoint— win-rate by owner/pipeline math, stage duration from activity, stage progression, pipeline filter, cross-tenantTestAgingEndpoint— stale count pastexpected_days, avg days in stage, open-only, pipeline filter, cross-tenantTestAnalyticsCurrencyHonesty— legacy keys preserved +by_currency/weighted_by_currencyadded, cross-tenant
backend/tests/test_crm_deal_structure.py(F50 Phase B — deal structure)TestDealLineItemModel—line_totalmath (qty×price×(1−disc)×(1+tax)), no-disc/no-tax, ordering bysort_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 bysort_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 nameTestSavedDealViewCRUD— 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 allowedTestSavedDealViewCrossTenant— list isolation, retrieve/update/delete other-org 404TestBulkUpdateCloseDate/TestBulkUpdatePipeline/TestBulkUpdateForecastCategory/TestBulkUpdateCustomField— happy path + per-dealDealActivityaudit, missing/invalid params 400, cross-tenant 404;update_pipelinekeeps a compatible stage vs lands on first stage, empty-pipeline 400, cross-org pipeline 404TestStageConfigPermission/TestPipelinePermission— member reads allowed; member structural writes blocked 403 with explanatorydetail(“admin”); admin/owner writes allowed
7.2 Required test matrix
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Deal lifecycle validation (won/lost requirements, stage move stamping) | backend | existing CRM suites |
| 2 | Reorder within stage | backend | TestDealReorder |
| 3 | Deal analytics totals and by-stage stats | backend | TestDealAnalytics |
| 4 | Comment create/delete rules | backend | backend/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 |
| 5 | Bulk operations tenant safety and per-deal audit logging | backend | TestBulkOperations |
| 6 | Company timeline merge + pagination cap | backend | TestCompanyTimeline |
| 7 | Custom fields validation | backend | TestCustomFields |
| 8 | Name resolution and unresolved names | backend | TestNameResolution, TestUnresolvedNames, TestExtractionResolution |
| 9 | View switcher across four views | e2e | CRM views block |
| 10 | Board reorder flow | e2e | CRM board block |
| 11 | Deal detail comments + activity render | e2e | deal detail block |
| 12 | Table export | e2e | table block |
| 13 | Scheduled CRM activity workflow | backend + e2e | required when implemented |
| 14 | Deal-contact roles | backend + e2e | required when implemented |
| 15 | Deal line items/products | backend + e2e | required when implemented |
| 16 | Forecast-category and saved-view semantics | backend + e2e | required when implemented |
| 17 | Permission-denied UX for restricted CRM actions | e2e | required when implemented |
| 18 | Mixed-currency analytics and forecast honesty | backend + e2e | required when implemented |
| 19 | No-next-activity warning semantics | backend + e2e | required when implemented |
| 20 | Native deal-stage route, returned truth, required-field explanation, role denial, and UI wiring | KMP API/model/source | F50.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
| # | Scenario | User-visible | Internal | Notes |
|---|---|---|---|---|
| 1 | Bulk operation partially succeeds | partial-success summary | per-deal errors returned | current contract supports this |
| 2 | Concurrent reorder conflict | board order may drift until refresh | position conflict risk | top-CRM polish would need stronger reconciliation |
| 3 | Comment deletion by non-author | 403 | permission check | shipped |
| 4 | Invalid or missing bulk params | 400 | request validation | shipped |
| 5 | Stage delete with active deals | blocked | referential protection in logic | shipped from F27 foundation |
| 6 | Mixed-currency analytics interpreted as one currency | misleading totals | insufficient normalization semantics | open managerial trust issue |
| 7 | Open deal with no next activity | currently not a first-class error | product gap, not code exception | benchmark-critical gap |
| 8 | Custom fields defined but not operable in UI | backend accepts schema but user cannot fully leverage it | product gap | open |
| 9 | Deal has several real stakeholders but only one contact can be operationally tracked | users fall back to notes or external docs | missing relationship structure | benchmark-critical gap |
| 10 | Large quote represented as one number only | value becomes opaque and hard to challenge | missing line-item structure | serious sales-ops gap |
| 11 | Restricted action silently unavailable | confusing UX | permission surface under-specified | open |
| 12 | Marking the next activity done fails on a poor connection | the current next activity remains visible, the failure is explained inline, and the exact activity can be retried without reselecting it | completion request must retain its target until success | shared 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
-
The CRM data spine is real
- Companies, contacts, pipelines, stages, deals, activities, comments, timelines, and custom field definitions all exist as first-class records.
-
Pipeline mechanics are stronger than a simple board
- Position ordering, stage aging, probabilities, analytics, and bulk ops are already beyond “good enough CRM.”
-
Company relationship memory is a real differentiator
- The company timeline and 360 view already make Lumi more relational than many lightweight CRMs.
-
The implementation is grounded in tenant safety
- Cross-org scoping is present across the surface.
Where spec and code disagree, ordered by priority
-
[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.
-
[P0] Deal records are too thin for quote-driven work
- No deal-contact roles
- No line items / products
- No structured quote composition
-
[P1] No forecast categories
- Weighted pipeline exists, but managerial forecasting semantics are still primitive.
-
[P1] Saved views are missing
- Multi-view exists, but repeatable operational filtering is not yet first-class.
-
[P1] Custom field UX lags backend support
- Validation exists; true business usability is weaker.
-
[P2] Analytics are useful but not yet managerial-complete
- No conversion analytics
- No stage-duration rollups
- No owner performance matrix
-
[P2] Mixed-currency revenue truth is still thin
- Better than before, but totals and forecasting semantics remain under-specified for heterogeneous currencies.
-
[P2] Permission semantics are still too implicit
- The CRM should make ownership and action authority legible, not merely enforce them in scattered ways.
-
[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)
- ✅ (backend, 2026-06-16)
CRMActivitymodel +/api/crm-activities/CRUD (see §4.3, §5.4). Model with derivedis_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),completeaction, owner-defaults-to-requester.DealSerializerexposes annotatednext_activity_at/has_overdue_activity/has_open_activity(N+1-safe). Migration0008_crmactivity. Tests:backend/tests/test_crm_activities.py. - ✅ (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;NextActivityIndicatorshows a compact due-date / overdue chip on kanban cards and the table row. Hooks:useCrmActivitiesfamily (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. - ✅ (web, 2026-06-16) “No next activity” warning state on every open deal (§2.3, §6.6).
NextActivityIndicatorreadshas_open_activityand shows an amber shared warning badge on cards/rows; the detail panel shows an amber-railed warning + Schedule CTA. (iOS/Android shells: open.) - ✅ (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 withhas_open_activity === false→ theno_activityhealth state.DealHealthBadge(frontend/src/pages/crm/DealHealthBadge.tsx) gainedhasOverdueActivity/hasOpenActivityprops 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;getDealHealthcontract mirror).- ✅ Daily-Brief glue (F12) — shipped (2026-07-02). Stuck open deals (overdue scheduled activity, no scheduled activity, or overdue unpaid deposit) surface as
dealBriefItems ingenerate_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_dealFK 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.)
- ✅ Daily-Brief glue (F12) — shipped (2026-07-02). Stuck open deals (overdue scheduled activity, no scheduled activity, or overdue unpaid deposit) surface as
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.”
| Dimension | Pipedrive-class bar | Lumi now | Target | Gap |
|---|---|---|---|---|
| Pipeline views | 5 | 5 | 5 | kanban/table/calendar/timeline + saved views shipped on web; native shells pending |
| Pipeline mechanics | 5 | 4 | 5 | stage policy, cross-pipeline rules |
| Next-step activities | 5 | 5 | 5 | backend 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 / audit | 5 | 3 | 4 | broaden field-level coverage + provenance |
| Deal-contact roles | 5 | 3 | 4 | DealContactRole 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 items | 5 | 4 | 4 | DealLineItem 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. |
| Forecasting | 5 | 2 | 4 | forecast categories + rollups (Phase C) |
| Conversion analytics | 5 | 4 | 4 | backend /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 views | 5 | 5 | 5 | SavedDealView 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 operations | 5 | 5 | 5 | wider 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 timeline | 5 | 5 | 5 | explicit CRM-tracked email follow-ups now render as first-class timeline events; general mailbox sync remains out of scope |
| Permissions legibility | 5 | 5 | 5 | stage/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.
| # | Item | Why undeniable | Current state | The move | Cross-link |
|---|---|---|---|---|---|
| U1 | Custom-field configuration UI in CRM Settings | Custom 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) | |
| U2 | Pipeline 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) | |
| U3 | Excel/CSV import of companies + contacts + deals | The 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 |
| U4 | Báo giá (quote document) rendered from line items + F71 confirm | The 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 |
| U5 | Deposits & 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 |
| U6 | Native mobile parity for the core act-flow | The 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/remove | F30, F47 |
| U7 | Stage policy / required-field enforcement on stage move | Manager 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 |
| U8 | One-tap unresolved-name → company + deal | Turns 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 |
| U9 | Activity agenda / “my day” inbox | The 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) |
| U10 | Server-side saved-view filters | Repeatable 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-side | Push filter evaluation to /api/deals/ query params | §2.1 |
| U11 | Collaboration that survives turnover: @mention · note visibility · provenance | The 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
-
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.
-
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.
- My judgment: lightweight line items belong in CRM if they directly explain
-
Should forecast categories be manual or rule-derived?
- My judgment: manual first. Rule-derived categories create false certainty too early.
-
Should contacts become more central inside the deal before email integration exists?
- Yes. Deal-contact roles are valuable even without an email stack.
-
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.
-
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.
-
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.yamlhealth 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 --checkPYENV_VERSION=system python3 scripts/check_docs_truth.pybash 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 universalCommentmodel (F81). §13 entry annotated to reflect deletion; comment behaviour now lives insrc/components/comments/CommentThread.frontend/src/test/DealCommentComposer.test.tsx(same §13 entry) — deleted with the component. §13 entry annotated; coverage moved tofrontend/src/test/CommentThread.test.tsx.frontend/src/pages/DealsPage.tsx(§13 entry 2026-06-16 health-redesign) — page moved tofrontend/src/pages/business/DealsPage.tsxduring 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
Commentmodel. TheDealCommentmodel was deleted in migration0018_alter_crmactivity_activity_type_delete_dealcomment.py(2026-06-21) and its rows backfilled intoapps/comments(migration0003). §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>/, andPOST /api/comments/<id>/resolve/. Contract verified againstapps/comments/urls.py+apps/comments/views.py. - Provenance clarified (§4.3(6)). The full
user\|system\|import\|confirmation_linkvocabulary lives onDealActivity; the universalCommentuses the narroweruser\|system\|ai. Investigation confirmed nothing creates aCommentwithimport/confirmation_linkorigin —apps/comments/views.pycoerces out-of-vocab origins touser; the F71 confirm/decline path (apps/confirmations/views.py) writes aDealActivitywithorigin="confirmation_link", not aComment; and the backfill migration collapsed legacyimport/confirmation_linkcomment origins tosystem. No code gap. - §11 U11 audit row annotated with the 2026-06-21 consolidation; stale
DealCommentmodel references pointed at the universalComment. - Test-class references corrected.
TestDealComment(§7 test plan + §7.2 matrix row 4) andTestDealCommentCollaboration(§13 historical entry) no longer exist — they were removed withDealComment. Deal-activity provenance is nowtests/test_crm_extensions.py::TestDealActivityOrigin; comment CRUD/visibility isbackend/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) asdealBriefItems withrelated_dealprovenance. Tests inbackend/tests/test_brief_deals.pyconfirm 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:
NextActivityIndicatorno-next/overdue chips,DealHealthBadgeoverdue/no-next badges, deal-detail no-next warning panel, and deal-detail Mark Won / Mark Lost terminal actions. - Reclassified
has_overdue_activityfrom redcriticalcard health to amberstalehealth. 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, andDealDetailPanel.test.tsxso overdue/no-next states do not regress tored-softand 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_VISIBILITYregisters 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, andcrm_pipeline_<id>automation seams, a two-pipeline JVM regression, and the lifecycle-owned.maestro/critical/109_android_deals_pipeline_scope.yamlproof 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.companyid 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.dealCardsResolveCompanyNameAndKeepItCompactand 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 compactdd/MM/yyyymetadata 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.
LumiDatesTestandDealsScreenDateSourceTestcover the formatter and presentation boundary; F50 proofF50.AC005.DATE_COPYrecords 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 was0: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-detail503s and two bounded activity-list503s each preserved the titled record and scheduled next activity; explicit Retry reached forwarded200responses, fixture cleanup reached0:0:0:0, proof ports closed, and the sole emulator shut down. Retained failure/recovery frames and the request trace live underdocs/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/DealDetailScreenbehavior exactly. - DTOs (
android/shared/.../api/LumiDtos.kt): extendedDealResponsewith the three U6DealSerializerSerializerMethodFields —next_activity_at(ISO string, nullable),has_overdue_activity,has_open_activity(bool) — plus computedisOpen/needsAttentionmirroring iOS. NewStageResponse(id/name/order/probability/expected_days),PipelineResponse(id/name/is_active/nestedstages), andCRMActivityResponse(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 againstbackend/apps/crm/serializers.py(DealSerializer,StageSerializer,PipelineSerializer,CRMActivitySerializer). - API client (
LumiApiClient.kt):deals(pipeline)gained the?pipeline=filter; newpipelines()→/api/pipelines/,dealActivities(dealId)→/api/crm-activities/?deal=<id>,completeActivity(id)→POST /api/crm-activities/<id>/complete/. All reuse the existingauthHeader()/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 forcommonTest. - Screen-model trio: extended
CrmScreenModel/CrmRepository(newpipelines/deal/dealActivities/completeActivitymethods;dealGroupsderived state; best-effortloadStages), and addedDealDetailScreenModelfollowing the F49TaskDetailScreenModelpattern (independent deal/activities load,nextActivity,completeActivity→ refresh). - Screens (
shared/.../ui/crm/): rewroteDealsScreento group by stage with aNextActivityIndicator(redDanger“Overdue” badge / amberWarning“No next activity” badge / else next due or close date) and a per-deal tap; addedDealDetailScreen(status/amount/stage header + “Next activity” card withMark done+ the remaining activities). Lumi primitives + tokens only (no raw colors). - Navigation (
android/app/.../ui/LumiApp.kt,SharedKmpViewModel.kt, newDealsNavKey/DealDetailNavKey): “Deals” in the Business + Workspace lanes now routes to a dedicatedDealsScreen(Companies stays onCrmNavKey); a deal tap →DealDetailNavKey; the search deep-linkdealroute 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.ktmirrorsCrmDealTests.swift—needsAttentionclassification (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 theDealDetailScreenModelload + mark-done flow. ExistingCrmScreenModelTestfake updated to the widened repository contract. - Build verification — BLOCKED (environment). The Android Gradle build cannot run here: AGP artifacts from
dl.google.comare denied by the egress policy, so:shared:jvmTest/assembleProdDebugcould 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 everyRes.string.*key confirmed to exist in the shared module (en+vi); (c) the screen-model trio mirrors the shippedTaskDetailScreenModel/ChaosImportScreenModellifecycle; (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:jvmTestrun 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+Dopens 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 carryingcompany/contactsearch params forward. - Safety rule: the shortcut is ignored while focus sits inside
input,textarea,select, orcontenteditable, 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,amountstring-Decimal,currency,stage,status,next_activity_at,has_overdue_activity,has_open_activity; computedneedsAttention),PipelineResponse+ nestedStageResponse,CRMActivityResponse(activity_type,subject,due_at,owner_name,status,is_overdue). Decoded with the shared.convertFromSnakeCasedecoder; 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 genericget/posthelpers (auth +X-Organization-IDhandled byAPIClient; 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” vianeedsAttention, else the next due date).DealDetailScreen— status/amount/stage header, a “Next activity” card (soonest scheduled activity) with a Mark done button that callscompleteActivityand refreshes, plus the remaining activities (scheduled first). Both screens use the fourLoadablestates + pull-to-refresh permobile-ux-patterns.md. PureDealsViewModelholds the grouping / next-activity / needs-attention logic. - Navigation: new
Dealsrow in the Workspace manifest’s Company group (ios/Lumi/UI/MainTabView.swift) — reachable in 2 taps (Workspace → Deals).RuleOfTwoTestsupdated: 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),needsAttentionclassification (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).
xcodebuildcould 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/(typedDealQuoteintypes/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 toDealDetailPanel(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 viaformatCurrency. 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 callswindow.print()after settinghtml[data-quote-printing]; a scoped@media printblock inindex.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) withsubjectType="deal",subjectId={deal.id}, roleclient, and the company name pre-filled as the external-party label (new optionaldefaultPartyLabelprop, backward-compatible). On create it reveals the copyablepublic_url. POSTs via the existingconfirmationsApi.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.tsdeals parity stays green. - Tests:
frontend/src/test/F50Quote.test.tsx— quote dialog renders line items + the total from a mockeddealsApi.quoteresponse; empty line items → the hint; “Send for confirmation” callsconfirmationsApi.createwith{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 --noEmitclean;F50Quote.test.tsx+F71Confirmation.test.tsx+i18n.test.tsall green (8338 tests pass, incl. deals parity across all 56 languages);check-design-tokens.mjsclean (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_summaryasshared_fieldson 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)onDealViewSet(apps/crm/views.py,IsOrgMember, tenant-scoped viaget_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 bybuild_deal_quotein the newapps/crm/quote.py. All money Decimal-exact, quantized 2 dp (HALF_UP), in the deal’s currency; per-line breakdown mirrorsDealLineItem.line_totalso totals reconcile exactly. Empty line items → all totals"0.00"+ the deal’s storedamount. - Confirmation glue (summary):
build_deal_quote_summary(deal)(apps/crm/quote.py) returns theConfirmationRequest.shared_summarysnapshot —{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 asshared_summaryon F71 confirmation create. The F71 create serializer was not modified — it always builds a non-emptyshared_summaryfromshared_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/declinedConfirmationRequesthas adealsubject, log aDealActivity(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’sorganization_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 → oneDealActivity origin="confirmation_link"; non-deal subject → none). Suite-k "crm or confirmation": 474 passed.apps/crm/quote.py+apps/confirmations/views.py100% 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”, lucideFileUp, newsidebar.crm_importkey). - Parser:
frontend/src/lib/parseTable.ts— a small, dependency-free CSV/TSV parser (noxlsx/papaparseinstalled). 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 shadcnSelectper target field, case-insensitive auto-match, required-field guards); (3) dry-run preview (useCrmImport(entity)→ POSTdry_run:true) showing created/skipped/error counts + a per-row results table; (4) real import (dry_run:false) with a final summary + “Import another”.>1000rows blocks import with a clear message. Loading/empty/error states throughout. - API/hook:
companiesApi.import/contactsApi.import/dealsApi.import(POST/{entity}/import/) added tofrontend/src/api/endpoints.ts;CrmImportRequest/CrmImportResponse/CrmImportEntitytypes infrontend/src/types/index.ts;useCrmImport(entity)mutation infrontend/src/hooks/queries/useCrmImport.ts(invalidates the entity’s lists after a real import). - Deferred: native
.xlsxparsing (no dependency available offline) — paste-from-Excel yields TSV and covers the migration path. - i18n: 61 new
companieskeys (import_*) + 1 newsidebarkey (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) andfrontend/src/test/CrmImportPage.test.tsx(3 cases — paste → map → dry-run preview shows counts then import calls the API withdry_run:false; row errors surfaced; empty paste toasts an error). - Diagrams:
frontend/DIAGRAMS.md— added/crm/importto 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): always200with{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_runopens a savepoint and rolls it back, so nothing is written and every created row reportsid:null. - Companies: dedup by
name_normalizedwithin the org → an existing match isskipped(reuses its id), else created via the existingCompanySerializer(defaultis_customer=truewhen no relationship flag given). - Contacts: resolve
company_nameby normalized name within the org (never auto-created — company import runs first); unknown company → row error"Unknown company '<x>'". Created viaContactSerializer. - 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, stampingstage_changed_at. Invalidamount→ row error. - Tenant isolation (Rule 1): every lookup and create is scoped to
request.organization_id; acompany_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
DealCommentComposercomponent (frontend/src/pages/crm/DealCommentComposer.tsx— deleted 2026-06-21; deal comments now use the universalCommentThreadcomponent fromsrc/components/comments/) replaces the single-line comment input onDealDetailPanel. - @mention autocomplete: typing
@in the textarea opens a member popover (active org members, filtered by typed text, anchored to the textarea); selecting one inserts@Nameand records the member id. Ids whose@Nametoken survives in the final body are sent as the write-onlymentions[]field.Entersubmits (when no mention popover is open);Shift+Enternewlines. - Visibility toggle: a shared/private control (default shared) sends
visibilityon submit. The comment list now renders a “Private” badge onvisibility==='private'notes and a muted origin badge for any non-userorigin (system/import/confirmation). - Types:
DealCommentgainsvisibility?+origin?(frontend/src/types/index.ts). The existinguseCreateDealCommentmutation +dealsApi.createCommentalready pass an arbitrary payload, somentions/visibilityflow through unchanged. - i18n: 7 new
dealskeys (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 theDealCommentComposercomponent; comment behaviour now covered byfrontend/src/test/CommentThread.test.tsx) — selecting a mention + posting callsonSubmitwith the member id inmentionsand the chosenvisibility; 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 inDealDetailPanel.
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.
DealCommentgainsvisibility(shared|private, defaultshared) andorigin(user|system|import|confirmation_link, defaultuser);DealActivitygains the sameorigin(sharedORIGIN_CHOICESinapps/crm/models.py). Migration0014_dealactivity_origin_dealcomment_origin_and_more. - @mentions:
POST /api/deals/<id>/comments/accepts a write-onlymentionsfield = list ofOrganizationMemberUUIDs (frontend resolves @name → id).DealViewSet._notify_mentionsfirescreate_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-listmentionsis 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 seessharedcomments plus their ownprivateones. Deletion rules unchanged (author-only).visibilityaccepted on create (out-of-vocab coerced toshared);visibility+originexposed read in the serializer;mentionswrite-only. - Tests: 15 new cases in
tests/test_crm_extensions.py::TestDealCommentCollaboration— mentions fire oneNotificationEventper 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 toshared; invalid visibility coerced;origindefaultsuseron comment + activity and persists a non-user value.- [Superseded 2026-07-02]
TestDealCommentCollaborationno longer exists: it was removed with theDealCommentmodel when comments were consolidated into the universal F81Comment(migration0018, 2026-06-21). Deal-activity provenance is now covered bytests/test_crm_extensions.py::TestDealActivityOrigin; comment create/delete/visibility behavior is covered bybackend/tests/test_comments.py.
- [Superseded 2026-07-02]
- Gates:
pytest tests/test_crm_extensions.py tests/test_crm.py→ 235 passed; every newmodels.py/serializers.py/views.pyline covered (term-missing shows only pre-existing untested analytics/bulk lines);ruff check --select E,F,W --ignore E501clean.
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
DealDepositcomponent (frontend/src/pages/crm/DealDeposit.tsx) renders a compact “Deposit & milestones” section between line items and comments onDealDetailPanel: deposit amount (formatted in the deal’s currency viaformatCurrency), 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 viauseUpdateDeal(PATCH with only the deposit/milestone fields; a cleared amount →null, empty dates →null). Overdue treatment: whenis_deposit_overdue ∧ ¬deposit_paid, the due date renders in--color-risk-redwith an “Overdue” chip (--color-red-softbg).DealTS 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 whereis_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.tsdeals 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 callsdealsApi.updatewith the changed fields, cleared amount →null. Mocks../api/endpointsper the repo’s reliable pattern (no MSW). - Gates:
tsc --noEmitclean;DealDeposit.test.tsx+ deals i18n parity green (only pre-existing namespace-count/tasks/projects/sidebar/hqfailures remain, not from this change);check-design-tokens.mjsclean (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). Migration0013_deal_deposit_amount_deal_deposit_due_date_and_more. All five are auto-exposed writable byDealSerializer(fields="__all__"); a derived read-onlyis_deposit_overdueproperty (open ∧ ¬deposit_paid ∧deposit_due_date < today, never stored — mirrorsCRMActivity.is_overdue) is added as aReadOnlyFieldon 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 ONEdealBriefItem (confidence 0.7,related_dealprovenance, 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 derivedis_deposit_overdue, then stage_changed_at/created_at). New templatedeal_deposit_overdueinapps/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_overdueproperty 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.TestDealBriefTemplatesextended fordeal_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_overduesaved-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.
Stagetype gainsrequired_fields?: string[](src/types/index.ts).CrmSettingsPageStageRownow 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 newcrmSettings:required_field_*keys) plus the org’s deal custom fields (fromuseFieldDefinitions('deal'), labeled by locale). Toggling marks the row dirty; Save persists through the existingonSave(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.
DealsPageshared helpersmissingRequiredFields(err)+toastMoveError(...): a drag (dealsMoveMutation.onError), a kanban “Move to” button (DealCard.moveStage), or the detail-panel move (handleMoveStage) that hits the backend 400 withmissing_required_fieldsnow toastsdeals:move_blocked(“Can’t move to {stage}: add {fields}.”) with keys mapped to localized labels (built-ins viacrmSettings, 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 genericerror_move.
- CRM Settings — required-field editor.
- i18n: 6 new keys —
crmSettings:{required_fields, required_field_amount, required_field_expected_close_date, required_field_owner}anddeals: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 keepsrc/test/i18n.test.tsall-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.mjsclean (only the 5 allowlisted LanguageSwitcher hits). Tests:src/test/StagePolicy.test.tsx(renders built-in + custom options, persists a selection viaonSave, non-admin disabled) andsrc/test/StageMoveBlocked.test.tsx(a 400 withmissing_required_fieldsproduces the specific toast; non-400 falls back to generic) — both green;tsc --noEmitzero 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)(migration0012_stage_required_fields) — a list of field keys required to enter the stage.StageSerializerisfields="__all__", so the field is writable by admins automatically via the existingIsPipelineStructureAdmingate (non-admins still 403; members read it). - Shared validator:
missing_required_fields(amount, expected_close_date, owner, custom_fields, stage)inapps/crm/serializers.pyreturns the list ofstage.required_fieldskeys not satisfied. Built-in keysamount/expected_close_date/ownercheck the deal column (missing if null); any other key is missing unless it maps to a non-empty value inDeal.custom_fields. Emptyrequired_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 raisesValidationError({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) inbackend/tests/test_crm.py— both paths blocked without the field + succeed once set, required custom field enforced, emptyrequired_fieldsnever 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’sUnresolvedStripchips now call the shippedPOST /api/companies/resolve/instead of the barecompaniesApi.create({name, is_customer:true}), which created the company but left the danglingRisk/Commitment/Taskrows 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/,ResolveUnresolvedResponsetype ({company, backfilled:{risks,commitments,tasks}, deal}), anduseResolveUnresolved()mutation invalidatingqueryKeys.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 = summedbackfilledcounts) 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
companieskeys (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.tscompanies parity green. - Tests:
frontend/src/test/UnresolvedStrip.test.tsx(4) — Create callsresolvewithcreate_deal:false; success toasts the summed linked-records count and removes the chip; + deal callsresolvewithcreate_deal:trueand toasts the deal message; error keeps the chip.tsc --noEmitclean;check-design-tokens.mjsno 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 onCompanyViewSet(IsOrgMember). Body{name, create_deal?, pipeline?}. In a singletransaction.atomic(): get-or-create theCompanybyname_normalized(reuses an existing same-normalized-name company so resolving never duplicates; new companies areis_customer=True), then backfill — set thecompanyFK on every dangling row in the org (company__isnull=True) whose name matches, acrossRisk.related_customer_name,Commitment.customer_name, andTask.customer_name. - The backfill match uses
Lower(Trim(<field>)) == name.strip().lower(), identical to howunresolvedgroups the dangling strings — so a padded value like" Mystery Corp "resolves too, and the name then drops out of/companies/unresolved/. - Optional
create_deal=trueopens anopenDealfor the company on the first stage (byorder, thencreated_at) of the suppliedpipeline(or the org’s first active, else first, pipeline), stampingstage_changed_at. If no pipeline/stage exists the deal is skipped anddealreturnsnull— 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/missingname→ 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 ofunresolved; reuse an existing same-normalized company;create_dealopens 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). Fulltests/test_crm.pygreen (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 (lucideCalendarClock). 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’smyMemberId—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, ISOdue_at), plus a “View deal” link to/deals(no/deals/:idroute exists). Loading skeletons, an error state, and a “No activities — you’re clear” empty state. - i18n: 15 new
dealskeys (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) + 1sidebarkey (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 --noEmitclean; design-tokens clean (5 allowlisted LanguageSwitcher hits only); i18n parity clean fordeals+sidebar. - Diagrams:
frontend/DIAGRAMS.mdroute 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):
DealsPagenow drives/api/deals/filtering from the active saved view instead of filtering client-side. NewbuildDealsQueryParams(infrontend/src/pages/crm/SavedViewsBar.tsx) composes the query-param object —{ pipeline, stage?, owner?, forecast_category?, health?, close_window? }, only set keys included — fromselectedPipelineId+ the activeSavedDealView.filters, andDealsPagepasses it straight touseDealsList. Pipeline is always driven byselectedPipelineId(a saved view that names a pipeline hoists it to the selector; the storedfilters.pipelineis 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_expectedhas no clean server param and stays client-side (applyBuiltInPresetinviewDeals); the now-redundant client-side filtering for the moved presets was removed so filters aren’t double-applied. Applying a view setsactiveFilters+ 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) andbuildDealsQueryParams(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 --noEmitclean; 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. ActivityAgendaPagenow uses the sharedEmptyStateprimitive with two concrete recovery actions: See team agenda switches the owner scope from Mine → All in place, and Open deals links to/dealsso 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_dealcopy withagenda_open_deals, and addedagenda_show_teamin en + vi. - Tests:
frontend/src/test/ActivityAgendaPage.test.tsxnow also asserts the empty state exposes the/dealslink and that the team-agenda action flips the header owner toggle toAll.
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. CrmImportPagenow 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.tsnow captures23-crm-importwith both the wizard overview and the mapping step. - Verification:
frontend/src/test/CrmImportPage.test.tsxgreen (3),pnpm exec tsc --noEmitclean,node scripts/check-design-tokens.mjsclean.
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. CrmSettingsPagenow uses the sharedPageHeaderand 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 getSectionHeadertreatment 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
EmptyStateprimitive instead of raw muted paragraphs. - Screenshot coverage:
frontend/tests/e2e/screenshot-flow.spec.tsnow captures24-crm-settings. - Verification:
src/test/CrmSettingsNav.test.tsx,CrmSettingsPermission.test.tsx,CrmCustomFields.test.tsx, andStagePolicy.test.tsxall green (11 tests total);pnpm exec tsc --noEmitclean;node scripts/check-design-tokens.mjsclean.
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_querysetnow evaluates the fullSavedDealView.filtersvocabulary 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 againstFORECAST_CHOICES; invalid → no filter).?health=…derived from existing state, reusing thehas_overdue_activity/has_open_activityExistsannotations:overdue=has a past-due scheduledCRMActivity;no_activity=open deal with no scheduled activity;stale=open deal whosestage_changed_atis older than the stage’sexpected_days(default 14 days when null), computed DB-side per row viaCoalesce(F("stage__expected_days"), 14) * timedelta(days=1);close_soon=open deal withexpected_close_datewithin the next 7 days.?close_window=…filtersexpected_close_date:this_week=current ISO week (Mon–Sun);this_month=current calendar month (Dec→Jan year rollover handled);overdue=< todayAND 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 newsection_pipelineSECTION_TIERSentry (types: ['deal']) placed below decisions/risks/operations, mirroring the backend ranking so a deal never visually outranks a critical risk.dealitem_typemapped to theTrendingUplucide icon (consistent with the CRM/deals nav). Link resolvergetEntityRoutereturns/dealsfor any item withrelated_deal(the board — no per-deal route exists inApp.tsx; none invented). The deal title prefersrelated_deal_title, falling back toitem.title. The deal reuses the sharedBriefItemCard— priority border,ceo_action_suggestionblock, confidence pip — no bespoke card.BriefItemtype (frontend/src/types/index.ts) gainedrelated_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 sosrc/test/i18n.test.tsall-locale parity stays green. The suggestion text itself comes from the server (not hardcoded).
- Tests:
frontend/src/test/BriefPage.test.tsx— new case asserts adealitem renders itsrelated_deal_title+ceo_action_suggestion, links to/deals, and falls under the Pipeline section. All 10 BriefPage tests +briefparity green. - Verification:
tsc --noEmit0 errors;check-design-tokensno 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
dealBriefItem, so the CEO runs the day from the Brief (F12 ∩ F50).BriefItemgainedrelated_dealFK (crm.Deal,on_delete=SET_NULL,related_name="brief_items") and a("deal","Deal")item_type. Migrationapps/briefs/migrations/0006_briefitem_related_deal_alter_briefitem_item_type.py.apps/ai/services.generate_morning_briefsection 8: tenant-scoped query ofstatus="open"deals annotatedExists(scheduled activity)andExists(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 onlyremaining_slots()so it never crowds out decisions/approvals/risks/commitments/tasks (soft cap 12 honored).- Rule-based
confidence_score=0.7(never 1.0); therelated_dealFK is the provenance — no fabricatedEvidenceSource(consistent with commitment/decision brief items). Suggestions from new_templateskeysdeal_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ờ…”). BriefItemSerializerexposesrelated_deal(id, via__all__) plus a lightweightrelated_deal_titlefor the frontend link;BriefViewSetbrief prefetch includesrelated_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.mdER gainsBriefItem }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 defineFieldDefinitions 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-basedChoicesEditor(add/remove →choices_json: string[]) shown only forchoice. - Admin gating reuses the page’s
canEditStructure; non-admins get read-only disabled controls + the existing admin-onlyAlert; a residual server 403 surfaces itsdetailvia the page’s sharednotifyErrortoast. - Empty / loading / error states for the list.
- Lists existing definitions filterable by
- Hooks (
frontend/src/hooks/queries/useDealStructure.ts):useCreateFieldDefinition,useUpdateFieldDefinition,useDeleteFieldDefinition— each invalidatesqueryKeys.fieldDefinitions.allon success (keepsCustomFieldsEditorin record forms in sync). ExistinguseFieldDefinitionsunchanged. - i18n: 35 new
cf_*keys in thecrmSettingsnamespace, 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/endpointsper the reliable CRM-component pattern) — lists field definitions; add-field callscreatewith the entered/slug-ified values; a non-admin sees disabled controls + the admin-only notice. - Diagrams:
frontend/DIAGRAMS.mdroute tree —/crm/settingsnow notes the custom-fields manager. - Backend untouched (
FieldDefinitionViewSet+fieldDefinitionsApialready shipped).
2026-06-16 — Fix: field-definition reads opened to members; writes stay admin
Type: Fix
Summary:
FieldDefinitionViewSet(apps/crm/views.py) hadpermission_classes = [IsOrgMember, IsOrgOwnerOrAdmin], so even listing field definitions required admin. The Phase BCustomFieldsEditorrenders custom fields for ALL members viaGET /api/field-definitions/, so non-admin members got a 403 and custom fields silently failed to render for them.- Mirrored the
StageViewSet/PipelineViewSetpattern: basepermission_classes = [IsOrgMember]plus aget_permissionsthat 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+fieldDefinitionsApiexist but no admin UI; U2:BriefItemhas norelated_dealFK).
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, tappabletel:/mailto:+ zalo), inline Add person form (name required; title/phone/zalo/email optional) that POSTs withcompanyprefilled, 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 invalidatequeryKeys.contacts.byCompany(companyId)(new key). Reuses the existingcontactsApi; no endpoint duplication. - i18n: 19 new
companies-namespace keys added to en + vi and seeded across all 60 other locales (English fallback) to keepsrc/test/i18n.test.tsparity 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-existingCompanyTimeline.test.tsxfails identically; not introduced by this change. tsc surfaces zero errors in the changed files;companiesi18n parity is clean;check-design-tokensshows 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.tsxand routed it at/contactsfromfrontend/src/App.tsx; Business navigation now includes Contacts inSidebar.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 genericuseCreateContact(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:
DealsPagereads?company=+?contact=during create flow;CreateDealFormpreselects the company, and afterdealsApi.create(...)it immediately callsdealsApi.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.contactsplus the newcompanies-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 assignsdecision_maker), andfrontend/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>(filterscompany_id),?search=<str>(case-insensitive name/email viafilters.SearchFilter,search_fields=["name","email"], mirroringCompanyViewSet), and?relationship=customer|supplier|partner(filters on the contact’s company flagcompany__is_customer/is_supplier/is_partner, mirroringCompanyViewSet’srelationshipsemantics).- 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.
2026-06-16 — Fix: orphaned CRM settings page now routed + linked; CRM cross-links audited
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 intoApp.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 />} />infrontend/src/App.tsx, beside/crm/approvals. - Fix (nav): sidebar Business group gains a “CRM Settings” item (
SlidersHorizontalicon, newsidebar.crm_settingskey) visible to all members — the page itself renders the permission-legible disabled state for non-admins (Phase D). The deals-boardPageHeadergains a calm gear icon-link to/crm/settings(buttonVariants+ react-router<Link>,aria-label/title“Configure pipelines & stages”, newdeals.configure_pipelineskey). - CRM cross-link audit: deal → company link added in
DealDetailPanel.tsx(company name in the panel header links to/companies, fed by a newcompanyNamesprop fromDealsPage). Company → deals link with company-prefill (/deals?new=1&company=<id>) already existed inCompaniesPage.tsx(Company360) — unchanged.ViewSwitcher.tsxForecast/Calendar/Timeline buttons gainedaria-label/title(icon-only on mobile, previously unlabeled) so the Forecast mode + view modes are discoverable. - Orphan audit: every other
pages/crm/*.tsxandpages/business/*.tsxfile confirmed either routed inApp.tsx(Approvals/Companies/Deals) or a sub-component imported into DealsPage/DealDetailPanel.CrmSettingsPagewas 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 forsidebar+deals). - Tests:
frontend/src/test/CrmSettingsNav.test.tsx— asserts/crm/settingsresolves toCrmSettingsPage, and the deals header exposes a link to/crm/settings. - Verification:
tsc --noEmit0 errors; targeted vitest green (CrmSettingsNav + CrmSettingsPermission + i18n parity for sidebar/deals);check-design-tokensonly 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.tsxgetDealHealth): folds the annotated activity signals into the existing health priority —has_overdue_activity→ amberstaleon an otherwise-fresh deal (a missed scheduled next step is a defect, but not destructive danger), and an open deal withhas_open_activity === false→ theno_activitystate. Critical very-stale-stage and overdue-deposit semantics remain red. DealHealthBadge.tsx: newhasOverdueActivity/hasOpenActivityprops 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 belowPipelineStatson 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 fromDealsPage); shows the conversionbasisnote for honesty; loading (skeleton) / empty / error states defined. Uses the already-shippeduseDealConversion/useDealAginghooks.- i18n:
overdue_activity+analytics_*keys (deals namespace) in en + vi; all 60 other locales seeded with English (all-locale parity green fordeals). - Tests:
frontend/src/test/DealAnalytics.test.tsx(overdue-activity → degraded health,getDealHealthcontract mirror, badge rendering; panel renders win-rate + per-stage aging +basis, plus empty/error states). - Verification:
tsc --noEmit0 errors; targeted vitest 21/21 (DealAnalytics + DealForecast) green;dealsi18n parity clean (only pre-existing tasks/projects + the one namespace-files config test fail, unrelated);check-design-tokensonly 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) inapi/endpoints.ts;queryKeys.dealViews; hooksuseDealViews/useCreateDealView/useUpdateDealView/useDeleteDealView(invalidatedealViews.list);useBulkDealActionre-typed toBulkDealActionType+ returnsBulkDealActionResult. - 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 pureapplyBuiltInPreset. User saved views: click to apply (filters/sort/view_mode), “Save current view” dialog (name + shareSwitch), 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 stubViewSwitcherbulk 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 dealFieldDefinitions + typed/choice value). Partial failures rendererrors[]inline ({deal_id, error}) in a destructiveAlert. - Permission legibility (
pages/crm/CrmSettingsPage.tsx): reads the org role fromuseAuthStore; non-admins see aninfonotice (“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 serverdetailin a toast. DealsPage.tsxwiresSavedViewsBar+DealsBulkBar; built-in preset filtering applies to all views (kanban/table/calendar/timeline) viaviewDeals;ViewSwitcherno longer owns bulk actions.- MSW:
/api/deal-views/CRUD + a partial-failure/api/deals/bulk/handler +fixtureDealViews(own + shared). - i18n: new
dealskeys (saved-views + wider-bulk) andcrmSettingskeys (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.mdroute note + new §9b sequence. - Verification:
tsc --noEmit0 errors; targeted vitest 14/14 + deals/crmSettings i18n parity green;check-design-tokensonly 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_tablecrm_saved_deal_views) — org FK;ownerFK (OrganizationMember, CASCADE);name;view_mode(defaultkanban);filtersJSONField (default dict);sortJSONField (default list);is_sharedbool; ordering["name"]. Migration0011_saveddealview.SavedDealViewSerializerexposes all fields;owner+organizationare 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_createstamps owner=requesting member + org;perform_update/perform_destroyraisePermissionDenied(“Only the view’s owner can edit or delete it.”) for non-owners → 403 detail. Registered inapps/crm/urls.py(basenamedeal-view). - Wider bulk (
DealViewSet.bulk): addedupdate_close_date(params.expected_close_dateYYYY-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 byorder; empty pipeline → 400),update_forecast_category(params.forecast_category∈Deal.FORECAST_CHOICES),update_custom_field(params.key+params.value, validated viavalidate_custom_fieldsagainst the org’s deal field defs; bad key/value → 400). Each keeps the per-dealsuccess_count/error_count/errorscontract + logs a per-dealDealActivity; each is tenant-scoped (cross-org deals → 404). - Permission legibility (
apps/crm/views.py): newIsPipelineStructureAdmin(subclass ofIsOrgOwnerOrAdmin,message = "Only an admin can change pipeline structure.").StageViewSet/PipelineViewSetgainedget_permissions— reads (list/retrieve) open to members; structural writes (create/update/partial_update/destroy) require admin/owner → 403 with explanatorydetail. (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 acrossTestSavedDealViewModel,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 uncoveredapps/crm/views.pylines are the pre-existingmoveaction + the per-dealexcept Exceptiondefensive branches shared by all bulk actions;models.py:191,serializers.py:115/211pre-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):ForecastCategoryunion +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 inViewSwitcher, 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.tsxrenders per-currency pipeline + weighted lines when more than one currency exists, else keeps the compact merged display. - i18n: new
dealskeys (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;dealsnamespace 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 (no150,012,000); empty + error + period-change states; ForecastCategoryControl PATCHes; PipelineStats per-currency vs single-currency display.
Verification:
tsc --noEmit0 errors; targeted vitest 27/27 (DealForecast 11, PipelineStats 3, DealComments 3, DealStructure 10);dealsi18n parity green;check-design-tokensno 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), defaultpipeline, manual in v1. Migration0010_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 againstDeal.FORECAST_CHOICES, saves (signal-suppressed), returns{id, forecast_category}(200); invalid/missing choice → 400; tenant-scoped viaget_object.forecast(GET /api/deals/forecast/?pipeline=&period=YYYY-MM) — open + org-scoped;currency_mode: per_currency;by_categoryalways returns all four keys, each{count, value:{<cur>:…}};weighted_pipeline= Σ amount × stage.probability/100 per currency (omitted excluded, null probability→0); period filtersexpected_close_datewithin the month (invalidYYYY-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_changedDealActivity(from_stage_id/to_stage_id/days_in_prev_stage); abasisfield documents the approximation. Aggregate queries (no N+1).aging(GET /api/deals/aging/?pipeline=) — per-stagecount,stale_count(open deals withdays_in_current_stage > stage.expected_days),avg_days_in_stage; open + org-scoped.analytics(existing) — addedby_currency({cur:{count, pipeline_value}}) +weighted_by_currencyper-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 acrossTestForecastCategoryField,TestForecastCategoryAction,TestForecastEndpoint,TestConversionEndpoint,TestAgingEndpoint,TestAnalyticsCurrencyHonesty. Cross-tenant on every new endpoint. Full CRM suite (279 tests) green; every new line covered (remaining uncoveredapps/crm/views.pylines — move action, bulk except branches — andmodels.py:191are 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,DealLineItemadded (FieldDefinitionalready existed). - API (
src/api/endpoints.ts):contactsApi,fieldDefinitionsApi; extendeddealsApiwithcontacts/addContact/removeContactandlineItems/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-rolledDeal.amountrefreshes (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_typeeditor honoringrequired+ locale label). All three wired intoDealDetailPanel;CustomFieldsEditoralso wired into the deal (DealsPage) and company (CompaniesPage) create forms, saved via the existing PATCHcustom_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 fordeals/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 --noEmitclean;check-design-tokenszero 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_tablecrm_deal_contact_roles) — org FK;dealFK CASCADE;contactFK CASCADE;rolechoices (decision_maker/champion/procurement/site_contact/finance/other);UniqueConstraint(deal, contact, role).DealLineItem(db_tablecrm_deal_line_items) — org FK;dealFK CASCADE;name/description;quantity/unit_price/discount_pct/tax_pctdecimals;sort_order; ordering[sort_order, created_at]; read-onlyline_total= qty × price × (1 − discount_pct/100) × (1 + tax_pct/100) (Decimal-exact). Migration0009_dealcontactrole_deallineitem. - Serializers (
apps/crm/serializers.py):DealContactRoleSerializer— validatescontact.company_id == deal.company_id(cross-company stakeholders rejected in v1) and cross-tenant contact; rejects duplicate(deal, contact, role); exposescontact_name+role_display.DealLineItemSerializer— read-onlyline_total. - ViewSet (
apps/crm/views.py): nested@actionroutes onDealViewSetmirroring the existing comment pattern —contacts(GET/POST),delete_contact_role(DELETEcontacts/<role_id>/),line_items(GET/POST),line_item_detail(PATCH/DELETEline-items/<item_id>/). All deal lookups org-scoped (Rule 1). Rollup helperrecompute_deal_amount(deal)— recomputesDeal.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-changeDealActivitysignal; called only from the line-item mutations (never aDeal.savesignal — no recursion).
Tests:
backend/tests/test_crm_deal_structure.py— 28 tests:line_totalmath, 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): newCRMActivity/CRMActivityType/CRMActivityStatus; extendedDealwithnext_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 viauseZodForm: 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 thedealsi18n 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.tsxnow accepts optionalactivitiesand adds a small mode toggle: Close dates vs Activities. In close-date mode the monthly grid behaves as before, grouping deals byexpected_close_date. In activity mode it groups scheduledCRMActivityrows bydue_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.tsxfetches 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 intoCalendarView. 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
dealskeys for the mode toggle + activity empty state added toen/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.mjsstill reports a pre-existing unrelatedtasks.board_file_helpgap across non-en/vi locales; the newdealskeys 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_tablecrm_activities): organization FK; nullabledeal(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). Derivedis_overdueproperty (never stored). Two indexes per §4.3. Migration0008_crmactivity. - Serializer
CRMActivitySerializer: read-onlyis_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-scopedselect_relatedqueryset; query filtersdeal/company/contact/owner/status/activity_type/overdue=true/upcoming=true;perform_createstamps org + defaults owner to the requesting member;@action completesetsstatus=done,completed_at=now. Routed at/api/crm-activities/(basenamecrm-activity). - Deal next-step discipline:
DealSerializergains read-onlynext_activity_at,has_overdue_activity,has_open_activity, annotated inDealViewSet.get_queryset(Min + Exists subqueries, N+1-safe) with serializer fallbacks for un-annotated instances.
Tests:
backend/tests/test_crm_activities.py— 39 tests (modelis_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 asTestDealReorder/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_categorywith the bounded quote-mix taxonomylabor|material|subcontractor|service(defaultservicefor existing rows with no historical evidence). GET /api/deals/<id>/line-items/now returns bothitemsandvalue_mix, wherevalue_mixis grouped by category with amount and percentage.DealLineItems.tsxnow 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 -qcd 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 inDealsPage.tsx: computescritical/stale/no_activity/close_soon/healthystatus fromdays_in_current_stage,days_since_last_activity,expected_close_date.DealCardborder 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).DealHealthBadgereceivesdaysSinceLastActivityprop to display no-activity state.- Stale badge colors updated: critical →
bg-red-50 text-red-700, stale →bg-amber-50 text-amber-700. Dealtype extended withdays_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
AnimatedPagecontainer instead of the browser viewport. PR #1431 added missingh-fullclasses, fixing inner scroll height but leavingposition: fixedtrapped below the app top bar. - Durable fix: keep
h-fulland portal both loading and loaded overlays todocument.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.tsxassertsh-fullplus the body portal boundary;deep-links.spec.tsassertsy = 0and 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
/dealspage 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:
/dealsnow 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.tsxcd frontend && pnpm exec tsc --noEmitcd 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
/contactsaround 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:
/contactsnow 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.tsxcd frontend && pnpm exec tsc --noEmitcd 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 indocs/tests/web/web-screen-audit.md. No recurrence field exists onCRMActivity(backend/apps/crm/models.py), so “recurring” rollups aren’t buildable honestly; “team rollups” is — every activity already carries a realowner_name(backend/apps/crm/serializers.pyCRMActivitySerializer.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-fetchedoverdue/upcomingarrays 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
dealskeys (agenda_rollup_label,agenda_stat_overdue/today/upcoming,agenda_overdue_by_owner,agenda_unassigned); en + vi authored, all other locales seeded viapropagate-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 --noEmitclean;check-design-tokens.mjsclean;check-locale-parity.mjsclean.
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
/dealsas 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_CUSTODYplus 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.