diff --git a/.changeset/sales-pipeline-kanban-section-and-account-workbench.md b/.changeset/sales-pipeline-kanban-section-and-account-workbench.md new file mode 100644 index 00000000..8a41f7ec --- /dev/null +++ b/.changeset/sales-pipeline-kanban-section-and-account-workbench.md @@ -0,0 +1,69 @@ +--- +'hotcrm': patch +--- + +Write the pipeline board's kanban section to the board the app really ships, and +put the Sales group's ninth sidebar entry back on the page whose job is to list +them. + +**The board cannot show seven stages' worth of deals.** The kanban section of +`content/docs/sales/pipeline-management.mdx` (and both zh pages) opened with "7 +columns — one per stage". `crm_opportunity.stage` does carry seven options, but +this board declares its own filter — `stage not_in [closed_won, closed_lost]` in +`src/views/opportunity.view.ts`, whose source comment says the exclusion is what +"keeps all five active stages visible" — so *Closed Won* and *Closed Lost* can +never hold a card here. A reader counting seven and finding two of them +permanently empty had no way to tell a filter from a bug. The page now says what +the metadata proves: one column per stage, **open deals only**, five active +stages, with closed business named where it does live (**All Opportunities** and +the **Sales Performance** dashboard). Whether the console draws the two closed +stages as empty columns or leaves them off is the renderer's choice, not this +app's declaration, and the page now says that rather than guessing a number — +the conservative shape `content/docs/sales/opportunities.mdx` already uses. + +**There is no owner avatar on a kanban card.** The card list ran to five items, +ending in *Owner avatar*. Cards are bound by `kanban.columns`, which names four +fields — name, account, amount, close date. `owner_id` is in the view's +top-level `columns`, which is what the grid renderings read, and it is nowhere +on the card. The list is now four, and the owner is re-pointed to the two +surfaces that genuinely carry it: the *Owner* column on **All Opportunities**, +and the **Deal Cards** gallery, whose `visibleFields` include it. + +**The stage rules are advice, not a gate.** The page told a rep "the system +enforces the stage rules, so you can't drag from *Prospecting* directly to +*Closed Won*". `opportunity_stage_progression` +(`src/objects/opportunity.object.ts`) does declare that transition illegal, but +at **warning** severity: the move writes one line to the server log and **the +save still goes through**. `content/docs/administration/state-machines` has said +exactly that — naming this very transition — since the state-machine sweep; the +two pages contradicted each other and this was the one that was wrong. Its +wording now matches, and it links there. + +**The cadence table's *Open Pipeline Kanban*** named nothing in the product. +`Pipeline Kanban` is a camel-case reading of the identifier `pipeline_kanban`; +the view's label is **Sales Pipeline** and the sidebar entry that opens it reads +**Pipeline**. The daily row now sends a reader to **Pipeline**, matching the +correction the same section's opening paragraph already carries. + +**Account Workbench was missing from the sales index.** The *Where to find +things* section of `content/docs/sales/index.mdx` listed eight of the Sales +group's nine entries. The missing one is **Account Workbench** +(`src/pages/account_workbench.page.ts`) — an interface page sitting third in the +group, with a real label in every locale bundle — so the page told a new reader +it does not exist, or that the sidebar they were looking at was broken. The same +question was already answered correctly one page over, in +`content/docs/getting-started/quick-tour.mdx`, which lists all nine. The section +now names it, says what it is (a curated way into the same account records, +reusing the **All Accounts** view with three quick filters and no view +management), and states the group size. + +Two guards now hold both pages to their source, because nothing else can — `os +validate` and `pnpm lint` never open `content/docs`: +`test/docs-sales-index-navigation.test.ts` compares the section against the +Sales group in all three locales, and `test/docs-pipeline-kanban-section.test.ts` +pins the kanban section's card fields, filter, active-stage count and advisory +wording, plus the source facts each rests on. Bind an owner onto the cards, drop +the board's filter, raise the rule to `error`, or add a Sales nav entry, and the +docs go red with the metadata. + +Docs and tests only — no `src/` change. diff --git a/content/docs/sales/index.mdx b/content/docs/sales/index.mdx index 6ec00bf4..d19f70d8 100644 --- a/content/docs/sales/index.mdx +++ b/content/docs/sales/index.mdx @@ -57,9 +57,10 @@ Just type into the chat: *"qualify this lead"*, *"draft a follow-up email"*, *"w ## Where to find things -The **Enterprise CRM app** sidebar groups everything under **Sales**: +The **Enterprise CRM app** sidebar groups everything under **Sales**, and that group carries nine entries — six object entries plus three that open something other than an object list: - Leads · Accounts · Contacts · Opportunities · Quotes · Contracts +- **Account Workbench** — a second, curated way into accounts, sitting right under **Accounts** in the group. It reuses the **All Accounts** view's columns, filter and sort, and adds three quick filters (industry, type, owner); view management and advanced filtering are deliberately not offered. Same records as **Accounts**, a narrower screen. - **Pipeline** — the kanban board view of opportunities. That is the sidebar label; the view it opens carries its own label, **Sales Pipeline** (`pipeline_kanban`). - **Sales Performance** — your daily KPIs, from the `sales_dashboard` dashboard. No sidebar item is called *Sales Dashboard*. diff --git a/content/docs/sales/index.zh-Hans.mdx b/content/docs/sales/index.zh-Hans.mdx index 36d696c4..735b212e 100644 --- a/content/docs/sales/index.zh-Hans.mdx +++ b/content/docs/sales/index.zh-Hans.mdx @@ -57,9 +57,10 @@ Lead ──► Account + Contact ──► Opportunity ──► Quote ## 在哪里找到这些功能 -**Enterprise CRM 应用**的侧边栏将所有内容归到**销售**之下: +**Enterprise CRM 应用**的侧边栏将所有内容归到**销售**之下,这个分组一共九个条目——六个对象入口,外加三个打开的不是对象列表的条目: - 线索 · 客户 · 联系人 · 商机 · 报价 · 合同 +- **客户工作台**(Account Workbench)——进入客户数据的第二条、经过策展的通道,在分组里就排在**客户**下面。它复用**全部客户**视图的列、过滤与排序,再加三个快捷筛选(行业、类型、负责人);视图管理与高级过滤是刻意不提供的。记录与**客户**完全相同,只是屏幕更窄。 - **销售管道**(Pipeline)——商机的看板视图。侧边栏上的 label 就是这个;它打开的那个视图自身的 label 是**销售流水线**(Sales Pipeline,`pipeline_kanban`)。 - **销售业绩**(Sales Performance)——你的每日 KPI,来自 `sales_dashboard` 仪表盘。没有任何侧边栏条目叫 *销售仪表盘* / *Sales Dashboard*。 diff --git a/content/docs/sales/index.zh-Hant.mdx b/content/docs/sales/index.zh-Hant.mdx index 85f37d26..3576e0b8 100644 --- a/content/docs/sales/index.zh-Hant.mdx +++ b/content/docs/sales/index.zh-Hant.mdx @@ -57,9 +57,10 @@ Lead ──► Account + Contact ──► Opportunity ──► Quote ## 在哪裡找到這些功能 -**Enterprise CRM 應用**的側邊欄將所有內容歸到**銷售**之下: +**Enterprise CRM 應用**的側邊欄將所有內容歸到**銷售**之下,這個分組一共九個條目——六個物件入口,外加三個打開的不是物件清單的條目: - 潛在客戶 · 客戶 · 聯絡人 · 商機 · 報價 · 合約 +- **客戶工作台**(Account Workbench)——進入客戶資料的第二條、經過策展的通道,在分組裡就排在**客戶**下面。它複用**全部客戶**檢視的欄、過濾與排序,再加三個快捷篩選(行業、類型、負責人);檢視管理與進階過濾是刻意不提供的。記錄與**客戶**完全相同,只是螢幕更窄。 - **銷售管道**(Pipeline)——商機的看板視圖。側邊欄上的 label 就是這個;它打開的那個視圖自身的 label 是**銷售流水線**(Sales Pipeline,`pipeline_kanban`)。 - **銷售業績**(Sales Performance)——你的每日 KPI,來自 `sales_dashboard` 儀表板。沒有任何側邊欄條目叫 *銷售儀表板* / *Sales Dashboard*。 diff --git a/content/docs/sales/pipeline-management.mdx b/content/docs/sales/pipeline-management.mdx index 96b4ae1e..0c2f474d 100644 --- a/content/docs/sales/pipeline-management.mdx +++ b/content/docs/sales/pipeline-management.mdx @@ -37,15 +37,18 @@ No dashboard can give you the same figure. The **Sales Performance** dashboard ( ## The pipeline kanban view -The kanban board is the **Sales Pipeline** view on Opportunities (`pipeline_kanban`), and the sidebar entry that opens it reads **Pipeline**. It shows the pipeline as 7 columns — one per stage. Each card shows: +The kanban board is the **Sales Pipeline** view on Opportunities (`pipeline_kanban`), and the sidebar entry that opens it reads **Pipeline**. It shows the pipeline **one column per stage, open deals only**: the board carries its own filter — `stage not_in [closed_won, closed_lost]` in `src/views/opportunity.view.ts` — so only the five active stages, *Prospecting*, *Qualification*, *Needs Analysis*, *Proposal* and *Negotiation*, can ever hold a card. Closed business is not hidden, it is elsewhere: **All Opportunities** and the **Sales Performance** dashboard both include it. Whether the two closed stages are drawn as permanently empty columns or left off the board altogether is the console's rendering choice and not something this app declares — either way there is no closed deal on this board to find. + +Each card carries the four fields the board binds (`kanban.columns`): - Opportunity name - Account - Amount - Close date -- Owner avatar -To advance a deal, just **drag the card** to the next column. The system enforces the stage rules, so you can't drag from *Prospecting* directly to *Closed Won*. +**Owner is not on the card.** `owner_id` sits in the view's top-level `columns` list, which is what the grid renderings read, but the card binding names those four fields and stops there. To see the owner next to a deal, use **All Opportunities**, which carries an *Owner* column, or the **Deal Cards** gallery, whose cards do show it. + +To advance a deal, just **drag the card** to the next column. The stage rules are **advice, not a gate**. `opportunity_stage_progression` (`src/objects/opportunity.object.ts`) declares which moves are legal — *Prospecting* straight to *Closed Won* is not one of them — but the rule carries **warning** severity, so a move outside the table writes one line to the server log, *"Invalid opportunity stage transition"*, and **the save still goes through**. Nothing in the board stops you. What the log buys you is that a bad import or a misbehaving integration can be found afterwards; see [Administration › State Machines](/docs/administration/state-machines) for what the table is and is not good for. At the top of each column you'll see **one** number: the sum of *Amount* across that column's deals — the unweighted total for that stage. One is all a kanban column can carry, by design: a board declares a single `summarizeField` (`src/views/opportunity.view.ts`), and this board binds it to `amount`. @@ -82,7 +85,7 @@ See [Analytics › Reports](/docs/analytics/reports) for details. | Cadence | What to do | | --- | --- | -| **Daily** | Open Pipeline Kanban. Find stalled cards (in-stage > 14 days). Push or close. | +| **Daily** | Open the **Pipeline** board. Find stalled cards (in-stage > 14 days). Push or close. | | **Weekly** | Run *Pipeline Coverage*. Ask the Sales Copilot: *"what's at risk?"* | | **Monthly** | Run *Opportunity Funnel by Owner → Stage*. Coach reps with leaky stages 1:1. | | **Quarter-end** | Run *Won Opportunities by Owner* for commissions. Archive lost deals with a *loss reason*. | diff --git a/content/docs/sales/pipeline-management.zh-Hans.mdx b/content/docs/sales/pipeline-management.zh-Hans.mdx index 0a19bf67..146f95f0 100644 --- a/content/docs/sales/pipeline-management.zh-Hans.mdx +++ b/content/docs/sales/pipeline-management.zh-Hans.mdx @@ -37,15 +37,18 @@ description: 销售管道的日常运作方式——推进交易、自动概率 ## 销售管道看板视图 -这块看板是商机上的**销售流水线**视图(`pipeline_kanban`),侧边栏上打开它的那个条目写作**销售管道**。它将销售管道显示为 7 列——每个阶段一列。每张卡片显示: +这块看板是商机上的**销售流水线**视图(`pipeline_kanban`),侧边栏上打开它的那个条目写作**销售管道**。它把销售管道按**每个阶段一列**铺开,且**只放在飞交易**:这块板自带过滤器——`src/views/opportunity.view.ts` 里的 `stage not_in [closed_won, closed_lost]`——所以只有五个活跃阶段(*寻找客户*、*资格审查*、*需求分析*、*提案*、*谈判*)里才可能有卡片。已结束的生意不是被藏了,而是在别处:**全部商机**视图与 **Sales Performance** 仪表盘都算上它们。至于那两个已结束阶段是被画成永远空着的列、还是干脆不出现在板上,那是 console 的算绘选择,本应用的元数据并没有声明——无论哪种,这块板上都没有已结束的交易可找。 + +每张卡片带的是这块板绑定的四个字段(`kanban.columns`): - 商机名称 - 客户 - 金额 - 成交日期 -- 负责人头像 -要推进一笔交易,只需**拖动卡片**到下一列。系统会强制执行阶段规则,因此你无法从*寻找客户*直接拖到*成交*。 +**卡片上没有负责人。** `owner_id` 在这个视图顶层的 `columns` 清单里,那是给网格类算绘读的;卡面绑定只点了上面四个字段,到此为止。要在交易旁边看到负责人,请用带 *Owner* 列的**全部商机**视图,或者卡面确实带负责人的 **Deal Cards** 图库视图。 + +要推进一笔交易,只需**拖动卡片**到下一列。阶段规则是**建议,不是闸门**。`opportunity_stage_progression`(`src/objects/opportunity.object.ts`)声明了哪些跃迁合法——*寻找客户*直接到*成交*并不在其中——但这条规则是**警告**级,所以走出转换表的移动只会往服务端日志写一行 *"Invalid opportunity stage transition"*,而**保存照样通过**。看板上没有任何东西会拦住你。日志给你的价值是:一次糟糕的导入、一个行为异常的集成,事后还能被找出来。这张表能做什么、不能做什么,详见 [Administration › State Machines](/zh-Hans/docs/administration/state-machines)。 在每列顶部你会看到**一个**数字:该列所有交易的*金额*之和,也就是这个阶段的未加权合计。看板每列只能有这一个数,这是设计使然:一块看板只声明一个 `summarizeField`(`src/views/opportunity.view.ts`),而这块板把它绑到了 `amount`。 @@ -82,7 +85,7 @@ description: 销售管道的日常运作方式——推进交易、自动概率 | 节奏 | 该做什么 | | --- | --- | -| **每日** | 打开销售管道看板。找出停滞的卡片(阶段内 > 14 天)。推进或关闭。 | +| **每日** | 打开**销售管道**这块板。找出停滞的卡片(阶段内 > 14 天)。推进或关闭。 | | **每周** | 运行 *Pipeline Coverage*。向 Sales Copilot 提问:*"what's at risk?"* | | **每月** | 运行 *Opportunity Funnel by Owner → Stage*。对漏斗漏水的销售代表进行一对一辅导。 | | **季末** | 运行 *Won Opportunities by Owner* 以计算提成。归档*失败*的交易并附上*丢单原因*。 | diff --git a/content/docs/sales/pipeline-management.zh-Hant.mdx b/content/docs/sales/pipeline-management.zh-Hant.mdx index 916954bc..4ac6d2c3 100644 --- a/content/docs/sales/pipeline-management.zh-Hant.mdx +++ b/content/docs/sales/pipeline-management.zh-Hant.mdx @@ -37,15 +37,18 @@ description: 銷售管道的日常運作方式——推進交易、自動機率 ## 銷售管道看板視圖 -這塊看板是商機上的**銷售流水線**視圖(`pipeline_kanban`),側邊欄上打開它的那個條目寫作**銷售管道**。它將銷售管道顯示為 7 列——每個階段一列。每張卡片顯示: +這塊看板是商機上的**銷售流水線**視圖(`pipeline_kanban`),側邊欄上打開它的那個條目寫作**銷售管道**。它把銷售管道按**每個階段一列**鋪開,且**只放在飛交易**:這塊板自帶過濾器——`src/views/opportunity.view.ts` 裡的 `stage not_in [closed_won, closed_lost]`——所以只有五個活躍階段(*尋找客戶*、*資格審查*、*需求分析*、*提案*、*談判*)裡才可能有卡片。已結束的生意不是被藏了,而是在別處:**全部商機**檢視與 **Sales Performance** 儀表板都算上它們。至於那兩個已結束階段是被畫成永遠空著的欄、還是乾脆不出現在板上,那是 console 的算繪選擇,本應用的元資料並沒有宣告——無論哪種,這塊板上都沒有已結束的交易可找。 + +每張卡片帶的是這塊板綁定的四個欄位(`kanban.columns`): - 商機名稱 - 客戶 - 金額 - 成交日期 -- 負責人頭像 -要推進一筆交易,只需**拖動卡片**到下一列。系統會強制執行階段規則,因此你無法從*尋找客戶*直接拖到*成交*。 +**卡片上沒有負責人。** `owner_id` 在這個檢視頂層的 `columns` 清單裡,那是給網格類算繪讀的;卡面綁定只點了上面四個欄位,到此為止。要在交易旁邊看到負責人,請用帶 *Owner* 欄的**全部商機**檢視,或者卡面確實帶負責人的 **Deal Cards** 圖庫檢視。 + +要推進一筆交易,只需**拖動卡片**到下一列。階段規則是**建議,不是閘門**。`opportunity_stage_progression`(`src/objects/opportunity.object.ts`)宣告了哪些躍遷合法——*尋找客戶*直接到*成交*並不在其中——但這條規則是**警告**級,所以走出轉換表的移動只會往伺服器日誌寫一行 *「Invalid opportunity stage transition」*,而**儲存照樣通過**。看板上沒有任何東西會攔住你。日誌給你的價值是:一次糟糕的匯入、一個行為異常的整合,事後還能被找出來。這張表能做什麼、不能做什麼,詳見 [Administration › State Machines](/zh-Hant/docs/administration/state-machines)。 在每列頂部你會看到**一個**數字:該列所有交易的*金額*之和,也就是這個階段的未加權合計。看板每列只能有這一個數,這是設計使然:一塊看板只宣告一個 `summarizeField`(`src/views/opportunity.view.ts`),而這塊板把它綁到了 `amount`。 @@ -82,7 +85,7 @@ description: 銷售管道的日常運作方式——推進交易、自動機率 | 節奏 | 該做什麼 | | --- | --- | -| **每日** | 打開銷售管道看板。找出停滯的卡片(階段內 > 14 天)。推進或關閉。 | +| **每日** | 打開**銷售管道**這塊板。找出停滯的卡片(階段內 > 14 天)。推進或關閉。 | | **每週** | 執行 *Pipeline Coverage*。向 Sales Copilot 提問:*"what's at risk?"* | | **每月** | 執行 *Opportunity Funnel by Owner → Stage*。對漏斗漏水的銷售代表進行一對一輔導。 | | **季末** | 執行 *Won Opportunities by Owner* 以計算提成。歸檔*失敗*的交易並附上*丟單原因*。 | diff --git a/test/docs-pipeline-kanban-section.test.ts b/test/docs-pipeline-kanban-section.test.ts new file mode 100644 index 00000000..125cf7ab --- /dev/null +++ b/test/docs-pipeline-kanban-section.test.ts @@ -0,0 +1,243 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { REPO_ROOT } from './helpers/repo-root'; +import { OpportunityViews } from '../src/views/opportunity.view'; +import { Opportunity } from '../src/objects/opportunity.object'; + +/** + * The pipeline page's kanban section, pinned to the board it describes (#996). + * + * Three claims in that section were each wrong in a different way, and none of + * them was reachable by any existing gate — `os validate` and `pnpm lint` walk + * authored metadata and never open `content/docs`, so the check has to live + * where the claim lives (the reasoning `docs-service-index-analytics.test.ts` + * #948 and `docs-quick-tour-navigation.test.ts` #960 already wrote down). + * + * 1. **"7 columns — one per stage."** `crm_opportunity.stage` does carry seven + * options, but this board filters `stage not_in [closed_won, closed_lost]`, + * so two of those stages can never hold a card. Whether the console draws + * them as empty columns or omits them is the renderer's choice — it lives + * outside this repo and no browser measurement was taken — so the page now + * states what the metadata proves (five active stages, open deals only, the + * conservative shape `content/docs/sales/opportunities.mdx` already uses) + * and says plainly that the drawn count is the console's call. What is + * pinned here is therefore the FIVE, not a rendered column count. + * 2. **"Owner avatar"** as the fifth card field. Cards are bound by + * `kanban.columns`, which names four fields; `owner_id` appears only in the + * view's top-level `columns`. The page now lists four and re-points the + * reader to the two surfaces that really do show an owner — the + * #927 / PR #932 convention of saying where a name lives rather than + * deleting it. + * 3. **"The system enforces the stage rules, so you can't drag …"** + * `opportunity_stage_progression` is `severity: 'warning'`, so the illegal + * move is logged and **saved**. `content/docs/administration/state-machines` + * had said exactly that — naming this very transition — since #919/#950; + * the two pages contradicted each other and this was the one that was + * wrong. Its wording now matches, phrase for phrase. + * + * The source side of each claim is pinned too, so the prose cannot become wrong + * in the other direction: bind `owner_id` onto the cards, drop the board's + * filter, or raise the rule to `error`, and this file goes red. + */ + +type AnyRec = Record; + +const KANBAN = ((OpportunityViews as AnyRec).listViews ?? {}).pipeline_kanban as AnyRec; +const ALL_OPPORTUNITIES = ((OpportunityViews as AnyRec).listViews ?? {}).all_opportunities as AnyRec; +const DEAL_GALLERY = ((OpportunityViews as AnyRec).listViews ?? {}).deal_gallery as AnyRec; + +const STAGE_OPTIONS: AnyRec[] = ((Opportunity as AnyRec).fields?.stage?.options ?? []) as AnyRec[]; + +/** Stage values this board filters out, read off the view rather than assumed. */ +const EXCLUDED_STAGES: string[] = (((KANBAN?.filter ?? []) as AnyRec[]).find( + (f) => f.field === 'stage' && f.operator === 'not_in', +)?.value ?? []) as string[]; + +const ACTIVE_STAGES = STAGE_OPTIONS.map((o) => String(o.value)).filter( + (v) => !EXCLUDED_STAGES.includes(v), +); + +const STAGE_MACHINE = (((Opportunity as AnyRec).validations ?? []) as AnyRec[]).find( + (v) => v.name === 'opportunity_stage_progression', +) as AnyRec; + +const CARD_FIELDS: string[] = (KANBAN?.kanban?.columns ?? []) as string[]; + +const PAGES = [ + { + file: 'content/docs/sales/pipeline-management.mdx', + lang: 'en', + heading: '## The pipeline kanban view', + cadence: '## Recommended pipeline cadence', + /** The five active stages, stated as a count in prose. */ + activeCount: /five active stages/, + /** The rule is advisory, in the wording administration/state-machines uses. */ + advisory: /advice, not a gate/, + savesAnyway: /the save still goes through/, + /** The sidebar label the cadence row must send a reader to. */ + boardName: '**Pipeline**', + /** + * Claims #996 removed. `Pipeline Kanban` is a camel-case reading of the + * identifier `pipeline_kanban` and names nothing in the product; on the en + * page the capitalisation makes it unambiguous, so it is barred page-wide. + */ + retired: [ + '7 columns', + 'Owner avatar', + 'The system enforces the stage rules', + 'Pipeline Kanban', + ], + pageWideRetired: ['Pipeline Kanban'], + }, + { + file: 'content/docs/sales/pipeline-management.zh-Hans.mdx', + lang: 'zh-Hans', + heading: '## 销售管道看板视图', + cadence: '## 推荐的销售管道节奏', + activeCount: /五个活跃阶段/, + advisory: /建议,不是闸门/, + savesAnyway: /保存照样通过/, + boardName: '**销售管道**', + retired: ['7 列', '负责人头像', '系统会强制执行阶段规则'], + pageWideRetired: [], + }, + { + file: 'content/docs/sales/pipeline-management.zh-Hant.mdx', + lang: 'zh-Hant', + heading: '## 銷售管道看板視圖', + cadence: '## 推薦的銷售管道節奏', + activeCount: /五個活躍階段/, + advisory: /建議,不是閘門/, + savesAnyway: /儲存照樣通過/, + boardName: '**銷售管道**', + retired: ['7 列', '負責人頭像', '系統會強制執行階段規則'], + pageWideRetired: [], + }, +] as const; + +/** The `## …` section named by `heading`, up to the next `## `. */ +const sectionOf = (file: string, heading: string): string => { + const lines = readFileSync(join(REPO_ROOT, file), 'utf8').split('\n'); + const start = lines.findIndex((l) => l.trim() === heading); + expect(start, `${file}: heading '${heading}' not found`).toBeGreaterThanOrEqual(0); + const rest = lines.slice(start + 1); + const end = rest.findIndex((l) => l.startsWith('## ')); + return (end === -1 ? rest : rest.slice(0, end)).join('\n'); +}; + +/** Contiguous `- ` bullets — the card-field list is the section's only one. */ +const bulletRun = (section: string): string[] => { + const bullets = section.split('\n').filter((l) => l.trim().startsWith('- ')); + return bullets.map((l) => l.trim().replace(/^-\s+/, '')); +}; + +describe('the pipeline page describes the board it actually ships (#996)', () => { + describe.each(PAGES)( + '$file', + ({ file, heading, cadence, activeCount, advisory, savesAnyway, boardName, retired, pageWideRetired }) => { + const section = () => sectionOf(file, heading); + + it('lists exactly the fields the cards are bound to', () => { + expect( + bulletRun(section()).length, + `${file}: the card-field list must have one bullet per kanban.columns entry ` + + `(${CARD_FIELDS.join(', ')}). A field on the card that is not bound is a phantom; ` + + 'a bound field the list omits sends the reader looking elsewhere.', + ).toBe(CARD_FIELDS.length); + }); + + it('says the board holds open deals only, and names the filter that makes it so', () => { + const text = section(); + expect(text, `${file}: the active-stage count is not stated`).toMatch(activeCount); + EXCLUDED_STAGES.forEach((s) => + expect( + text, + `${file}: the board filters out '${s}' but the section does not name it`, + ).toContain(s), + ); + }); + + it('calls the stage rules advisory, not enforcement', () => { + const text = section(); + expect(text, `${file}: the rule is warning severity — say so`).toMatch(advisory); + expect(text, `${file}: the section does not say the save goes through`).toMatch(savesAnyway); + expect(text).toContain(STAGE_MACHINE.name); + }); + + it('sends the daily cadence to the sidebar entry that exists', () => { + const row = sectionOf(file, cadence) + .split('\n') + .find((l) => l.includes('|') && /每日|Daily/.test(l)); + expect(row, `${file}: the cadence table has no daily row`).toBeTruthy(); + expect( + row, + `${file}: the daily row must name the sidebar entry that opens the board`, + ).toContain(boardName); + }); + + it('does not resurrect the claims #996 removed', () => { + const text = section(); + expect(retired.filter((phrase) => text.includes(phrase))).toEqual([]); + const whole = readFileSync(join(REPO_ROOT, file), 'utf8'); + expect( + pageWideRetired.filter((phrase) => whole.includes(phrase)), + `${file}: a name the product does not carry came back somewhere on the page`, + ).toEqual([]); + }); + }, + ); +}); + +describe('the source facts those three claims now rest on (#996)', () => { + it('the board binds four card fields, and owner is not one of them', () => { + expect(CARD_FIELDS).toEqual(['name', 'crm_account', 'amount', 'close_date']); + expect( + CARD_FIELDS, + 'binding owner onto the cards would make the "no owner on the card" paragraph wrong — ' + + 'update the prose in all three locales, not just the metadata', + ).not.toContain('owner_id'); + }); + + it('owner_id is in the view-level columns the prose re-points to', () => { + expect(KANBAN.columns).toContain('owner_id'); + }); + + it('re-points the owner to two surfaces that really do show it', () => { + const cols = ((ALL_OPPORTUNITIES.columns ?? []) as AnyRec[]).map((c) => + typeof c === 'string' ? c : c.field, + ); + expect(cols, 'All Opportunities is named as an owner-bearing view').toContain('owner_id'); + expect( + (DEAL_GALLERY.gallery?.visibleFields ?? []) as string[], + 'Deal Cards is named as showing the owner on the card', + ).toContain('owner_id'); + }); + + it('excludes both closed stages, leaving five that can hold a card', () => { + expect([...EXCLUDED_STAGES].sort()).toEqual(['closed_lost', 'closed_won']); + expect(STAGE_OPTIONS).toHaveLength(7); + expect(ACTIVE_STAGES).toEqual([ + 'prospecting', + 'qualification', + 'needs_analysis', + 'proposal', + 'negotiation', + ]); + }); + + it('governs the stage with a warning, not an error', () => { + // An `error` here would make "advice, not a gate" wrong on this page and on + // content/docs/administration/state-machines — change both, or neither. + expect(STAGE_MACHINE.severity).toBe('warning'); + expect(STAGE_MACHINE.field).toBe('stage'); + }); + + it('leaves Prospecting → Closed Won outside the table, which is why it is the example', () => { + expect((STAGE_MACHINE.transitions as Record).prospecting).not.toContain( + 'closed_won', + ); + }); +}); diff --git a/test/docs-sales-index-navigation.test.ts b/test/docs-sales-index-navigation.test.ts new file mode 100644 index 00000000..a0ea71fe --- /dev/null +++ b/test/docs-sales-index-navigation.test.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { REPO_ROOT } from './helpers/repo-root'; +import { CrmApp } from '../src/apps/crm.app'; +import { AccountWorkbenchPage } from '../src/pages/account_workbench.page'; + +/** + * The sales index page's "Where to find things" section, pinned to the Sales + * navigation group (#997). + * + * The section's whole job is "here is what the sidebar's Sales group holds", + * and it held eight of the group's nine entries. The missing one was + * **Account Workbench** — an ADR-0047 interface page + * (`src/pages/account_workbench.page.ts`), with a real label in every locale + * bundle, sitting third in the group — so the page told a new reader it does + * not exist, or that the sidebar they were looking at was broken. + * + * The same fact was already right one page over: + * `content/docs/getting-started/quick-tour.mdx` lists all nine, and + * `docs-quick-tour-navigation.test.ts` (#960) pins that list item-for-item. Two + * pages, one question, two answers — and only one of them had a guard. This + * file is the other guard, and it is why the same drift cannot recur here: add + * a nav item to the Sales group without touching this section and it goes red, + * in all three locales, the way #938 (marketing/index, Products) and #927 + * (service/index, Knowledge) each had to be found by hand. + * + * The rules are shaped around the section's own layout, which differs by + * locale in exactly one respect worth knowing: + * + * - the six OBJECT entries share one `·`-separated bullet. The zh pages + * translate those six names, so they cannot be matched against the English + * labels — what is pinned instead is the COUNT, which catches a seventh + * object entry landing in the group in every locale. The en page is checked + * label-for-label on top of that. + * - the three NON-object entries get a bullet each, and every locale spells + * the English label there (bare on en, in parentheses on the zh pages), so + * those are matched by label everywhere. + * - the group size is stated in prose and must match the group. + */ + +type AnyRec = Record; + +const NAV = ((CrmApp as AnyRec).navigation ?? []) as AnyRec[]; + +const SALES = (() => { + const g = NAV.find((n) => n.type === 'group' && n.label === 'Sales'); + if (!g) throw new Error('the app has no Sales navigation group — this pin is out of date'); + return g; +})(); + +const SALES_CHILDREN = ((SALES.children ?? []) as AnyRec[]); + +/** + * The six entries that open an object's own list, and the three that open + * something else. `nav_pipeline` is `type: 'object'` too, but it pins a + * `viewName` — it lands on one specific kanban view rather than on the + * Opportunities list — which is why it gets its own bullet alongside the page + * and the dashboard rather than joining the `·`-separated run. + */ +const OBJECT_ENTRIES = SALES_CHILDREN.filter((c) => c.type === 'object' && !c.viewName); +const OTHER_ENTRIES = SALES_CHILDREN.filter((c) => c.type !== 'object' || !!c.viewName); + +/** `·`-separated list items in the section's first bullet. */ +const OBJECT_BULLET_ITEMS = (section: string): string[] => { + const line = section.split('\n').find((l) => l.trim().startsWith('- ') && l.includes(' · ')); + expect(line, 'the section has no `·`-separated bullet of object entries').toBeTruthy(); + return (line as string) + .trim() + .replace(/^-\s+/, '') + .split(' · ') + .map((s) => s.trim()); +}; + +const PAGES = [ + { + file: 'content/docs/sales/index.mdx', + lang: 'en', + heading: '## Where to find things', + /** The group size, stated in prose. */ + count: /nine entries/, + /** The en page names the object entries in English, so they are checked verbatim. */ + checkObjectLabels: true, + }, + { + file: 'content/docs/sales/index.zh-Hans.mdx', + lang: 'zh-Hans', + heading: '## 在哪里找到这些功能', + count: /九个条目/, + checkObjectLabels: false, + }, + { + file: 'content/docs/sales/index.zh-Hant.mdx', + lang: 'zh-Hant', + heading: '## 在哪裡找到這些功能', + count: /九個條目/, + checkObjectLabels: false, + }, +] as const; + +/** The `## …` section named by `heading`, up to the next `## `. */ +const sectionOf = (file: string, heading: string): string => { + const lines = readFileSync(join(REPO_ROOT, file), 'utf8').split('\n'); + const start = lines.findIndex((l) => l.trim() === heading); + expect(start, `${file}: heading '${heading}' not found`).toBeGreaterThanOrEqual(0); + const rest = lines.slice(start + 1); + const end = rest.findIndex((l) => l.startsWith('## ')); + return (end === -1 ? rest : rest.slice(0, end)).join('\n'); +}; + +describe('sales/index lists the whole Sales navigation group (#997)', () => { + describe.each(PAGES)('$file', ({ file, heading, count, checkObjectLabels }) => { + const section = () => sectionOf(file, heading); + + it('states the group size the app really ships', () => { + expect( + section(), + `${file}: the entry count in prose does not match the Sales group ` + + `(${SALES_CHILDREN.length} children)`, + ).toMatch(count); + }); + + it('carries one item per object entry in the shared bullet', () => { + expect( + OBJECT_BULLET_ITEMS(section()).length, + `${file}: the object bullet lists a different number of entries than the Sales group ` + + 'has object children. Add a nav item, list it here.', + ).toBe(OBJECT_ENTRIES.length); + }); + + it('names every non-object entry by the label the app carries', () => { + const text = section(); + const missing = OTHER_ENTRIES.map((c) => c.label as string).filter((l) => !text.includes(l)); + expect( + missing, + `${file}: Sales entries that open something other than an object list, and that this ` + + 'section does not name. This is the first place a reader looks for them.', + ).toEqual([]); + }); + + it('names Account Workbench — the entry #997 found missing', () => { + expect( + section(), + `${file}: Account Workbench is a real Sales entry with a label in every locale bundle; ` + + 'omitting it tells the reader it does not exist.', + ).toContain('Account Workbench'); + }); + + if (checkObjectLabels) { + it('spells each object entry with its own navigation label', () => { + expect(OBJECT_BULLET_ITEMS(section())).toEqual(OBJECT_ENTRIES.map((c) => c.label)); + }); + } + }); +}); + +describe('the source facts that section now rests on (#997)', () => { + it('the Sales group holds nine entries, in this order', () => { + expect(SALES_CHILDREN.map((c) => c.label)).toEqual([ + 'Leads', + 'Accounts', + 'Account Workbench', + 'Contacts', + 'Opportunities', + 'Pipeline', + 'Quotes', + 'Contracts', + 'Sales Performance', + ]); + }); + + it('six of them open an object list; three open something else', () => { + expect(OBJECT_ENTRIES.map((c) => c.label)).toEqual([ + 'Leads', + 'Accounts', + 'Contacts', + 'Opportunities', + 'Quotes', + 'Contracts', + ]); + expect(OTHER_ENTRIES.map((c) => c.label)).toEqual([ + 'Account Workbench', + 'Pipeline', + 'Sales Performance', + ]); + }); + + it('Account Workbench is a page entry sitting directly under Accounts', () => { + const i = SALES_CHILDREN.findIndex((c) => c.id === 'nav_account_workbench'); + expect(SALES_CHILDREN[i - 1]?.id).toBe('nav_account'); + expect(SALES_CHILDREN[i].type).toBe('page'); + expect(SALES_CHILDREN[i].pageName).toBe(AccountWorkbenchPage.name); + expect(SALES_CHILDREN[i].label).toBe(AccountWorkbenchPage.label); + }); + + it('that page reuses the accounts view rather than restating it, as the bullet says', () => { + const cfg = (AccountWorkbenchPage as AnyRec).interfaceConfig ?? {}; + expect(cfg.source).toBe('crm_account'); + expect(cfg.sourceView).toBe('all_accounts'); + expect(cfg.userActions?.filter).toBe(false); + expect((cfg.userFilters?.fields ?? []).map((f: AnyRec) => f.field)).toEqual([ + 'industry', + 'type', + 'owner_id', + ]); + }); + + it('every locale bundle gives that nav entry a real label', () => { + (['zh-CN', 'es-ES', 'ja-JP'] as const).forEach((locale) => { + const src = readFileSync(join(REPO_ROOT, `src/translations/${locale}.ts`), 'utf8'); + expect( + src, + `${locale}: nav_account_workbench has no label — the docs claim it is not a hidden item`, + ).toMatch(/nav_account_workbench:\s*\{\s*label:/); + }); + }); +});