diff --git a/.changeset/quick-tour-landing-dashboard-real.md b/.changeset/quick-tour-landing-dashboard-real.md new file mode 100644 index 00000000..da625f09 --- /dev/null +++ b/.changeset/quick-tour-landing-dashboard-real.md @@ -0,0 +1,44 @@ +--- +'hotcrm': patch +--- + +Rewrite the quick tour's opening section against the dashboard **Home** really +opens. `content/docs/getting-started/quick-tour.mdx` — the first paragraph a new +user reads — described a dashboard that does not exist: its name came from one +dashboard and its five bullets from three others. + +The name was wrong. `nav_home` binds `executive_dashboard`, whose label is +**Executive Overview**. **CRM Overview** is a real dashboard, but it hangs off +`nav_crm_dashboard` under **Insights** — a group that ships collapsed — so a new +user neither lands on it nor can click it without opening the group first. The +same page's navigation table already said Home opens **Executive Overview** +(PR #968), so the page contradicted itself, with the wrong half first. + +Of the five bullets, one was right (**Open Leads**), one was half-right, and +three named tiles that are on other dashboards entirely. The section now lists +all nine **Executive Overview** tiles with what each measures, names the three +dashboard-wide controls, and re-points every retired claim instead of deleting +it: cases are counted on **Service Overview** (**Open Cases**, **Critical +Cases**, **SLA Violations**), interactions on **Sales Activity** +(**Interactions Logged**, **Meetings Booked**, **Customer Minutes**), and a +count of open deals is **Active Deals** on **CRM Overview**. "Top accounts by +pipeline value" is not a tile in this app at all — the nearest ranking anywhere, +**Pipeline by Owner**, ranks sales reps rather than customers. + +Two corrections beyond the reported ones. The pipeline bullet was half-right +rather than wrong: **Pipeline by Stage** *is* on this dashboard, arriving from +the shared widget factory rather than an inline literal, so a title grep over +`executive.dashboard.ts` finds eight tiles where the dashboard ships nine. The +section now says what that tile actually measures — open opportunity value per +stage, not a count of deals. And the closing promise of "team-level rollups for +managers" describes something this app does not do: positions are flat, so +visibility never rolls up a reporting line, and the **Sales Manager** permission +set grants `viewAllRecords` outright, which makes a manager's totals org-wide +rather than a team slice. + +All three locales updated; `src/` untouched. The guard added in PR #968, +`test/docs-quick-tour-navigation.test.ts`, only ever read the navigation table +and so stayed green through all of this — it now also compares this section +against `ExecutiveDashboard.widgets` at runtime (which is what counts the +factory-produced funnel), pins the source side of each negative claim, and +fails in all three locales when a tile is added, renamed or removed. diff --git a/content/docs/getting-started/quick-tour.mdx b/content/docs/getting-started/quick-tour.mdx index a9ae8384..8518cdd3 100644 --- a/content/docs/getting-started/quick-tour.mdx +++ b/content/docs/getting-started/quick-tour.mdx @@ -9,15 +9,31 @@ A guided tour through the screens you'll use every day. ## 1. The home dashboard -The first thing you see is the **CRM Overview** dashboard. It tells you: +The left nav opens with one pinned entry, **Home**, and that is where you land. **Home** opens the **Executive Overview** dashboard — nine tiles of revenue, customer and pipeline numbers: -- How many **open leads** you have. -- How many **opportunities** are in your pipeline. -- Any **cases** awaiting your response. -- Top accounts by pipeline value. -- Recent activity (last calls, meetings, emails). - -Use it as your daily start-of-day landing page. If you're a manager, you also see team-level rollups. +| Tile | What it shows | +| --- | --- | +| **Total Revenue (YTD)** | Closed-won revenue this year | +| **Active Accounts** | Customers with at least one active relationship | +| **Total Contacts** | People in the address book | +| **Open Leads** | Unconverted leads in the funnel | +| **Revenue Trend** | Closed-won revenue over the last 12 months | +| **Revenue by Industry** | YTD closed-won revenue split by customer industry | +| **Pipeline by Stage** | Open opportunity value at each sales stage, drawn as a funnel — value, not a count of deals | +| **New Accounts** | Account creation cadence over the last 6 months | +| **Accounts by Industry** | Total annual revenue and account count per industry | + +Above them sit a date-range picker over the close date (this quarter by default), an **Owner** filter and a **Lead Source** filter. The four KPI tiles state their own period — YTD, "active", "unconverted" — and are not narrowed by the picker, so those counts stay whole while the revenue charts follow it. + +Use it as your daily start-of-day landing page. + +Four things this page used to promise are on no tile here, and one name it used belongs to a different dashboard: + +- **CRM Overview** is a real dashboard, but it is not the one **Home** opens. It sits under **Insights** — one of the groups collapsed when the app loads — so it is two clicks away rather than the first thing you see. **Active Deals** (a count of open opportunities), **Won Deals** and **Pipeline by Owner** are its tiles, not this dashboard's. +- *Cases awaiting your response* are on neither dashboard: no tile on **Executive Overview** or on **CRM Overview** counts a case. **Open Cases**, **Critical Cases** and **SLA Violations** are on **Service Overview**, under **Service** — see [Service](/docs/service/index). +- *Top accounts by pipeline value* is not a tile in this app at all. **Accounts by Industry** groups by industry rather than ranking customers, and the nearest ranking anywhere — **Pipeline by Owner** on **CRM Overview** — ranks sales reps rather than customers. +- *Recent activity (last calls, meetings, emails)* is not here either. **Interactions Logged**, **Meetings Booked** and **Customer Minutes** are on **Sales Activity**, under **Activity**. +- *Team-level rollups for managers* are not something this dashboard does. Every tile runs the same query for everyone; what differs is which records you are allowed to read, and that comes from your permission set. Positions in this app are flat — there is no reporting line for visibility to roll up — and the **Sales Manager** set grants `viewAllRecords` outright, so a manager reads org-wide totals rather than a team slice. ➜ Learn more: [Dashboards](/docs/analytics/dashboards) diff --git a/content/docs/getting-started/quick-tour.zh-Hans.mdx b/content/docs/getting-started/quick-tour.zh-Hans.mdx index bdba8cbb..7624037b 100644 --- a/content/docs/getting-started/quick-tour.zh-Hans.mdx +++ b/content/docs/getting-started/quick-tour.zh-Hans.mdx @@ -9,15 +9,33 @@ description: HotCRM 的 5 分钟演练 —— 主页、你的第一条线索、 ## 1. 主页仪表盘 -你看到的第一个东西是 **CRM Overview** 仪表盘。它告诉你: +左侧导航最上面只有一个固定条目 **Home**,你落地的就是它。**Home** 打开的是 **Executive Overview** 仪表盘——9 个磁贴,讲收入、客户与管道: -- 你有多少**打开的线索**。 -- 你的管道中有多少**商机**。 -- 任何**等待你响应**的案例。 -- 按管道价值排名的顶级客户。 -- 近期活动(最近的通话、会议、邮件)。 +| 磁贴 | 它显示什么 | +| --- | --- | +| **Total Revenue (YTD)** | 本年度已成交收入 | +| **Active Accounts** | 至少存在一项活跃关系的客户 | +| **Total Contacts** | 通讯录中的联系人 | +| **Open Leads** | 漏斗中尚未转化的线索 | +| **Revenue Trend** | 过去 12 个月的已成交收入 | +| **Revenue by Industry** | 本年度已成交收入按客户行业拆分 | +| **Pipeline by Stage** | 各销售阶段的进行中商机金额,画成漏斗——是金额,不是商机条数 | +| **New Accounts** | 过去 6 个月的客户创建节奏 | +| **Accounts by Industry** | 各行业的年营收总额与客户数 | + +上表用的是源码里的英文 label,也就是英文界面上看到的名字。简体中文界面下,它们显示为语言包里的译名——例如 **Open Leads** 显示为「未转化线索」、**Pipeline by Stage** 显示为「阶段管道分布」,仪表盘 **Executive Overview** 自己显示为「高管总览」。 + +磁贴上方是三个面板级控件:一个按成交日期的日期范围选择器(默认本季度)、一个 **Owner** 筛选器和一个 **Lead Source** 筛选器。开头 4 个 KPI 磁贴各自框定了自己的周期(本年度累计、"活跃"、"未转化"),不受日期选择器收窄,所以这几个数始终是完整的,而收入类图表跟着选择器走。 + +把它当作你每日开始工作的着陆页。 + +本节过去承诺的 4 样东西,这块盘上没有任何磁贴对应;另有一个名字,它属于另一块盘: -把它当作你每日开始工作的着陆页。如果你是经理,你还会看到团队级汇总。 +- **CRM Overview** 是一块真实存在的仪表盘,但它不是 **Home** 打开的那块。它挂在 **Insights** 分组下,而 **Insights** 在应用加载时是折叠的——所以它离你两次点击,不是"第一眼看到"的东西。**Active Deals**(进行中商机的条数)、**Won Deals** 与 **Pipeline by Owner** 是它的磁贴,不是这块盘的。 +- *等待你响应的案例*:两块盘上都没有。**Executive Overview** 与 **CRM Overview** 都没有任何磁贴在数案例。**Open Cases**、**Critical Cases**、**SLA Violations** 在 **Service** 分组下的 **Service Overview** 上——参见 [Service](/zh-Hans/docs/service/index)。 +- *按管道价值排名的顶级客户*:本应用里根本没有这样一个磁贴。**Accounts by Industry** 是按行业归组,不是给客户排名;全应用最接近的排名是 **CRM Overview** 上的 **Pipeline by Owner**,那是按销售代表排,不是按客户排。 +- *近期活动(最近的通话、会议、邮件)*:也不在这里。**Interactions Logged**、**Meetings Booked** 与 **Customer Minutes** 在 **Activity** 分组下的 **Sales Activity** 上。 +- *给经理看的团队级汇总*:这块盘不做这件事。每个磁贴对所有人跑的都是同一个查询,变的是你被允许读到哪些记录,而那由你的权限集决定。本应用的岗位是扁平的——没有可供可见性向上汇总的汇报线——而 **Sales Manager** 权限集直接授予 `viewAllRecords`,所以经理读到的是全组织的合计,而不是某个团队的切片。 ➜ 了解更多:[仪表盘](/zh-Hans/docs/analytics/dashboards) diff --git a/content/docs/getting-started/quick-tour.zh-Hant.mdx b/content/docs/getting-started/quick-tour.zh-Hant.mdx index a25defb8..91bd0db8 100644 --- a/content/docs/getting-started/quick-tour.zh-Hant.mdx +++ b/content/docs/getting-started/quick-tour.zh-Hant.mdx @@ -9,15 +9,33 @@ description: HotCRM 的 5 分鐘演練 —— 主頁、你的第一條線索、 ## 1. 主頁儀表板 -你看到的第一個東西是 **CRM Overview** 儀表板。它告訴你: +左側導覽最上面只有一個固定條目 **Home**,你落地的就是它。**Home** 打開的是 **Executive Overview** 儀表板——9 個磁貼,講收入、客戶與管道: -- 你有多少**開啟的線索**。 -- 你的管道中有多少**商機**。 -- 任何**等待你回應**的案例。 -- 按管道價值排名的頂級客戶。 -- 近期活動(最近的通話、會議、郵件)。 +| 磁貼 | 它顯示什麼 | +| --- | --- | +| **Total Revenue (YTD)** | 本年度已成交收入 | +| **Active Accounts** | 至少存在一項活躍關係的客戶 | +| **Total Contacts** | 通訊錄中的聯絡人 | +| **Open Leads** | 漏斗中尚未轉換的線索 | +| **Revenue Trend** | 過去 12 個月的已成交收入 | +| **Revenue by Industry** | 本年度已成交收入按客戶產業拆分 | +| **Pipeline by Stage** | 各銷售階段的進行中商機金額,畫成漏斗——是金額,不是商機筆數 | +| **New Accounts** | 過去 6 個月的客戶建立節奏 | +| **Accounts by Industry** | 各產業的年營收總額與客戶數 | + +上表用的是原始碼裡的英文 label,也就是英文介面上看到的名字。本應用提供 en / zh-CN / ja-JP / es-ES 四種介面語言,沒有繁體語言包——簡體中文介面下這些名字會顯示為語言包裡的譯名,例如 **Open Leads** 顯示為「未转化线索」、**Pipeline by Stage** 顯示為「阶段管道分布」,儀表板 **Executive Overview** 自己顯示為「高管总览」。 + +磁貼上方是三個面板級控制項:一個按成交日期的日期範圍選擇器(預設本季)、一個 **Owner** 篩選器和一個 **Lead Source** 篩選器。開頭 4 個 KPI 磁貼各自框定了自己的週期(本年度累計、"活躍"、"未轉換"),不受日期選擇器收窄,所以這幾個數始終是完整的,而收入類圖表跟著選擇器走。 + +把它當作你每日開始工作的著陸頁。 + +本節過去承諾的 4 樣東西,這塊儀表板上沒有任何磁貼對應;另有一個名字,它屬於另一塊儀表板: -把它當作你每日開始工作的著陸頁。如果你是經理,你還會看到團隊級彙總。 +- **CRM Overview** 是一塊真實存在的儀表板,但它不是 **Home** 打開的那塊。它掛在 **Insights** 群組下,而 **Insights** 在應用載入時是摺疊的——所以它離你兩次點擊,不是"第一眼看到"的東西。**Active Deals**(進行中商機的筆數)、**Won Deals** 與 **Pipeline by Owner** 是它的磁貼,不是這塊的。 +- *等待你回應的案例*:兩塊儀表板上都沒有。**Executive Overview** 與 **CRM Overview** 都沒有任何磁貼在數案例。**Open Cases**、**Critical Cases**、**SLA Violations** 在 **Service** 群組下的 **Service Overview** 上——參見 [Service](/zh-Hant/docs/service/index)。 +- *按管道價值排名的頂級客戶*:本應用裡根本沒有這樣一個磁貼。**Accounts by Industry** 是按產業歸組,不是給客戶排名;全應用最接近的排名是 **CRM Overview** 上的 **Pipeline by Owner**,那是按業務代表排,不是按客戶排。 +- *近期活動(最近的通話、會議、郵件)*:也不在這裡。**Interactions Logged**、**Meetings Booked** 與 **Customer Minutes** 在 **Activity** 群組下的 **Sales Activity** 上。 +- *給經理看的團隊級彙總*:這塊儀表板不做這件事。每個磁貼對所有人跑的都是同一個查詢,變的是你被允許讀到哪些記錄,而那由你的權限集決定。本應用的職位是扁平的——沒有可供可見性向上彙總的匯報線——而 **Sales Manager** 權限集直接授予 `viewAllRecords`,所以經理讀到的是全組織的合計,而不是某個團隊的切片。 ➜ 了解更多:[儀表板](/zh-Hant/docs/analytics/dashboards) diff --git a/test/docs-quick-tour-navigation.test.ts b/test/docs-quick-tour-navigation.test.ts index 5f77a53b..c7e6a3f0 100644 --- a/test/docs-quick-tour-navigation.test.ts +++ b/test/docs-quick-tour-navigation.test.ts @@ -6,6 +6,11 @@ import { join } from 'node:path'; import { REPO_ROOT } from './helpers/repo-root'; import { CrmApp } from '../src/apps/crm.app'; import { ExecutiveDashboard } from '../src/dashboards/executive.dashboard'; +import { CrmOverviewDashboard } from '../src/dashboards/crm.dashboard'; +import { ServiceDashboard } from '../src/dashboards/service.dashboard'; +import { ActivityDashboard } from '../src/dashboards/activity.dashboard'; +import { SalesManagerProfile } from '../src/profiles/sales-manager.profile'; +import { CrmPositions } from '../src/sharing/positions'; /** * The quick-tour page's left-nav table, pinned to `src/apps/crm.app.ts` (#960). @@ -266,6 +271,15 @@ describe('the source facts the quick-tour table now rests on (#960)', () => { expect(taskOwners).toEqual(['My Work']); }); + it('routes CRM Overview through Insights, which is collapsed on load', () => { + const insights = GROUPS.find((g) => g.label === 'Insights'); + expect(insights?.expanded).not.toBe(true); + const item = ((insights?.children ?? []) as AnyRec[]).find( + (c) => c.dashboardName === CrmOverviewDashboard.name, + ); + expect(item?.label).toBe(CrmOverviewDashboard.label); + }); + it('keeps the labels the page spells verbatim', () => { const byId = new Map( ((): AnyRec[] => { @@ -280,3 +294,276 @@ describe('the source facts the quick-tour table now rests on (#960)', () => { expect(byId.get('nav_product')?.label).toBe('Products'); }); }); + +/* ─────────────────────────────────────────────────────────────────────────── + * Section 1 — the landing dashboard (#971) + * ───────────────────────────────────────────────────────────────────────── */ + +/** + * The quick tour's opening section, pinned to the dashboard **Home** really + * opens (#971). + * + * The section described a dashboard that does not exist. Its name came from + * `crm_overview_dashboard` — a real dashboard, but the one under **Insights**, + * a group that is collapsed on load, so a new user neither lands on it nor can + * click it without opening the group first. Its five bullets came from three + * different dashboards: exactly one, **Open Leads**, is a tile on the dashboard + * `nav_home` actually binds. + * + * The rewrite is measured against `ExecutiveDashboard.widgets` at runtime, not + * against the file's text, and that distinction is the whole reason the issue + * mis-counted. #971 read the source and listed EIGHT tiles, concluding that no + * pipeline tile existed on this dashboard — but the funnel arrives from the + * shared factory (`pipelineByStageFunnelWidget`, #539), so it carries no inline + * `title:` literal to grep. The dashboard ships NINE tiles, and **Pipeline by + * Stage** is one of them; the four locale bundles register it too. So the + * page's "opportunities in your pipeline" bullet was the one claim that was + * half-right, and the rewrite says what the tile really measures — open + * opportunity VALUE per stage, not a count of deals. A count tile does exist, + * **Active Deals**, and it is on **CRM Overview**. + * + * Rules, in both directions, using the typography section 2 already + * establishes (#960 / PR #968): + * + * - exists ⇒ listed: every widget title on `executive_dashboard` must appear + * in the section, so a new tile cannot land while the tour goes stale. + * - listed ⇒ exists: every bolded Latin run must resolve to a real widget + * title, dashboard label, navigation label, dashboard filter label, or the + * permission set the section names. (CJK bold runs are the locale prose's + * own emphasis — product names stay English in every locale.) + * - a name the reader arrives with is re-pointed, never deleted: the four + * retired claims must each still be named, in *italics*, and none may ever + * be bolded as if it were real. + * - the source side of the negative claims is pinned too. Bind a case or an + * activity dataset to either landing-candidate dashboard, add a per-account + * ranking, give positions a parent, or take `viewAllRecords` off the sales + * manager, and this file goes red — because the prose would then be wrong in + * the other direction. + */ + +const tileTitles = (d: AnyRec): string[] => + ((d.widgets ?? []) as AnyRec[]).map((w) => w.title as string).filter(Boolean); + +const EXEC_TILES = tileTitles(ExecutiveDashboard as AnyRec); + +/** Dashboard-level filter labels the section names as controls. */ +const EXEC_FILTER_LABELS = (((ExecutiveDashboard as AnyRec).globalFilters ?? []) as AnyRec[]) + .map((f) => f.label as string) + .filter(Boolean); + +const ALLOWED_BOLD_SECTION1 = new Set([ + ...ALL_NAV_LABELS, + ...EXEC_TILES, + ...tileTitles(CrmOverviewDashboard as AnyRec), + ...tileTitles(ServiceDashboard as AnyRec), + ...tileTitles(ActivityDashboard as AnyRec), + ...EXEC_FILTER_LABELS, + ExecutiveDashboard.label as string, + CrmOverviewDashboard.label as string, + SalesManagerProfile.label, +]); + +const SECTION1 = [ + { + file: 'content/docs/getting-started/quick-tour.mdx', + lang: 'en', + heading: '## 1. The home dashboard', + /** The tile count, stated in prose, must match the dashboard. */ + count: /nine tiles/, + /** Each retired claim, in the exact spelling the section now italicises. */ + retired: [ + 'Cases awaiting your response', + 'Top accounts by pipeline value', + 'Recent activity (last calls, meetings, emails)', + 'Team-level rollups for managers', + ], + /** Sentences the rewrite removed. None may come back in any locale. */ + gone: [ + 'The first thing you see is the **CRM Overview** dashboard', + 'you also see team-level rollups', + ], + }, + { + file: 'content/docs/getting-started/quick-tour.zh-Hans.mdx', + lang: 'zh-Hans', + heading: '## 1. 主页仪表盘', + count: /9 个磁贴/, + retired: [ + '等待你响应的案例', + '按管道价值排名的顶级客户', + '近期活动(最近的通话、会议、邮件)', + '给经理看的团队级汇总', + ], + gone: ['你看到的第一个东西是 **CRM Overview** 仪表盘', '如果你是经理,你还会看到团队级汇总'], + }, + { + file: 'content/docs/getting-started/quick-tour.zh-Hant.mdx', + lang: 'zh-Hant', + heading: '## 1. 主頁儀表板', + count: /9 個磁貼/, + retired: [ + '等待你回應的案例', + '按管道價值排名的頂級客戶', + '近期活動(最近的通話、會議、郵件)', + '給經理看的團隊級彙總', + ], + gone: ['你看到的第一個東西是 **CRM Overview** 儀表板', '如果你是經理,你還會看到團隊級彙總'], + }, +] as const; + +const sectionOf = (file: string, heading: string): string => { + const lines = readFileSync(join(REPO_ROOT, file), 'utf8').split('\n'); + const from = lines.findIndex((l) => l.trim() === heading); + expect(from, `${file}: section heading '${heading}' not found`).toBeGreaterThanOrEqual(0); + const rest = lines.slice(from); + const end = rest.findIndex((l, i) => i > 0 && l.startsWith('## ')); + return (end === -1 ? rest : rest.slice(0, end)).join('\n'); +}; + +describe("getting-started/quick-tour describes the dashboard Home really opens (#971)", () => { + describe.each(SECTION1)('$file', ({ file, heading, count, retired, gone }) => { + const section = () => sectionOf(file, heading); + + it('names the pinned entry, the dashboard it opens, and every one of its tiles', () => { + const text = section(); + expect(text, `${file}: the pinned nav entry is not named`).toContain('**Home**'); + expect(text, `${file}: the dashboard Home opens is not named`).toContain( + `**${ExecutiveDashboard.label}**`, + ); + const missing = EXEC_TILES.filter((t) => !text.includes(`**${t}**`)); + expect( + missing, + `${file}: tile(s) on ${ExecutiveDashboard.name} that the section does not name. A new ` + + 'tile cannot land while the first section a user reads goes stale.', + ).toEqual([]); + expect(text, `${file}: the tile count in prose does not match the dashboard`).toMatch(count); + }); + + it('bolds only names the app actually carries', () => { + const unknown = boldNames(section()).filter((n) => !ALLOWED_BOLD_SECTION1.has(n)); + expect( + unknown, + `${file}: bolded name(s) that are not a widget title, dashboard label, navigation ` + + 'label, dashboard filter label, or the permission set named here. Bold is reserved ' + + 'for real names — a name the app does not carry goes in *italics*.', + ).toEqual([]); + }); + + it('re-points CRM Overview to where it really lives instead of deleting the name', () => { + const text = section(); + expect(text, `${file}: the name readers arrive with was dropped`).toContain( + `**${CrmOverviewDashboard.label}**`, + ); + expect( + text, + `${file}: CRM Overview is named but the group it actually sits under is not`, + ).toContain('**Insights**'); + }); + + it('still names every retired claim, in italics, and never in bold', () => { + const text = section(); + const italics = italicNames(text); + const bold = boldNames(text); + + const unnamed = retired.filter((n) => !italics.includes(n)); + expect( + unnamed, + `${file}: a claim the section used to make was deleted instead of answered. Readers ` + + 'arrive with these — say where the thing really is, do not drop it silently.', + ).toEqual([]); + + expect( + retired.filter((n) => bold.includes(n)), + `${file}: a claim the app does not support, written in bold as if it were real`, + ).toEqual([]); + + gone.forEach((s) => + expect(text, `${file}: a removed sentence came back: ${s}`).not.toContain(s), + ); + }); + }); +}); + +describe('the source facts the quick-tour landing section now rests on (#971)', () => { + it('Home binds the executive dashboard, and that dashboard ships nine tiles', () => { + expect(PINNED[0].dashboardName).toBe(ExecutiveDashboard.name); + expect(EXEC_TILES).toEqual([ + 'Total Revenue (YTD)', + 'Active Accounts', + 'Total Contacts', + 'Open Leads', + 'Revenue Trend', + 'Revenue by Industry', + 'Pipeline by Stage', + 'New Accounts', + 'Accounts by Industry', + ]); + }); + + it('counts the funnel that arrives from the shared factory, which no title grep finds', () => { + // #971 read `executive.dashboard.ts` and found eight `title:` literals, so it + // reported that the dashboard carries no pipeline tile. `pipeline_by_stage` + // comes from `pipelineByStageFunnelWidget` (#539) and has no inline literal — + // it is the ninth tile, and it is the reason the "opportunities in your + // pipeline" bullet was half-right rather than simply wrong. + const inlineTitles = (readFileSync( + join(REPO_ROOT, 'src/dashboards/executive.dashboard.ts'), + 'utf8', + ).match(/title: '/g) ?? []).length; + expect(inlineTitles).toBe(EXEC_TILES.length - 1); + expect(EXEC_TILES).toContain('Pipeline by Stage'); + }); + + it('binds no case or activity dataset on either dashboard a reader might land on', () => { + const datasets = (d: AnyRec): string[] => [ + ...new Set(((d.widgets ?? []) as AnyRec[]).map((w) => w.dataset as string).filter(Boolean)), + ]; + expect(datasets(ExecutiveDashboard as AnyRec).sort()).toEqual([ + 'account_metrics', + 'contact_metrics', + 'lead_metrics', + 'opportunity_metrics', + ]); + expect(datasets(CrmOverviewDashboard as AnyRec).sort()).toEqual([ + 'opportunity_metrics', + 'product_metrics', + ]); + // The tiles the section re-points to, on the dashboards that do carry them. + expect(datasets(ServiceDashboard as AnyRec)).toContain('case_metrics'); + expect(datasets(ActivityDashboard as AnyRec)).toContain('event_metrics'); + }); + + it('ranks nothing by individual account on either dashboard', () => { + const dims = (d: AnyRec): string[] => [ + ...new Set( + ((d.widgets ?? []) as AnyRec[]).flatMap((w) => (w.dimensions ?? []) as string[]), + ), + ].sort(); + // Pinned as exact sets: adding any dimension here must force a re-read of the + // "top accounts by pipeline value is not a tile" claim, in all three locales. + expect(dims(ExecutiveDashboard as AnyRec)).toEqual([ + 'account_industry', + 'close_date', + 'created_at', + 'industry', + 'stage', + ]); + expect(dims(CrmOverviewDashboard as AnyRec)).toEqual([ + 'category', + 'close_date', + 'lead_source', + 'owner', + 'stage', + ]); + }); + + it('keeps positions flat and the sales manager org-wide, as the section says', () => { + expect(CrmPositions.filter((p) => 'parent' in p || 'parentRole' in p)).toEqual([]); + (['crm_lead', 'crm_account', 'crm_opportunity'] as const).forEach((o) => + expect( + (SalesManagerProfile.objects as AnyRec)[o]?.viewAllRecords, + `sales_manager must read every ${o} for "org-wide totals, not a team slice" to hold`, + ).toBe(true), + ); + }); +});