diff --git a/.changeset/demo-ready-sales-marketing.md b/.changeset/demo-ready-sales-marketing.md new file mode 100644 index 00000000..acf4f7b6 --- /dev/null +++ b/.changeset/demo-ready-sales-marketing.md @@ -0,0 +1,5 @@ +--- +'hotcrm': patch +--- + +Refresh the CRM demo experience with curated sales opportunities, expanded account coverage, and a campaign calendar that tells a complete month-long story. Simplify the default account and campaign grids, keep the active sales board focused on open work, and make the Sales Performance dashboard's KPIs visible earlier in the viewport. diff --git a/.changeset/translate-chinese-list-view-labels.md b/.changeset/translate-chinese-list-view-labels.md new file mode 100644 index 00000000..72281ff1 --- /dev/null +++ b/.changeset/translate-chinese-list-view-labels.md @@ -0,0 +1,6 @@ +--- +'hotcrm': patch +--- + +Complete Chinese metadata labels for every HotCRM list view, including the +high-value CRM views used in the product demo. diff --git a/src/dashboards/sales.dashboard.ts b/src/dashboards/sales.dashboard.ts index 4a2720f6..9ea5199f 100644 --- a/src/dashboards/sales.dashboard.ts +++ b/src/dashboards/sales.dashboard.ts @@ -21,7 +21,9 @@ export const SalesDashboard: Dashboard = { header: { showTitle: true, - showDescription: true, + // Keep the opening KPI row in the first viewport for a live demo. The + // description remains available in dashboard metadata and translations. + showDescription: false, // Header action buttons removed: `create_opportunity` / `export_dashboard_pdf` // are not defined actions, and `/reports/forecast` matches no report route — // all three were dead. Re-add real, wired-up actions here when available. diff --git a/src/data/index.ts b/src/data/index.ts index 1f6be8e7..aeac4962 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -153,6 +153,62 @@ three regional teams (NA, EMEA, APAC). next_renewal_date: cel`daysFromNow(28)`, last_activity_date: cel`daysAgo(1)`, }, + { + name: 'Northwind Energy', + type: 'prospect', + industry: 'energy', + annual_revenue: 18000000, + number_of_employees: 1100, + phone: '+1-303-555-0600', + website: 'https://northwind.example.com', + tier: 'enterprise', + segment: 'net_new', + last_activity_date: cel`daysAgo(2)`, + description: 'A regional energy provider evaluating a unified operations and customer-data platform.', + }, + { + name: 'Vertex Analytics', + type: 'customer', + industry: 'software', + annual_revenue: 9500000, + number_of_employees: 420, + phone: '+1-512-555-0700', + website: 'https://vertex.example.com', + tier: 'enterprise', + segment: 'growth', + health_score: 'healthy', + next_renewal_date: cel`daysFromNow(62)`, + last_activity_date: cel`daysAgo(4)`, + description: 'An analytics SaaS customer expanding usage from the data team to its revenue organization.', + }, + { + name: 'Lattice Education', + type: 'customer', + industry: 'education', + annual_revenue: 6800000, + number_of_employees: 320, + phone: '+1-617-555-0800', + website: 'https://lattice.example.com', + tier: 'mid_market', + segment: 'stable', + health_score: 'healthy', + next_renewal_date: cel`daysFromNow(36)`, + last_activity_date: cel`daysAgo(6)`, + description: 'A higher-education technology company consolidating admissions, student-success and alumni workflows.', + }, + { + name: 'Apex Logistics', + type: 'prospect', + industry: 'logistics', + annual_revenue: 22000000, + number_of_employees: 1400, + phone: '+1-404-555-0900', + website: 'https://apexlogistics.example.com', + tier: 'enterprise', + segment: 'net_new', + last_activity_date: cel`daysAgo(9)`, + description: 'A fast-growing logistics provider assessing a modern data hub for its operations and commercial teams.', + }, ] }); @@ -216,6 +272,50 @@ const contacts = defineSeed(Contact, { crm_account: 'Wayne Enterprises', is_primary: true, }, + { + salutation: 'ms', + first_name: 'Olivia', + last_name: 'Chen', + email: 'olivia.chen@northwind.example.com', + phone: '+1-303-555-0601', + title: 'VP of Digital Operations', + department: 'operations', + crm_account: 'Northwind Energy', + is_primary: true, + }, + { + salutation: 'mr', + first_name: 'Ethan', + last_name: 'Brooks', + email: 'ethan.brooks@vertex.example.com', + phone: '+1-512-555-0701', + title: 'Chief Revenue Officer', + department: 'executive', + crm_account: 'Vertex Analytics', + is_primary: true, + }, + { + salutation: 'ms', + first_name: 'Priya', + last_name: 'Shah', + email: 'priya.shah@lattice.example.com', + phone: '+1-617-555-0801', + title: 'VP of Student Success', + department: 'operations', + crm_account: 'Lattice Education', + is_primary: true, + }, + { + salutation: 'mr', + first_name: 'Marcus', + last_name: 'Reed', + email: 'marcus.reed@apexlogistics.example.com', + phone: '+1-404-555-0901', + title: 'Director of Commercial Systems', + department: 'operations', + crm_account: 'Apex Logistics', + is_primary: true, + }, ] }); @@ -509,74 +609,106 @@ analytics seats for the Ops org, (3) priority support SLA.`, forecast_category: 'omitted', lead_source: 'advertisement', }, - // ─── Generated demo opportunities — ~50 deals across ~6 months close_date - // spread over every stage / forecast / source combination so the - // `PipelineCoverageByQuarterReport`, `OpportunityFunnelByOwnerStageReport`, - // and the dashboard funnel/area widgets all have rich data to chew on. - ...((): readonly Record[] => { - const stages = ['qualification', 'needs_analysis', 'proposal', 'negotiation', 'closed_won', 'closed_lost'] as const; - // Mirrors of opportunity.hook.ts STAGE_FORECAST / STAGE_PROBABILITY — - // hooks don't run over seeds, so the generator must produce exactly the - // values the hook derives from `stage` (#490). The old block used a - // pseudo-random probability and mapped proposal → best_case, both of - // which the hook rewrote on the first user edit. - const forecastByStage: Record = { - qualification: 'pipeline', - needs_analysis: 'best_case', - proposal: 'commit', - negotiation: 'commit', - closed_won: 'closed', - closed_lost: 'omitted', - }; - const probabilityByStage: Record = { - qualification: 25, - needs_analysis: 40, - proposal: 60, - negotiation: 80, - closed_won: 100, - closed_lost: 0, - }; - const sources = ['web', 'referral', 'partner', 'event', 'cold_call', 'advertisement'] as const; - const types = ['new_business', 'existing_upgrade', 'existing_renewal'] as const; - const accountsList = ['Acme Corporation', 'Globex Industries', 'Wayne Enterprises', 'Initech Solutions', 'Stark Medical'] as const; - const isClosed = (s: string) => s === 'closed_won' || s === 'closed_lost'; - const out: Record[] = []; - for (let i = 0; i < 50; i++) { - const stage = stages[i % stages.length]; - // Close date follows the stage instead of being scattered blindly - // across ±180 days. The old spread produced open deals whose close - // date was six months in the PAST — a pipeline that looks abandoned — - // and won deals still to close in the future. Open deals land 7–180 - // days out; settled ones 5–180 days back. - const spread = Math.floor((i * 367) % 174); - const close_date = isClosed(stage) - ? celDaysAgo(5 + spread) - : celDaysFromNow(7 + spread); - // A settled deal entered its closed stage the day it closed; an open - // one has been parked 3–62 days, straddling STALE_THRESHOLD_DAYS so - // the stagnation sweep and the stale view both have rows on each side. - const stage_entry_date = isClosed(stage) - ? celDaysAgo(5 + spread) - : celDaysAgo(3 + (i * 11) % 60); - const amount = 20000 + ((i * 17_393) % 480_000); - const probability = probabilityByStage[stage]; - out.push({ - name: `Demo Deal ${String(i + 1).padStart(2, '0')}`, - crm_account: accountsList[i % accountsList.length], - amount, - stage, - probability, - // Same formula as the hook: round(amount * probability) / 100. - expected_revenue: Math.round(amount * probability) / 100, - close_date, - type: types[i % types.length], - forecast_category: forecastByStage[stage], - lead_source: sources[i % sources.length], - stage_entry_date, - }); - } - return out; - })(), + // ─── Curated active pipeline ───────────────────────────────────── + // Two cards in each active stage make the kanban immediately legible in + // a customer demo. They also tell a coherent sales story instead of + // overwhelming the board with anonymous generated rows. + { + name: 'Northwind Grid Modernization', + crm_account: 'Northwind Energy', + primary_contact: 'olivia.chen@northwind.example.com', + amount: 240000, + stage: 'prospecting', + probability: 10, + expected_revenue: 24000, + close_date: cel`daysFromNow(72)`, + type: 'new_business', + forecast_category: 'pipeline', + lead_source: 'webinar', + stage_entry_date: celDaysAgo(4), + description: 'Northwind is assessing a unified grid-modernization program after an executive webinar.', + next_step: 'Confirm discovery participants and agree the operational-data assessment scope.', + }, + { + name: 'Lattice Student Success Platform', + crm_account: 'Lattice Education', + primary_contact: 'priya.shah@lattice.example.com', + amount: 125000, + stage: 'prospecting', + probability: 10, + expected_revenue: 12500, + close_date: cel`daysFromNow(96)`, + type: 'new_business', + forecast_category: 'pipeline', + lead_source: 'content', + stage_entry_date: celDaysAgo(11), + description: 'A new student-success initiative sourced from the higher-education content series.', + next_step: 'Invite the operations sponsor to the discovery workshop.', + }, + { + name: 'Vertex Analytics Expansion', + crm_account: 'Vertex Analytics', + primary_contact: 'ethan.brooks@vertex.example.com', + amount: 320000, + stage: 'qualification', + probability: 25, + expected_revenue: 80000, + close_date: cel`daysFromNow(52)`, + type: 'existing_expansion', + forecast_category: 'pipeline', + lead_source: 'partner', + stage_entry_date: celDaysAgo(8), + description: 'Vertex wants to extend the platform from its data team to the full revenue organization.', + next_step: 'Validate the buying committee, budget owner and expansion timeline.', + }, + { + name: 'Apex Logistics Data Hub', + crm_account: 'Apex Logistics', + primary_contact: 'marcus.reed@apexlogistics.example.com', + amount: 275000, + stage: 'needs_analysis', + probability: 40, + expected_revenue: 110000, + close_date: cel`daysFromNow(38)`, + type: 'new_business', + forecast_category: 'best_case', + lead_source: 'partner', + stage_entry_date: celDaysAgo(18), + description: 'Apex is mapping its fragmented commercial systems before committing to a data-hub program.', + next_step: 'Complete the architecture workshop and quantify the integration backlog.', + }, + { + name: 'Lattice Education Renewal', + crm_account: 'Lattice Education', + primary_contact: 'priya.shah@lattice.example.com', + amount: 210000, + stage: 'proposal', + probability: 60, + expected_revenue: 126000, + close_date: cel`daysFromNow(24)`, + type: 'existing_renewal', + forecast_category: 'commit', + lead_source: 'email_campaign', + stage_entry_date: celDaysAgo(6), + description: 'Renewal proposal adds student-success automation and an expanded analytics package.', + next_step: 'Review the two-year option with procurement before the executive sponsor meeting.', + }, + { + name: 'Vertex Enterprise Rollout', + crm_account: 'Vertex Analytics', + primary_contact: 'ethan.brooks@vertex.example.com', + amount: 675000, + stage: 'negotiation', + probability: 80, + expected_revenue: 540000, + close_date: cel`daysFromNow(11)`, + type: 'existing_expansion', + forecast_category: 'commit', + lead_source: 'referral', + stage_entry_date: celDaysAgo(3), + description: 'An enterprise rollout negotiated around phased deployment and data-residency requirements.', + next_step: 'Resolve the final security addendum and confirm the procurement signature date.', + }, ] }); @@ -925,44 +1057,85 @@ const campaigns = defineSeed(Campaign, { externalId: 'name', records: [ { - name: 'Q2 Enterprise Email Nurture', - description: 'Multi-touch email nurture targeting enterprise IT decision makers.', + name: 'Q3 Enterprise Email Nurture', + description: 'Multi-touch nurture for enterprise IT decision makers evaluating AI governance and analytics.', type: 'email', channel: 'email', status: 'in_progress', - start_date: cel`daysAgo(30)`, - end_date: cel`daysFromNow(30)`, - budgeted_cost: 25000, - actual_cost: 12500, - expected_revenue: 500000, + start_date: cel`daysAgo(15)`, + end_date: cel`daysFromNow(21)`, + budgeted_cost: 28000, + actual_cost: 14000, + expected_revenue: 560000, + actual_revenue: 70000, target_size: 5000, - landing_page_url: 'https://acme.example.com/lp/enterprise-q2', + landing_page_url: 'https://acme.example.com/lp/enterprise-q3', is_active: true, }, { - name: 'Cloud Migration Webinar Series', - description: 'Three-part webinar series on cloud migration best practices.', + name: 'Executive AI Governance Roundtable', + description: 'A live executive roundtable on operating AI agents with governance, auditability and trust.', + type: 'conference', + channel: 'events', + status: 'planning', + start_date: cel`daysFromNow(2)`, + end_date: cel`daysFromNow(3)`, + budgeted_cost: 22000, + expected_revenue: 320000, + target_size: 120, + is_active: true, + }, + { + name: 'Cloud Migration Webinar', + description: 'A technical webinar on modernizing customer operations and data workflows in the cloud.', type: 'webinar', channel: 'digital', - status: 'completed', - start_date: cel`daysAgo(90)`, - end_date: cel`daysAgo(30)`, - budgeted_cost: 15000, - actual_cost: 14200, - expected_revenue: 300000, - actual_revenue: 285000, + status: 'planning', + start_date: cel`daysFromNow(6)`, + end_date: cel`daysFromNow(8)`, + budgeted_cost: 18000, + expected_revenue: 360000, target_size: 1500, landing_page_url: 'https://acme.example.com/webinars/cloud-migration', - is_active: false, + is_active: true, + }, + { + name: 'Operations Platform Launch', + description: 'Launch campaign for the operations platform, pairing product content with customer proof points.', + type: 'content', + channel: 'digital', + status: 'in_progress', + start_date: cel`daysFromNow(12)`, + end_date: cel`daysFromNow(14)`, + budgeted_cost: 36000, + actual_cost: 12000, + expected_revenue: 480000, + actual_revenue: 120000, + target_size: 8000, + landing_page_url: 'https://acme.example.com/launch/operations-platform', + is_active: true, + }, + { + name: 'Partner Operations Roadshow', + description: 'Joint regional sessions with strategic partners for operations and IT leaders.', + type: 'partner', + channel: 'partner', + status: 'planning', + start_date: cel`daysFromNow(19)`, + end_date: cel`daysFromNow(22)`, + budgeted_cost: 48000, + expected_revenue: 720000, + target_size: 600, + is_active: true, }, { name: 'SaaSCon 2026 Trade Show', - description: 'Booth and sponsorship at the SaaSCon 2026 industry conference.', + description: 'A focused booth, executive dinner and customer-story program at the SaaSCon industry conference.', type: 'trade_show', channel: 'events', status: 'planning', - start_date: cel`daysFromNow(60)`, - end_date: cel`daysFromNow(63)`, + start_date: cel`daysFromNow(27)`, + end_date: cel`daysFromNow(30)`, budgeted_cost: 75000, expected_revenue: 1200000, target_size: 3000, @@ -970,32 +1143,20 @@ const campaigns = defineSeed(Campaign, { }, { name: 'Developer Content Marketing Push', - description: 'Technical blog posts, video tutorials, and developer community engagement.', + description: 'Technical blog posts, video tutorials and developer community engagement completed last month.', type: 'content', channel: 'digital', - status: 'in_progress', - start_date: cel`daysAgo(60)`, - end_date: cel`daysFromNow(90)`, + status: 'completed', + start_date: cel`daysAgo(35)`, + end_date: cel`daysAgo(4)`, budgeted_cost: 40000, - actual_cost: 18000, + actual_cost: 25000, expected_revenue: 250000, + actual_revenue: 200000, target_size: 10000, landing_page_url: 'https://acme.example.com/developers', is_active: true, }, - { - name: 'Partner Co-Marketing Initiative', - description: 'Joint marketing campaigns with strategic technology partners.', - type: 'partner', - channel: 'partner', - status: 'planning', - start_date: cel`daysFromNow(14)`, - end_date: cel`daysFromNow(120)`, - budgeted_cost: 50000, - expected_revenue: 800000, - target_size: 2000, - is_active: true, - }, ] }); diff --git a/src/translations/zh-CN.ts b/src/translations/zh-CN.ts index ea136298..c6d7088e 100644 --- a/src/translations/zh-CN.ts +++ b/src/translations/zh-CN.ts @@ -65,6 +65,8 @@ export const zhCN: TranslationData = { account_map: { label: '客户地图', description: '客户的地理分布' }, enterprise_accounts: { label: '企业客户', description: '年营收最高的大客户' }, my_accounts: { label: '我的客户', description: '由当前用户负责的客户' }, + renewals_due: { label: '🔄 即将续约' }, + at_risk_accounts: { label: '⚠️ 风险客户' }, }, _sections: { basic: { label: '基本信息' }, @@ -303,6 +305,7 @@ export const zhCN: TranslationData = { gallery_view: { label: '线索卡片' }, my_leads: { label: '我的线索' }, high_priority: { label: '高优先级' }, + hot_leads: { label: '🔥 高热度线索' }, }, _sections: { // Detail-page `record:details` section names (lead_detail.page.ts) @@ -484,6 +487,8 @@ export const zhCN: TranslationData = { sla_calendar: { label: 'SLA 日历' }, case_timeline: { label: '工单时间线' }, escalated_cases: { label: '已升级工单' }, + my_open_cases: { label: '我的待处理工单' }, + sla_at_risk: { label: '⏰ SLA 风险预警' }, }, _actions: { log_call: { @@ -583,6 +588,8 @@ export const zhCN: TranslationData = { task_gantt: { label: '执行计划' }, task_timeline: { label: '工时时间线' }, my_open_tasks: { label: '我的待办任务' }, + todays_tasks: { label: '📅 我的优先任务' }, + overdue_tasks: { label: '⏰ 待办任务 · 按逾期时长排序' }, }, }, @@ -768,6 +775,8 @@ export const zhCN: TranslationData = { deal_timeline: { label: '商机时间线' }, deal_gallery: { label: '商机卡片' }, my_open_deals: { label: '我的进行中商机' }, + stale_opportunities: { label: '⚠️ 停滞商机 · 按阶段停留时间排序' }, + closing_this_quarter: { label: '本季度待成交商机' }, }, _sections: { // Detail-page `record:details` section names (opportunity_detail.page.ts) diff --git a/src/views/account.view.ts b/src/views/account.view.ts index 91f80cae..53bcd127 100644 --- a/src/views/account.view.ts +++ b/src/views/account.view.ts @@ -16,18 +16,15 @@ export const AccountViews = defineView({ label: 'All Accounts', data: { provider: 'object', object: 'crm_account' }, columns: [ - { field: 'name', width: 220, sortable: true, link: true, pinned: 'left' }, - { field: 'account_number', width: 140 }, + { field: 'name', width: 240, sortable: true, link: true, pinned: 'left' }, { field: 'industry', width: 140, sortable: true }, + { field: 'type', width: 130 }, { field: 'annual_revenue', width: 160, align: 'right', summary: 'sum' }, { field: 'number_of_employees', width: 130, align: 'right', summary: 'avg' }, - { field: 'phone', width: 150 }, - { field: 'website', width: 200 }, - { field: 'owner', width: 150 }, - { field: 'is_active', width: 100, align: 'center' }, + { field: 'health_score', width: 140 }, + { field: 'next_renewal_date', width: 160, sortable: true }, ], sort: [{ field: 'annual_revenue', order: 'desc' }], - grouping: { fields: [{ field: 'industry', order: 'asc', collapsed: false }] }, rowColor: { field: 'is_active', colors: { true: '#16a34a', false: '#94a3b8' } }, selection: { type: 'multiple' }, pagination: { pageSize: 50, pageSizeOptions: [25, 50, 100] }, diff --git a/src/views/campaign.view.ts b/src/views/campaign.view.ts index 679edf95..c0b7314e 100644 --- a/src/views/campaign.view.ts +++ b/src/views/campaign.view.ts @@ -23,15 +23,9 @@ export const CampaignViews = defineView({ { field: 'start_date', width: 130, sortable: true }, { field: 'end_date', width: 130, sortable: true }, { field: 'budgeted_cost', width: 140, align: 'right', summary: 'sum' }, - { field: 'actual_cost', width: 140, align: 'right', summary: 'sum' }, { field: 'expected_revenue', width: 160, align: 'right', summary: 'sum' }, - { field: 'actual_revenue', width: 160, align: 'right', summary: 'sum' }, - { field: 'response_rate', width: 130, align: 'right' }, - { field: 'roi', width: 110, align: 'right' }, - { field: 'owner', width: 150 }, ], - sort: [{ field: 'start_date', order: 'desc' }], - grouping: { fields: [{ field: 'channel', order: 'asc' }] }, + sort: [{ field: 'start_date', order: 'asc' }], pagination: { pageSize: 25 }, selection: { type: 'multiple' }, appearance: { @@ -53,7 +47,7 @@ export const CampaignViews = defineView({ type: 'gantt', label: 'Campaign Schedule', data: { provider: 'object', object: 'crm_campaign' }, - columns: ['name', 'channel', 'owner'], + columns: ['name', 'channel'], gantt: { startDateField: 'start_date', endDateField: 'end_date', @@ -82,7 +76,10 @@ export const CampaignViews = defineView({ type: 'timeline', label: 'Marketing Timeline', data: { provider: 'object', object: 'crm_campaign' }, - columns: ['name', 'channel'], + // The list data source projects configured columns. Keep the date + // fields in that projection so the timeline renderer can bucket and + // order campaigns from the same schedule data as the calendar/Gantt. + columns: ['name', 'channel', 'start_date', 'end_date'], timeline: { startDateField: 'start_date', endDateField: 'end_date', diff --git a/src/views/opportunity.view.ts b/src/views/opportunity.view.ts index 9b5d1b81..a0111849 100644 --- a/src/views/opportunity.view.ts +++ b/src/views/opportunity.view.ts @@ -36,10 +36,8 @@ export const OpportunityViews = defineView({ { field: 'probability', width: 110, align: 'right' }, { field: 'expected_revenue', width: 160, align: 'right', summary: 'sum' }, { field: 'close_date', width: 140, sortable: true }, - { field: 'owner', width: 150 }, ], sort: [{ field: 'close_date', order: 'asc' }], - grouping: { fields: [{ field: 'stage', order: 'asc', collapsed: false }] }, rowColor: { field: 'stage', colors: { @@ -120,7 +118,11 @@ export const OpportunityViews = defineView({ summarizeField: 'amount', columns: ['name', 'crm_account', 'amount', 'close_date'], }, - filter: [{ field: 'stage', operator: 'not_equals', value: 'closed_lost' }], + // The board is the active working pipeline. Closed business remains in + // the full book and the dashboard, while excluding it here keeps all + // five active stages visible at a presentation-friendly width. + filter: [{ field: 'stage', operator: 'not_in', value: ['closed_won', 'closed_lost'] }], + sort: [{ field: 'close_date', order: 'asc' }], navigation: { mode: 'drawer', width: '640px' }, }, diff --git a/test/actions-flows-integrity.test.ts b/test/actions-flows-integrity.test.ts index e95ea8cd..c2c9eb59 100644 --- a/test/actions-flows-integrity.test.ts +++ b/test/actions-flows-integrity.test.ts @@ -268,16 +268,16 @@ describe('demo data is demo-ready', () => { } }); - it('open demo deals close in the future and settled ones in the past', () => { - // The generator used to scatter close_date across ±180 days regardless of - // stage, so the pipeline was full of open deals that "closed" six months - // ago and won deals still to close — a book that reads as abandoned. + it('open opportunities close in the future and settled ones in the past', () => { + // A pipeline that holds open deals with past close dates, or closed deals + // scheduled in the future, reads as abandoned. This covers the whole + // curated demo book rather than tying the invariant to a placeholder name. const seeds: AnyRec[] = (stack as any).data ?? (stack as any).seeds ?? []; const opps = seeds.find((s) => s.object === 'crm_opportunity'); expect(opps, 'opportunity seed missing').toBeTruthy(); - const demo = (opps!.records ?? []).filter((r: AnyRec) => String(r.name ?? '').startsWith('Demo Deal')); - expect(demo.length).toBeGreaterThan(0); - for (const r of demo) { + const records = (opps!.records ?? []) as AnyRec[]; + expect(records.length).toBeGreaterThan(0); + for (const r of records) { const expr = String(r.close_date?.source ?? r.close_date ?? ''); const closed = r.stage === 'closed_won' || r.stage === 'closed_lost'; expect(expr, `${r.name} (${r.stage}) has the wrong close-date direction`)