diff --git a/.changeset/2367-own-work-first-navigation.md b/.changeset/2367-own-work-first-navigation.md new file mode 100644 index 000000000..bc664a17b --- /dev/null +++ b/.changeset/2367-own-work-first-navigation.md @@ -0,0 +1,18 @@ +--- +"hotcrm": minor +--- + +Clause-②: no +Metadata-protocol: none + +**Each persona's own work comes first in the sidebar and in the default views (#2367).** Who may read what is unchanged, and every persona opens the app where it did before. + +- **Sales reps:** the sidebar runs **Sales Home**, then **Sales**. The **Service** group, which held only **Knowledge** for a rep, is no longer shown to a rep; a rep's **Knowledge** is the last entry of **Sales** and opens the published articles, so the search box still finds articles by title. +- **Service agents and service managers:** **Opportunities** and **My Deals** are no longer in their sidebar. Both lists were always empty for them; the deals stay readable from an account's related list. +- **Accounts:** the tabs open with **My Accounts**, **At-Risk Accounts** and **Enterprise Accounts**, ahead of the card and map views, so a rep's own list is no longer in the overflow menu. +- **Opportunities:** **My Open Deals** follows **Open Deals**, ahead of the board, calendar, timeline and card views. +- **SLA Calendar:** shows open cases only; resolved and closed cases are left out. +- **Campaign Schedule** opens at Month zoom and **Contract Terms** at Quarter zoom, instead of Day. +- **All Forecasts:** one heading per period, newest first, instead of two nested headings over each row. + +The documentation site's quick tour, Sales, Cases, SLA and Forecasting pages and the in-app Getting Started page say what each surface does now, in English and Chinese. diff --git a/content/docs/getting-started/quick-tour.mdx b/content/docs/getting-started/quick-tour.mdx index aed4c642c..a78d7d21f 100644 --- a/content/docs/getting-started/quick-tour.mdx +++ b/content/docs/getting-started/quick-tour.mdx @@ -51,7 +51,7 @@ Inside HotCRM, the left nav opens with two pinned items — **Home**, which is t | Group | Contents | | --- | --- | | **Service** | Service Overview, Cases, Knowledge | -| **Sales** | Leads, Accounts, Contacts, Opportunities, Sales Performance, Contracts, Quotes, Products, Price Books | +| **Sales** | Leads, Accounts, Contacts, Opportunities, Sales Performance, Contracts, Quotes, Products, Price Books, Knowledge | | **My Work** | My Tasks, My Deals, My Leads, My Calendar, Inbox, My Cases | | **Activity** | Events, Sales Activity | | **Marketing** | Campaigns | @@ -61,7 +61,7 @@ Inside HotCRM, the left nav opens with two pinned items — **Home**, which is t **Reports** and **Help** are collapsed when the app loads — **Service**, **Sales**, **My Work**, **Activity**, **Marketing** and **Insights** open themselves. Click **Reports** open before deciding a report isn't there. **Help** opens the in-app manuals; **Admin Manual** is shown to administrators only. -The entries built on opportunities — **Home**, **Sales Home**, **Sales Performance**, **CRM Overview** and the four opportunity reports under **Reports** — are left out of the sidebar for anyone who cannot read opportunities, and for a service agent or service manager by position: they can still open their own deals from **Opportunities** and **My Deals**, but these dashboards and reports are not shown to them. Every other report asks the same of its own records: a service agent still gets the lead, account and case reports, and a user who cannot read cases does not get **Service Overview** or the three case reports. **Home** is left out for a sales rep as well, which is why a rep's sidebar starts at **Sales Home**. A sales rep works no cases, so a rep's sidebar also leaves out **Service Overview**, **Cases**, **My Cases** and the three case reports — **Cases by Status and Priority**, **SLA Performance** and **Cases Opened by Day**; **Knowledge** stays, because articles are read while selling too. **Service** is the first group because the app opens on the first entry a sidebar shows: for a service agent or service manager that is **Service Overview**. +The entries built on opportunities — **Home**, **Sales Home**, **Sales Performance**, **CRM Overview** and the four opportunity reports under **Reports** — are left out of the sidebar for anyone who cannot read opportunities, and for a service agent or service manager by position: and as they own no deals, **Opportunities** and **My Deals**, which were always empty for them, are not shown to them either. Every other report asks the same of its own records: a service agent still gets the lead, account and case reports, and a user who cannot read cases does not get **Service Overview** or the three case reports. **Home** is left out for a sales rep as well, which is why a rep's sidebar starts at **Sales Home**. A sales rep works no cases, so a rep's sidebar also leaves out **Service Overview**, **Cases**, **My Cases** and the three case reports — **Cases by Status and Priority**, **SLA Performance** and **Cases Opened by Day**. A rep's **Knowledge** is the last entry of **Sales** instead, opening the published articles, so a rep's sidebar has no **Service** group at all. Everyone else finds **Knowledge** under **Service**. **Service** is the first group because the app opens on the first entry a sidebar shows: for a service agent or service manager that is **Service Overview**. One sidebar row per destination is deliberate. Where an object offers several ways of looking at the same records, those live as tabs across the top of that object's list page, not as extra rows in the nav — so the sidebar stays short enough to scan. diff --git a/content/docs/getting-started/quick-tour.zh-Hans.mdx b/content/docs/getting-started/quick-tour.zh-Hans.mdx index 8719db6f1..45519ae78 100644 --- a/content/docs/getting-started/quick-tour.zh-Hans.mdx +++ b/content/docs/getting-started/quick-tour.zh-Hans.mdx @@ -53,7 +53,7 @@ description: HotCRM 的 5 分钟演练 —— 主页、你的第一条线索、 | 分组 | 内容 | | --- | --- | | **Service** | Service Overview、Cases、Knowledge | -| **Sales** | Leads、Accounts、Contacts、Opportunities、Sales Performance、Contracts、Quotes、Products、Price Books | +| **Sales** | Leads、Accounts、Contacts、Opportunities、Sales Performance、Contracts、Quotes、Products、Price Books、Knowledge | | **My Work** | My Tasks、My Deals、My Leads、My Calendar、Inbox、My Cases | | **Activity** | Events、Sales Activity | | **Marketing** | Campaigns | @@ -65,7 +65,7 @@ description: HotCRM 的 5 分钟演练 —— 主页、你的第一条线索、 **Reports** 和 **Help** 这两个分组在应用加载时是折叠的——**Service**、**Sales**、**My Work**、**Activity**、**Marketing**、**Insights** 都会自动展开。先把 **Reports** 点开,再判断某份报表在不在这里。**Help** 打开应用内手册;**Admin Manual** 只对管理员显示。 -建立在商机之上的条目——**Home**、**Sales Home**、**Sales Performance**、**CRM Overview**,以及 **Reports** 下的四张商机报表——不能读取商机的人在侧边栏里看不到;服务坐席与服务经理也按岗位看不到它们:他们仍可从 **Opportunities** 与 **My Deals** 打开自己的商机,但这些仪表盘与报表不对他们显示。其余报表对各自的数据也是同样的要求:服务坐席照样看得到线索、客户与工单报表,而不能读取工单的人看不到 **Service Overview** 和那三张工单报表。销售代表也看不到 **Home**,所以销售代表的侧边栏从 **Sales Home** 开始。销售代表不处理工单,所以销售代表的侧边栏也看不到 **Service Overview**、**Cases**、**My Cases** 以及那三张工单报表——**Cases by Status and Priority**、**SLA Performance**、**Cases Opened by Day**;**Knowledge** 仍然保留,因为销售时也要查阅知识文章。**Service** 排在所有分组之首,是因为应用打开时落在侧边栏显示的第一个条目上:对服务坐席与服务经理来说,那就是 **Service Overview**。 +建立在商机之上的条目——**Home**、**Sales Home**、**Sales Performance**、**CRM Overview**,以及 **Reports** 下的四张商机报表——不能读取商机的人在侧边栏里看不到;服务坐席与服务经理也按岗位看不到它们:他们名下没有商机,**Opportunities** 与 **My Deals** 对他们总是空的,所以这两个条目也不对他们显示。其余报表对各自的数据也是同样的要求:服务坐席照样看得到线索、客户与工单报表,而不能读取工单的人看不到 **Service Overview** 和那三张工单报表。销售代表也看不到 **Home**,所以销售代表的侧边栏从 **Sales Home** 开始。销售代表不处理工单,所以销售代表的侧边栏也看不到 **Service Overview**、**Cases**、**My Cases** 以及那三张工单报表——**Cases by Status and Priority**、**SLA Performance**、**Cases Opened by Day**。销售代表的 **Knowledge** 改为 **Sales** 的最后一项,打开已发布的文章,所以销售代表的侧边栏里没有 **Service** 分组。其他人仍在 **Service** 下找到 **Knowledge**。**Service** 排在所有分组之首,是因为应用打开时落在侧边栏显示的第一个条目上:对服务坐席与服务经理来说,那就是 **Service Overview**。 一个去处只占一行,这是刻意的。同一个对象如果有多种查看方式,它们是该对象列表页顶部的标签页,而不是导航里另起一行——这样侧边栏才短到能一眼扫完。 diff --git a/content/docs/getting-started/quick-tour.zh-Hant.mdx b/content/docs/getting-started/quick-tour.zh-Hant.mdx index 3ba55f45a..0a4ecd8b8 100644 --- a/content/docs/getting-started/quick-tour.zh-Hant.mdx +++ b/content/docs/getting-started/quick-tour.zh-Hant.mdx @@ -53,7 +53,7 @@ description: HotCRM 的 5 分鐘演練 —— 主頁、你的第一條線索、 | 分組 | 內容 | | --- | --- | | **Service** | Service Overview、Cases、Knowledge | -| **Sales** | Leads、Accounts、Contacts、Opportunities、Sales Performance、Contracts、Quotes、Products、Price Books | +| **Sales** | Leads、Accounts、Contacts、Opportunities、Sales Performance、Contracts、Quotes、Products、Price Books、Knowledge | | **My Work** | My Tasks、My Deals、My Leads、My Calendar、Inbox、My Cases | | **Activity** | Events、Sales Activity | | **Marketing** | Campaigns | @@ -65,7 +65,7 @@ description: HotCRM 的 5 分鐘演練 —— 主頁、你的第一條線索、 **Reports** 和 **Help** 這兩個群組在應用載入時是摺疊的——**Service**、**Sales**、**My Work**、**Activity**、**Marketing**、**Insights** 都會自動展開。先把 **Reports** 點開,再判斷某份報表在不在這裡。**Help** 開啟應用內手冊;**Admin Manual** 只對管理員顯示。 -建立在商機之上的條目——**Home**、**Sales Home**、**Sales Performance**、**CRM Overview**,以及 **Reports** 下的四張商機報表——不能讀取商機的人在側邊欄裡看不到;服務專員與服務經理也按職位看不到它們:他們仍可從 **Opportunities** 與 **My Deals** 打開自己的商機,但這些儀表板與報表不對他們顯示。其餘報表對各自的資料也是同樣的要求:服務專員照樣看得到線索、客戶與工單報表,而不能讀取工單的人看不到 **Service Overview** 和那三張工單報表。銷售代表也看不到 **Home**,所以銷售代表的側邊欄從 **Sales Home** 開始。銷售代表不處理工單,所以銷售代表的側邊欄也看不到 **Service Overview**、**Cases**、**My Cases** 以及那三張工單報表——**Cases by Status and Priority**、**SLA Performance**、**Cases Opened by Day**;**Knowledge** 仍然保留,因為銷售時也要查閱知識文章。**Service** 排在所有群組之首,是因為應用打開時落在側邊欄顯示的第一個條目上:對服務專員與服務經理來說,那就是 **Service Overview**。 +建立在商機之上的條目——**Home**、**Sales Home**、**Sales Performance**、**CRM Overview**,以及 **Reports** 下的四張商機報表——不能讀取商機的人在側邊欄裡看不到;服務專員與服務經理也按職位看不到它們:他們名下沒有商機,**Opportunities** 與 **My Deals** 對他們總是空的,所以這兩個條目也不對他們顯示。其餘報表對各自的資料也是同樣的要求:服務專員照樣看得到線索、客戶與工單報表,而不能讀取工單的人看不到 **Service Overview** 和那三張工單報表。銷售代表也看不到 **Home**,所以銷售代表的側邊欄從 **Sales Home** 開始。銷售代表不處理工單,所以銷售代表的側邊欄也看不到 **Service Overview**、**Cases**、**My Cases** 以及那三張工單報表——**Cases by Status and Priority**、**SLA Performance**、**Cases Opened by Day**。銷售代表的 **Knowledge** 改為 **Sales** 的最後一項,開啟已發布的文章,所以銷售代表的側邊欄裡沒有 **Service** 群組。其他人仍在 **Service** 下找到 **Knowledge**。**Service** 排在所有群組之首,是因為應用打開時落在側邊欄顯示的第一個條目上:對服務專員與服務經理來說,那就是 **Service Overview**。 一個去處只佔一列,這是刻意的。同一個物件如果有多種檢視方式,它們是該物件列表頁頂部的標籤頁,而不是導覽裡另起一列——這樣側邊欄才短到能一眼掃完。 diff --git a/content/docs/sales/forecasting.mdx b/content/docs/sales/forecasting.mdx index b8156cd23..cc5ac0e09 100644 --- a/content/docs/sales/forecasting.mdx +++ b/content/docs/sales/forecasting.mdx @@ -47,7 +47,7 @@ The advice is therefore what it always was — when you write a snapshot by hand **A row stored before these rules shipped stays refused until its window is corrected.** The check reads the whole record on every save, not just the field you touched, so even an unrelated edit to such a row — a quota correction, a note — is rejected until the dates agree with the label. The correction itself is always accepted: put `period_start` and `period_end` back on the calendar period, and the row saves normally again. -So you'll never see a record labelled “2026-07-01 through ???” — only the human label. A forecast's own title adds its owner after that label — **Q4 2026 · Nina Reyes** — so two reps' forecasts for the same quarter read apart on the record, in Recent items and in a lookup. A forecast with no owner is titled by its label alone. The name is taken from **Owner** when the forecast is saved: reassign it and the title follows; rename the user and the title changes at the forecast's next save. Dashboards group by `period_label` directly. The **All Forecasts** list groups on two levels, newest period first: by `period_start`, and under each start date by `period_label`, so every period gets a heading of its own (**Q4 2026**). It cannot group by the label alone, because a label sorts as text: *Sep 2026* would come before *Q4 2026*, and *Q4 2025* before *Oct 2026*. Nor can it group by the start date alone, because a quarter and the month that opens it start on the same day: **Q4 2026** and **Oct 2026** share one start-date heading and sit under it as two periods. +So you'll never see a record labelled “2026-07-01 through ???” — only the human label. A forecast's own title adds its owner after that label — **Q4 2026 · Nina Reyes** — so two reps' forecasts for the same quarter read apart on the record, in Recent items and in a lookup. A forecast with no owner is titled by its label alone. The name is taken from **Owner** when the forecast is saved: reassign it and the title follows; rename the user and the title changes at the forecast's next save. Dashboards group by `period_label` directly. The **All Forecasts** list groups by `period_label`, one heading per period (**Q4 2026**), newest period first: the headings are ordered by each period's `period_start`, not by the label's text, which would put *Sep 2026* before *Q4 2026* and *Q4 2025* before *Oct 2026*. It groups by the label rather than by the start date because a quarter and the month that opens it start on the same day: **Q4 2026** and **Oct 2026** each get a heading of their own. ## How forecasts get created diff --git a/content/docs/sales/forecasting.zh-Hans.mdx b/content/docs/sales/forecasting.zh-Hans.mdx index fecb8a0d8..00fa5d404 100644 --- a/content/docs/sales/forecasting.zh-Hans.mdx +++ b/content/docs/sales/forecasting.zh-Hans.mdx @@ -47,7 +47,7 @@ description: 按负责人和周期的销售管道快照——营收预测的基 **在这些规则上线之前就已经存错的行,会一直被拒收,直到它的窗口被改正。** 这项校验每次保存时读的是整条记录,而不只是你改动的那个字段,所以哪怕是一处无关的编辑——改个配额、加条备注——也会被拒绝,直到日期和标签对得上。改正本身则永远是被允许的:把 `period_start` 和 `period_end` 拨回到对应的日历周期,这一行就又能正常保存了。 -因此你绝不会看到一条标记为"2026-07-01 至 ???"的记录——只有人类可读的标签。预测记录本身的标题在这个标签后面加上负责人——**Q4 2026 · Nina Reyes**——所以两位销售同一季度的预测,在记录页、最近访问和查找框里都能区分开。没有负责人的预测,标题就只是标签本身。名字在保存预测时从**负责人**读取:把预测改派给别人,标题随之改变;用户改名后,标题要等这条预测下一次保存时才更新。仪表盘会直接按 `period_label` 分组。**全部预测**列表则分两层分组,最新的周期排在最前:先按 `period_start`,再在每个起始日之下按 `period_label`,所以每个周期都有自己的一行标题(**Q4 2026**)。它不能只按标签分组,因为标签是按文本排序的:*Sep 2026* 会排在 *Q4 2026* 前面,*Q4 2025* 会排在 *Oct 2026* 前面。它也不能只按起始日分组,因为一个季度和开启它的那个月是同一天开始的:**Q4 2026** 和 **Oct 2026** 共用同一个起始日标题,在它之下作为两个周期分开列出。 +因此你绝不会看到一条标记为"2026-07-01 至 ???"的记录——只有人类可读的标签。预测记录本身的标题在这个标签后面加上负责人——**Q4 2026 · Nina Reyes**——所以两位销售同一季度的预测,在记录页、最近访问和查找框里都能区分开。没有负责人的预测,标题就只是标签本身。名字在保存预测时从**负责人**读取:把预测改派给别人,标题随之改变;用户改名后,标题要等这条预测下一次保存时才更新。仪表盘会直接按 `period_label` 分组。**全部预测**列表按 `period_label` 分组,每个周期一个标题(**Q4 2026**),最新的周期排在最前:标题按各周期的 `period_start` 排序,而不是按标签的文本排序——按文本排,*Sep 2026* 会排在 *Q4 2026* 前面,*Q4 2025* 会排在 *Oct 2026* 前面。它按标签而不是按起始日分组,因为一个季度和开启它的那个月是同一天开始的:**Q4 2026** 和 **Oct 2026** 各有自己的标题。 ## 预测是如何创建的 diff --git a/content/docs/sales/forecasting.zh-Hant.mdx b/content/docs/sales/forecasting.zh-Hant.mdx index d406bdd40..0e79e18d7 100644 --- a/content/docs/sales/forecasting.zh-Hant.mdx +++ b/content/docs/sales/forecasting.zh-Hant.mdx @@ -49,7 +49,7 @@ description: 按負責人和週期的銷售管道快照——營收預測的基 **在這些規則上線之前就已經存錯的資料列,會一直被拒收,直到它的區間被改正。** 這項檢核每次儲存時讀的是整筆記錄,而不只是你改動的那個欄位,所以哪怕是一處無關的編輯——改個配額、加條備註——也會被拒絕,直到日期和標籤對得上。改正本身則永遠是被允許的:把 `period_start` 和 `period_end` 撥回到對應的日曆週期,這筆資料列就又能正常儲存了。 -因此你絕不會看到一筆標記為「2026-07-01 至 ???」的記錄——只有人類可讀的標籤。預測記錄本身的標題在這個標籤後面加上負責人——**Q4 2026 · Nina Reyes**——所以兩位業務同一季度的預測,在記錄頁、最近存取和查找框裡都能區分開。沒有負責人的預測,標題就只是標籤本身。名字在儲存預測時從**負責人**讀取:把預測改派給別人,標題隨之改變;使用者改名後,標題要等這筆預測下一次儲存時才更新。儀表板會直接按 `period_label` 分組。**全部預測**清單則分兩層分組,最新的週期排在最前:先按 `period_start`,再在每個起始日之下按 `period_label`,所以每個週期都有自己的一列標題(**Q4 2026**)。它不能只按標籤分組,因為標籤是按文字排序的:*Sep 2026* 會排在 *Q4 2026* 前面,*Q4 2025* 會排在 *Oct 2026* 前面。它也不能只按起始日分組,因為一個季度和開啟它的那個月是同一天開始的:**Q4 2026** 和 **Oct 2026** 共用同一個起始日標題,在它之下作為兩個週期分開列出。 +因此你絕不會看到一筆標記為「2026-07-01 至 ???」的記錄——只有人類可讀的標籤。預測記錄本身的標題在這個標籤後面加上負責人——**Q4 2026 · Nina Reyes**——所以兩位業務同一季度的預測,在記錄頁、最近存取和查找框裡都能區分開。沒有負責人的預測,標題就只是標籤本身。名字在儲存預測時從**負責人**讀取:把預測改派給別人,標題隨之改變;使用者改名後,標題要等這筆預測下一次儲存時才更新。儀表板會直接按 `period_label` 分組。**全部預測**清單按 `period_label` 分組,每個週期一個標題(**Q4 2026**),最新的週期排在最前:標題按各週期的 `period_start` 排序,而不是按標籤的文字排序——按文字排,*Sep 2026* 會排在 *Q4 2026* 前面,*Q4 2025* 會排在 *Oct 2026* 前面。它按標籤而不是按起始日分組,因為一個季度和開啟它的那個月是同一天開始的:**Q4 2026** 和 **Oct 2026** 各有自己的標題。 ## 預測是如何建立的 diff --git a/content/docs/sales/index.mdx b/content/docs/sales/index.mdx index 7a14088a3..1145df0b8 100644 --- a/content/docs/sales/index.mdx +++ b/content/docs/sales/index.mdx @@ -60,14 +60,15 @@ Just type into the chat: *"qualify this lead"*, *"draft a follow-up email"*, *"w ## Where to find things -The **HotCRM app** sidebar groups everything under **Sales**, and that group carries nine entries — eight object entries plus one that opens something other than an object list: +The **HotCRM app** sidebar groups everything under **Sales**, and that group carries ten entries — eight object entries plus two that open something other than an object list: - Leads · Accounts · Contacts · Opportunities · Contracts · Quotes · Products · Price Books - **Sales Performance** — your daily KPIs, from the `sales_dashboard` dashboard. No sidebar item is called *Sales Dashboard*. +- **Knowledge** — for sales reps only: the published knowledge articles, so a rep finds them here and through the search box. Everyone else finds **Knowledge** under **Service**, which a rep's sidebar does not show. **Products** joined this group rather than **Marketing**: the catalogue is the master data every quote line and opportunity line item points at, so it is revenue data that happened to be filed next to campaigns — see [Products](/docs/revenue/products). -In the sidebar, **Contracts** follows **Sales Performance**, and **Quotes**, **Products** and **Price Books** come last in the group: they ship in HotCRM's revenue module, and a module's entries follow the entries the app itself places in a group. +In the sidebar, **Contracts** follows **Sales Performance**, and **Quotes**, **Products** and **Price Books** follow it: they ship in HotCRM's revenue module, and a module's entries follow the entries the app itself places in a group. A rep's **Knowledge** comes last, from the service module. There is no *Pipeline* entry in the sidebar. The kanban board is a tab on the **Opportunities** list page, where it sits beside the other ways of reading the same deals; the view carries its own label, **Sales Pipeline** (`pipeline_kanban`). diff --git a/content/docs/sales/index.zh-Hans.mdx b/content/docs/sales/index.zh-Hans.mdx index ce0e383e6..9fad0bc10 100644 --- a/content/docs/sales/index.zh-Hans.mdx +++ b/content/docs/sales/index.zh-Hans.mdx @@ -60,14 +60,15 @@ Lead ──► Account + Contact ──► Opportunity ──► Quote ## 在哪里找到这些功能 -**HotCRM 应用**的侧边栏将所有内容归到**销售**之下,这个分组一共九个条目——八个对象入口,外加一个打开的不是对象列表的条目: +**HotCRM 应用**的侧边栏将所有内容归到**销售**之下,这个分组一共十个条目——八个对象入口,外加两个打开的不是对象列表的条目: - 线索 · 客户 · 联系人 · 商机 · 合同 · 报价 · 产品 · 价格表 - **销售业绩**(Sales Performance)——你的每日 KPI,来自 `sales_dashboard` 仪表盘。没有任何侧边栏条目叫 *销售仪表盘* / *Sales Dashboard*。 +- **知识库**(Knowledge)——只对销售代表显示:已发布的知识文章,销售代表在这里、也能通过搜索框找到它们。其他人在**服务**分组下找到**知识库**,销售代表的侧边栏不显示这个分组。 **产品**(Products)归在本分组而不是**市场营销**下:产品目录是每一条报价行项目和商机行项目所指向的主数据,本质上是收入侧的数据,只是当初被顺手放在了营销旁边——参见 [Products](/zh-Hans/docs/revenue/products)。 -在侧边栏里,**合同**排在**销售业绩**(Sales Performance)之后,**报价**、**产品**和**价格表**排在本分组最后:它们属于 HotCRM 的收入模块,而模块贡献的条目排在应用自己放进分组的条目之后。 +在侧边栏里,**合同**排在**销售业绩**(Sales Performance)之后,**报价**、**产品**和**价格表**排在其后:它们属于 HotCRM 的收入模块,而模块贡献的条目排在应用自己放进分组的条目之后。销售代表的**知识库**来自服务模块,排在最后。 侧边栏里没有 *销售管道*(Pipeline)这一条。看板是**商机**列表页上的一个标签页,与查看同一批交易的其他方式并列;那个视图自身的 label 是**销售流水线**(Sales Pipeline,`pipeline_kanban`)。 diff --git a/content/docs/sales/index.zh-Hant.mdx b/content/docs/sales/index.zh-Hant.mdx index ddedd85df..dc73d7c3b 100644 --- a/content/docs/sales/index.zh-Hant.mdx +++ b/content/docs/sales/index.zh-Hant.mdx @@ -62,14 +62,15 @@ Lead ──► Account + Contact ──► Opportunity ──► Quote ## 在哪裡找到這些功能 -**HotCRM 應用**的側邊欄將所有內容歸到**銷售**之下,這個分組一共九個條目——八個物件入口,外加一個打開的不是物件清單的條目: +**HotCRM 應用**的側邊欄將所有內容歸到**銷售**之下,這個分組一共十個條目——八個物件入口,外加兩個打開的不是物件清單的條目: - 潛在客戶 · 客戶 · 聯絡人 · 商機 · 合約 · 報價 · 產品 · 價格表 - **銷售業績**(Sales Performance)——你的每日 KPI,來自 `sales_dashboard` 儀表板。沒有任何側邊欄條目叫 *銷售儀表板* / *Sales Dashboard*。 +- **知識庫**(Knowledge)——只對銷售代表顯示:已發布的知識文章,銷售代表在這裡、也能透過搜尋框找到它們。其他人在**服務**分組下找到**知識庫**,銷售代表的側邊欄不顯示這個分組。 **產品**(Products)歸在本分組而不是**行銷**之下:產品目錄是每一條報價行項目與商機行項目所指向的主資料,本質上是收入側的資料,只是當初被順手放在了行銷旁邊——參見 [Products](/zh-Hant/docs/revenue/products)。 -在側邊欄裡,**合約**排在**銷售業績**(Sales Performance)之後,**報價**、**產品**和**價格表**排在本分組最後:它們屬於 HotCRM 的收入模組,而模組貢獻的條目排在應用自己放進分組的條目之後。 +在側邊欄裡,**合約**排在**銷售業績**(Sales Performance)之後,**報價**、**產品**和**價格表**排在其後:它們屬於 HotCRM 的收入模組,而模組貢獻的條目排在應用自己放進分組的條目之後。銷售代表的**知識庫**來自服務模組,排在最後。 側邊欄裡沒有 *銷售管道*(Pipeline)這一條。看板是**商機**清單頁上的一個標籤頁,與檢視同一批交易的其他方式並列;那個檢視自身的 label 是**銷售流水線**(Sales Pipeline,`pipeline_kanban`)。 diff --git a/content/docs/service/cases.mdx b/content/docs/service/cases.mdx index 375002eaa..42654e155 100644 --- a/content/docs/service/cases.mdx +++ b/content/docs/service/cases.mdx @@ -174,7 +174,7 @@ Nothing else claims a case, and each exclusion is deliberate: | --- | --- | | **All Cases** | Every case, sorted by priority then SLA due date; a case flagged **SLA Violated** is tinted red, every other row stays plain | | **Service Workflow** | Kanban of open cases, grouped by status | -| **SLA Calendar** | Cases laid out on their **SLA Due Date** — every case carries one now, so the calendar shows the whole open backlog | +| **SLA Calendar** | Live cases laid out on their **SLA Due Date** — every case carries one, and resolved and closed cases are left out, so the calendar shows the open backlog's deadlines | | **Case Timeline** | Cases on a *created → closed* timeline, grouped by owner | | **Escalated Cases** | `is_escalated` is true | | **Unassigned — triage** | Open cases with no owner at all, urgent first then soonest due — where a web-to-case submission lands when nobody holds the Service Agent position (see [Case routing](/docs/administration/setup#12-case-routing--nothing-to-configure)) | diff --git a/content/docs/service/cases.zh-Hans.mdx b/content/docs/service/cases.zh-Hans.mdx index 322002e4a..bef54ddb5 100644 --- a/content/docs/service/cases.zh-Hans.mdx +++ b/content/docs/service/cases.zh-Hans.mdx @@ -174,7 +174,7 @@ description: 客户支持工单——主题、优先级、状态、解决方案 | --- | --- | | **全部工单** | 全部工单,先按优先级、再按 SLA 截止日期排序;已标记 **SLA Violated** 的工单整行标红,其余行不着色 | | **服务流转** | 未结工单的看板,按状态分列 | -| **SLA 日历** | 按 **SLA 截止日期** 把工单铺在日历上——现在每张工单都带截止日期,所以它显示的是整个未结积压 | +| **SLA 日历** | 按 **SLA 截止日期** 把未结工单铺在日历上——每张工单都带截止日期,已解决和已关闭的工单不显示,所以它显示的是未结积压的截止日 | | **工单时间线** | 工单按「创建 → 关闭」铺在时间轴上,按负责人分组 | | **已升级工单** | `is_escalated` 为真 | | **未分派 — 待分诊** | 完全没有负责人的未结工单,先按紧急程度、再按最近的截止日期——没有人担任服务坐席时,Web 提交的工单就落在这里(见[工单路由](/zh-Hans/docs/administration/setup#12-工单路由--没有需要配置的东西)) | diff --git a/content/docs/service/cases.zh-Hant.mdx b/content/docs/service/cases.zh-Hant.mdx index 0a64bc9a1..7e96abcd6 100644 --- a/content/docs/service/cases.zh-Hant.mdx +++ b/content/docs/service/cases.zh-Hant.mdx @@ -176,7 +176,7 @@ description: 客戶支援工單——主題、優先順序、狀態、解決方 | --- | --- | | **全部工單** | 全部工單,先按優先順序、再按 SLA 截止日期排序;已標記 **SLA Violated** 的工單整列標紅,其餘列不著色 | | **服務流轉** | 未結工單的看板,按狀態分欄 | -| **SLA 日曆** | 按 **SLA 截止日期** 把工單鋪在行事曆上——現在每張工單都帶截止日期,所以它顯示的是整個未結積壓 | +| **SLA 日曆** | 按 **SLA 截止日期** 把未結工單鋪在行事曆上——每張工單都帶截止日期,已解決和已關閉的工單不顯示,所以它顯示的是未結積壓的截止日 | | **工單時間線** | 工單按「建立 → 關閉」鋪在時間軸上,按負責人分組 | | **已升級工單** | `is_escalated` 為真 | | **未分派 — 待分診** | 完全沒有負責人的未結工單,先按緊急程度、再按最近的截止日期——沒有人擔任服務座席時,Web 提交的工單就落在這裡(見[工單路由](/zh-Hant/docs/administration/setup#12-工單路由--沒有需要設定的東西)) | diff --git a/content/docs/service/sla-and-escalation.mdx b/content/docs/service/sla-and-escalation.mdx index 9a1f5af6d..d2d4c5aa7 100644 --- a/content/docs/service/sla-and-escalation.mdx +++ b/content/docs/service/sla-and-escalation.mdx @@ -47,7 +47,7 @@ The breach flag is **read-only for agents** — they can't fudge it. Nobody type Two views help with that, neither of them a timer: -- **SLA Calendar** lays cases out on their **SLA Due Date**, so in practice it shows the Critical ones and whatever else was dated by hand. +- **SLA Calendar** lays the live cases out on their **SLA Due Date**: every case carries one, and resolved and closed cases are left out, so it shows the open backlog's deadlines. - **SLA at Risk** lists open **High and Critical** cases sorted by due date, soonest first. It selects on priority, not on how close the deadline is — so it is a priority queue that happens to be ordered by deadline, and Medium and Low cases never appear in it however close they are to breaching. ## SLA performance reporting diff --git a/content/docs/service/sla-and-escalation.zh-Hans.mdx b/content/docs/service/sla-and-escalation.zh-Hans.mdx index 91a7ddce4..7513a837d 100644 --- a/content/docs/service/sla-and-escalation.zh-Hans.mdx +++ b/content/docs/service/sla-and-escalation.zh-Hans.mdx @@ -47,7 +47,7 @@ SLA(服务级别协议)系统为**每一张工单**打上一个**截止时 有两个视图可以帮上忙,但它们都不是计时器: -- **SLA 日历** 按 **SLA 截止日期**把工单铺在日历上;现在每张工单都带截止日期,所以它显示的是整个未结积压。 +- **SLA 日历** 按 **SLA 截止日期**把未结工单铺在日历上:每张工单都带截止日期,已解决和已关闭的工单不显示,所以它显示的是未结积压的截止日。 - **SLA 风险预警** 列出打开的 **High 与 Critical** 工单,按截止日期升序排列。它筛的是优先级,不是"离截止还有多近"——所以它是一个恰好按期限排序的优先级队列,Medium 与 Low 工单无论离违约多近都不会出现在里面。 ## SLA 表现报表 diff --git a/content/docs/service/sla-and-escalation.zh-Hant.mdx b/content/docs/service/sla-and-escalation.zh-Hant.mdx index 191680711..ed7415d9f 100644 --- a/content/docs/service/sla-and-escalation.zh-Hant.mdx +++ b/content/docs/service/sla-and-escalation.zh-Hant.mdx @@ -49,7 +49,7 @@ SLA(服務級別協議)系統為**每一張工單**打上一個**截止時 有兩個檢視可以幫上忙,但它們都不是計時器: -- **SLA Calendar** 按 **SLA 截止日期**把工單鋪在日曆上;現在每張工單都帶截止日期,所以它顯示的是整個未結積壓。 +- **SLA Calendar** 按 **SLA 截止日期**把未結工單鋪在日曆上:每張工單都帶截止日期,已解決和已關閉的工單不顯示,所以它顯示的是未結積壓的截止日。 - **SLA at Risk** 列出打開的 **High 與 Critical** 工單,按截止日期升序排列。它篩的是優先順序,不是"離截止還有多近"——所以它是一個恰好按期限排序的優先順序佇列,Medium 與 Low 工單無論離違約多近都不會出現在裡面。 ## SLA 表現報表 diff --git a/src/docs/crm_getting_started.md b/src/docs/crm_getting_started.md index 091cdb4c6..fbfd4229a 100644 --- a/src/docs/crm_getting_started.md +++ b/src/docs/crm_getting_started.md @@ -26,9 +26,11 @@ the job you hold: | A service agent or service manager | **Service Overview** — the case dashboard, at the top of **Service** | | A sales manager, a marketer or an administrator | **Home** — the **Executive Overview** dashboard | -The sidebar only shows what you are allowed to read: a sales rep sees no cases, -and a service agent sees no pipeline dashboards. If you expected an entry and it -is not there, your access is the reason, not a fault — ask your administrator. +The sidebar only shows what you are allowed to read and what your job uses: a +sales rep sees no cases, and finds **Knowledge** at the end of **Sales** rather +than under **Service**; a service agent sees no pipeline dashboards and no deal +lists. If you expected an entry and it is not there, your access is the reason, +not a fault — ask your administrator. ## Your own work: My Work @@ -38,7 +40,8 @@ for them: - **My Tasks** — your open tasks, the next thing to do first. Tasks the system creates for you (a follow-up on a stalled deal, an escalation) land here too; see [Activities](./crm_sales_activities.md). -- **My Deals** and **My Leads** — the opportunities and leads you own. +- **My Deals** and **My Leads** — the opportunities and leads you own. Service + staff, who own no deals, see **My Leads** only. - **My Calendar** — your meetings and calls. - **Inbox** — approval requests waiting on you (below). - **My Cases** — the cases you own, for service staff. diff --git a/src/docs/crm_getting_started.zh.md b/src/docs/crm_getting_started.zh.md index 09d883e75..71c26942d 100644 --- a/src/docs/crm_getting_started.zh.md +++ b/src/docs/crm_getting_started.zh.md @@ -17,14 +17,14 @@ description: HotCRM 为你打开在哪里、我的工作队列、待我审批, | 客服专员或服务经理 | **服务概览**——工单仪表盘,位于**服务**分组顶部 | | 销售经理、市场人员或管理员 | **首页**——**Executive Overview** 仪表盘 | -侧边栏只显示你有权查看的内容:销售代表看不到工单,客服专员看不到管道类仪表盘。如果你以为会有的入口不在,原因是你的权限,而不是故障——请联系你的管理员。 +侧边栏只显示你有权查看、并且你的工作用得到的内容:销售代表看不到工单,**知识库**在**销售**分组的最后,而不在**服务**下;客服专员看不到管道类仪表盘,也看不到商机列表。如果你以为会有的入口不在,原因是你的权限,而不是故障——请联系你的管理员。 ## 你自己的工作:我的工作 **我的工作**汇集了属于你的记录,你很少需要再去搜索: - **我的任务**——你的待办任务,最先要做的排在前面。系统为你创建的任务(停滞商机的跟进、工单升级)也会出现在这里;见[活动](./crm_sales_activities.md)。 -- **我的商机**和**我的线索**——你负责的商机和线索。 +- **我的商机**和**我的线索**——你负责的商机和线索。服务人员名下没有商机,只看到**我的线索**。 - **我的日历**——你的会议和电话。 - **待我审批**——等你处理的审批请求(见下文)。 - **我的工单**——你负责的工单,供服务人员使用。 diff --git a/src/marketing/views/campaign.view.ts b/src/marketing/views/campaign.view.ts index 6199b49e3..f027d3da1 100644 --- a/src/marketing/views/campaign.view.ts +++ b/src/marketing/views/campaign.view.ts @@ -56,6 +56,10 @@ export const CampaignViews = defineView({ startDateField: 'start_date', endDateField: 'end_date', titleField: 'name', + // Opens at Month (#2367 row 4). Without it the chart opened at Day, + // a one-week window, and a campaign runs for weeks: every bar ran off + // both edges. The viewer's own zoom, once saved, still wins over this. + viewMode: 'month', }, }, diff --git a/src/sales/apps/crm.app.ts b/src/sales/apps/crm.app.ts index b0742e7bb..c2460a1e8 100644 --- a/src/sales/apps/crm.app.ts +++ b/src/sales/apps/crm.app.ts @@ -106,8 +106,9 @@ const EXECUTIVE_AUDIENCE = P`current_user.can("crm_opportunity", "read") && !("s * `EXECUTIVE_AUDIENCE` above says why they are asked by position. Since #2231 * the read term alone showed them all seven, each scoped to the deals they * own. A rep keeps them: Sales Home is a rep's landing. ⛔ Do not narrow - * `crm_user` instead (#2231); the object entries (Opportunities, My Deals, - * Leads…) keep no position term and stay visible to them. + * `crm_user` instead (#2231). The two opportunity object entries ask the + * position half only (`NOT_SERVICE_STAFF` below); the other object entries + * (Leads, Accounts…) keep no position term and stay visible to them. * * Each gate spells the positions out: `P` interpolates a value as a literal, * never as an expression, so a sub-predicate cannot be shared by reference. @@ -115,6 +116,21 @@ const EXECUTIVE_AUDIENCE = P`current_user.can("crm_opportunity", "read") && !("s */ const OPPORTUNITY_AUDIENCE = P`current_user.can("crm_opportunity", "read") && !("service_agent" in current_user.positions) && !("service_manager" in current_user.positions)`; +/** + * The two opportunity OBJECT entries, Opportunities and My Deals (#2367 + * row 6): everyone but the two service personas, as #2268 did for the + * opportunity dashboards and reports above. Since #2231 a service persona + * reads the deals they own through `crm_user`, and they own none, so both + * lists were always empty for them. Tidying, not a boundary (#2287): the + * object stays readable by URL, from an account's related list and over + * REST, exactly as before. + * + * No read term: an object entry is pruned by read access already (the note + * on `OPPORTUNITY_AUDIENCE` above), so the position half is the whole of what + * this gate adds. + */ +const NOT_SERVICE_STAFF = P`!("service_agent" in current_user.positions) && !("service_manager" in current_user.positions)`; + /** * HotCRM — navigation. * @@ -270,7 +286,7 @@ export const CrmApp = App.create({ // zero clicks further than a second sidebar row. The // view-entry exemplar duty this item used to carry now sits with the // "My Work" items below, which are all object + `viewName`. - { id: 'nav_opportunity', type: 'object', objectName: 'crm_opportunity', label: 'Opportunities', icon: 'target' }, + { id: 'nav_opportunity', type: 'object', objectName: 'crm_opportunity', label: 'Opportunities', icon: 'target', visible: NOT_SERVICE_STAFF }, { id: 'nav_sales_dashboard', type: 'dashboard', dashboardName: 'sales_dashboard', label: 'Sales Performance', icon: 'chart-line', visible: OPPORTUNITY_AUDIENCE }, // Contracts close the sales cycle: quote → signed agreement → renewal. // The app's own entry since `crm_contract` moved into this package @@ -305,7 +321,7 @@ export const CrmApp = App.create({ // `component` entry, and the app's only one. children: [ { id: 'nav_my_tasks', type: 'object', objectName: 'crm_task', viewName: 'my_open_tasks', label: 'My Tasks', icon: 'circle-check' }, - { id: 'nav_my_deals', type: 'object', objectName: 'crm_opportunity', viewName: 'my_open_deals', label: 'My Deals', icon: 'target' }, + { id: 'nav_my_deals', type: 'object', objectName: 'crm_opportunity', viewName: 'my_open_deals', label: 'My Deals', icon: 'target', visible: NOT_SERVICE_STAFF }, { id: 'nav_my_leads', type: 'object', objectName: 'crm_lead', viewName: 'my_leads', label: 'My Leads', icon: 'user-plus' }, // #592 — the rep's own calendar: a list of events, so a view, not a // dashboard. diff --git a/src/sales/translations/es-ES/app.ts b/src/sales/translations/es-ES/app.ts index 3d67d587f..a97c5995e 100644 --- a/src/sales/translations/es-ES/app.ts +++ b/src/sales/translations/es-ES/app.ts @@ -57,6 +57,7 @@ export const appSurface: Omit = { group_service: { label: 'Servicio' }, nav_case: { label: 'Casos' }, nav_knowledge: { label: 'Conocimiento' }, + nav_knowledge_published: { label: 'Conocimiento' }, nav_service_dashboard: { label: 'Resumen de Servicio' }, group_marketing: { label: 'Marketing' }, diff --git a/src/sales/translations/ja-JP/app.ts b/src/sales/translations/ja-JP/app.ts index 65fe76547..0ce157ed8 100644 --- a/src/sales/translations/ja-JP/app.ts +++ b/src/sales/translations/ja-JP/app.ts @@ -60,6 +60,7 @@ export const appSurface: Omit = { group_service: { label: 'サービス' }, nav_case: { label: 'ケース' }, nav_knowledge: { label: 'ナレッジベース' }, + nav_knowledge_published: { label: 'ナレッジベース' }, nav_service_dashboard: { label: 'サービス概要' }, group_insights: { label: 'インサイト' }, diff --git a/src/sales/translations/zh-CN/app.ts b/src/sales/translations/zh-CN/app.ts index b378d0a43..73d023cfc 100644 --- a/src/sales/translations/zh-CN/app.ts +++ b/src/sales/translations/zh-CN/app.ts @@ -65,6 +65,7 @@ export const appSurface: Omit = { group_service: { label: '服务' }, nav_case: { label: '工单' }, nav_knowledge: { label: '知识库' }, + nav_knowledge_published: { label: '知识库' }, nav_service_dashboard: { label: '服务概览' }, group_insights: { label: '数据洞察' }, diff --git a/src/sales/views/account.view.ts b/src/sales/views/account.view.ts index a43e5c9d0..06b70d3a4 100644 --- a/src/sales/views/account.view.ts +++ b/src/sales/views/account.view.ts @@ -93,51 +93,40 @@ export const AccountViews = defineView({ }, listViews: { - /** Branded account cards */ - account_gallery: { - name: 'account_gallery', - type: 'gallery', - label: 'Account Cards', + // The working lists lead the strip, a rep's own first (#2367 row 2): + // declaration order IS tab order, and behind the gallery and the map + // My Accounts sat in the "1 more" overflow at 1280px. The same correction + // `lead.view.ts` made for My Leads. The gallery and the map follow. + my_accounts: { + name: 'my_accounts', + type: 'grid', + label: 'My Accounts', + userActions: { editInline: true, hideFields: true }, data: { provider: 'object', object: 'crm_account' }, - // `logo` rides in the projection for the same reason the timeline view - // carries its date fields: the list data source projects the configured - // columns, and `coverField` reads off that projection. - columns: ['name', 'industry', 'annual_revenue', 'phone', 'logo'], - gallery: { - // The card art this view was named for. "Branded account cards" had a - // brand COLOUR and no brand IMAGE — `crm_account.logo` is the object's - // only image field and nothing displayed it, so every card drew the - // same blank cover. `coverFit` is left unstated: the schema's default - // is the crop these cards want, and materialising a default rewrites - // "the author said nothing" into "the author asked for this". - coverField: 'logo', - cardSize: 'medium', - titleField: 'name', - visibleFields: ['industry', 'annual_revenue', 'number_of_employees', 'phone', 'website', 'owner_id'], - }, + columns: ['name', 'industry', 'annual_revenue', 'phone', 'last_activity_date'], + filter: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], + sort: [{ field: 'last_activity_date', order: 'desc' }], }, - /** - * Geospatial distribution. - * - * The `map` block is what BINDS the view to a coordinate source. Without it - * the renderer falls back to literal default field names, which resolve on - * `crm_account` only by luck — the view draws no marker while authoring - * reports success (`view/layout-without-binding`). `office_location` is the - * object's `Field.location(...)`, which is the one field on `crm_account` - * carrying coordinates; `billing_address` is an `address` field and is a - * postal shape, not a coordinate one, so it stays a column rather than the - * binding. `titleField` names the marker label — `name`, not the object's - * `nameField` (`display_title`), because the map's own `columns` show - * `name` and the two should agree. - */ - account_map: { - name: 'account_map', - type: 'map', - label: 'Accounts by Location', + // The `renewals_due` view is gone with the fields it was built on (#1181): + // both its `is_not_null` filter and its sort key were + // `crm_account.next_renewal_date`, so the view could not outlive the field. + // Nothing is lost — `crm_contract` ships the renewals queue that the daily + // sweep actually acts on (`renewal_calendar`, over `end_date`). + + /** At-risk customers — CSM action queue */ + at_risk_accounts: { + name: 'at_risk_accounts', + type: 'grid', + label: '⚠️ At-Risk Accounts', + userActions: { editInline: true, hideFields: true }, data: { provider: 'object', object: 'crm_account' }, - columns: ['name', 'industry', 'office_location', 'billing_address'], - map: { locationField: 'office_location', titleField: 'name' }, + columns: ['name', 'tier', 'health_score', 'segment', 'last_activity_date'], + filter: [ + { field: 'type', operator: 'equals', value: 'customer' }, + { field: 'health_score', operator: 'in', value: ['at_risk', 'churning'] }, + ], + sort: [{ field: 'health_score', order: 'desc' }], }, /** Tier accounts: Enterprise (>= $10M ARR) */ @@ -177,36 +166,51 @@ export const AccountViews = defineView({ } as any), }, - my_accounts: { - name: 'my_accounts', - type: 'grid', - label: 'My Accounts', - userActions: { editInline: true, hideFields: true }, + /** Branded account cards */ + account_gallery: { + name: 'account_gallery', + type: 'gallery', + label: 'Account Cards', data: { provider: 'object', object: 'crm_account' }, - columns: ['name', 'industry', 'annual_revenue', 'phone', 'last_activity_date'], - filter: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], - sort: [{ field: 'last_activity_date', order: 'desc' }], + // `logo` rides in the projection for the same reason the timeline view + // carries its date fields: the list data source projects the configured + // columns, and `coverField` reads off that projection. + columns: ['name', 'industry', 'annual_revenue', 'phone', 'logo'], + gallery: { + // The card art this view was named for. "Branded account cards" had a + // brand COLOUR and no brand IMAGE — `crm_account.logo` is the object's + // only image field and nothing displayed it, so every card drew the + // same blank cover. `coverFit` is left unstated: the schema's default + // is the crop these cards want, and materialising a default rewrites + // "the author said nothing" into "the author asked for this". + coverField: 'logo', + cardSize: 'medium', + titleField: 'name', + visibleFields: ['industry', 'annual_revenue', 'number_of_employees', 'phone', 'website', 'owner_id'], + }, }, - // The `renewals_due` view is gone with the fields it was built on (#1181): - // both its `is_not_null` filter and its sort key were - // `crm_account.next_renewal_date`, so the view could not outlive the field. - // Nothing is lost — `crm_contract` ships the renewals queue that the daily - // sweep actually acts on (`renewal_calendar`, over `end_date`). - - /** At-risk customers — CSM action queue */ - at_risk_accounts: { - name: 'at_risk_accounts', - type: 'grid', - label: '⚠️ At-Risk Accounts', - userActions: { editInline: true, hideFields: true }, + /** + * Geospatial distribution. + * + * The `map` block is what BINDS the view to a coordinate source. Without it + * the renderer falls back to literal default field names, which resolve on + * `crm_account` only by luck — the view draws no marker while authoring + * reports success (`view/layout-without-binding`). `office_location` is the + * object's `Field.location(...)`, which is the one field on `crm_account` + * carrying coordinates; `billing_address` is an `address` field and is a + * postal shape, not a coordinate one, so it stays a column rather than the + * binding. `titleField` names the marker label — `name`, not the object's + * `nameField` (`display_title`), because the map's own `columns` show + * `name` and the two should agree. + */ + account_map: { + name: 'account_map', + type: 'map', + label: 'Accounts by Location', data: { provider: 'object', object: 'crm_account' }, - columns: ['name', 'tier', 'health_score', 'segment', 'last_activity_date'], - filter: [ - { field: 'type', operator: 'equals', value: 'customer' }, - { field: 'health_score', operator: 'in', value: ['at_risk', 'churning'] }, - ], - sort: [{ field: 'health_score', order: 'desc' }], + columns: ['name', 'industry', 'office_location', 'billing_address'], + map: { locationField: 'office_location', titleField: 'name' }, }, }, diff --git a/src/sales/views/contract.view.ts b/src/sales/views/contract.view.ts index 3ef71be03..9652f975e 100644 --- a/src/sales/views/contract.view.ts +++ b/src/sales/views/contract.view.ts @@ -73,6 +73,9 @@ export const ContractViews = defineView({ startDateField: 'start_date', endDateField: 'end_date', titleField: 'contract_number', + // Opens at Quarter (#2367 row 4): a contract term runs a year or + // more, so the Day default showed one week of one bar. + viewMode: 'quarter', }, }, diff --git a/src/sales/views/forecast.view.ts b/src/sales/views/forecast.view.ts index c4c8f6eaf..f15476c89 100644 --- a/src/sales/views/forecast.view.ts +++ b/src/sales/views/forecast.view.ts @@ -36,31 +36,22 @@ export const ForecastViews = defineView({ { field: 'period_start', order: 'desc' }, { field: 'owner_id', order: 'asc' }, ], - // Newest period first, each under a heading a manager can read (#2060 - // item 11, C-09). A grouping field orders its groups by its OWN value, and - // nothing else: the spec's `GroupingFieldSchema` is `field` / `order` / - // `collapsed`, with no key that orders one field's groups by another. So - // grouping on the text `period_label` sorted the periods as text ("Sep - // 2026 → Q4 2026 → Q4 2025 …"). Hence two levels: + // Newest period first, one heading per period, titled by its label (#2060 + // item 11, then #2367 row 5). A grouping field orders its groups by its + // own value unless `orderBy` names another: grouping on the text + // `period_label` alone ran the periods as text ("Sep 2026 → Q4 2026 → + // Q4 2025 …"), so until 18.2.0 the view grouped twice, `period_start` + // then `period_label`, and every period drew two nested headings over one + // row. `orderBy` (objectstack#25786, in 18.2.0) places each label's group + // where its newest `period_start` falls, so one level does both jobs. // - // • `period_start` desc puts the periods in time order. Its heading is - // the stored date as the console draws a date key today: raw - // (`2026-10-01`, measured on 18.0.0-next.5), not the column's "Oct 1". - // • `period_label` names the period under it ("Q4 2026"). It cannot be - // dropped: a quarter and the month that opens it start on the same - // day (Q4 2026 and Oct 2026), so `period_start` alone would put a - // quarter's row and a month's row under one heading. `desc` puts the - // quarter's heading ahead of that month's ("Q" sorts after every - // month that opens a quarter). - // - // The single-level form (one heading per period, ordered by - // `period_start`, titled by `period_label`) is not expressible in the - // spec today; it is a platform gap, reported on the card rather than - // routed around here (AGENTS.md "Scope"). + // The label is the group key, not the date: a quarter and the month that + // opens it start on the same day (Q4 2026 and Oct 2026), and grouping by + // `period_start` would put both rows under one heading. Those two tie on + // the order key; their relative order is the platform's. grouping: { fields: [ - { field: 'period_start', order: 'desc' }, - { field: 'period_label', order: 'desc' }, + { field: 'period_label', order: 'desc', orderBy: 'period_start' }, ], }, pagination: { pageSize: 50 }, diff --git a/src/sales/views/opportunity.view.ts b/src/sales/views/opportunity.view.ts index 2f0ff61c5..b11c21ac4 100644 --- a/src/sales/views/opportunity.view.ts +++ b/src/sales/views/opportunity.view.ts @@ -128,6 +128,29 @@ export const OpportunityViews = defineView({ }, listViews: { + /** Personal pipeline */ + // My Open Deals comes straight after Open Deals, the primary list (#2367 + // row 2): declaration order IS tab order, and behind the board, the + // calendar, the timeline and the cards a rep's own queue sat in the + // overflow menu at 1280px. + my_open_deals: { + name: 'my_open_deals', + bulkActionDefs: [...GRID_BAR], + type: 'grid', + label: 'My Open Deals', + userActions: { editInline: true, hideFields: true }, + data: { provider: 'object', object: 'crm_opportunity' }, + columns: ['name', 'crm_account', 'stage', 'amount', 'close_date'], + // "Open" has to exclude BOTH closed stages. Excluding only closed_won + // meant a rep's own queue opened on their lost deals, sorted to the top + // by close_date — the least actionable rows in the system. + filter: [ + { field: 'owner_id', operator: 'equals', value: '{current_user_id}' }, + { field: 'stage', operator: 'not_in', value: ['closed_won', 'closed_lost'] }, + ], + sort: [{ field: 'close_date', order: 'asc' }], + }, + /** * All Opportunities — the unfiltered book, including closed business. * @@ -230,25 +253,6 @@ export const OpportunityViews = defineView({ }, }, - /** Personal pipeline */ - my_open_deals: { - name: 'my_open_deals', - bulkActionDefs: [...GRID_BAR], - type: 'grid', - label: 'My Open Deals', - userActions: { editInline: true, hideFields: true }, - data: { provider: 'object', object: 'crm_opportunity' }, - columns: ['name', 'crm_account', 'stage', 'amount', 'close_date'], - // "Open" has to exclude BOTH closed stages. Excluding only closed_won - // meant a rep's own queue opened on their lost deals, sorted to the top - // by close_date — the least actionable rows in the system. - filter: [ - { field: 'owner_id', operator: 'equals', value: '{current_user_id}' }, - { field: 'stage', operator: 'not_in', value: ['closed_won', 'closed_lost'] }, - ], - sort: [{ field: 'close_date', order: 'asc' }], - }, - /** Stale Opportunities — still-open deals, longest-parked first */ stale_opportunities: { name: 'stale_opportunities', diff --git a/src/service/index.ts b/src/service/index.ts index 973336267..d1aaf3ef0 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -78,7 +78,12 @@ export type HotCrmServiceCollections = Pick; * position out each: `P` interpolates a value as a literal, never as an * expression, so the sub-predicate cannot be shared by reference. * - * Knowledge stays open to a rep: articles are read while selling too. + * Knowledge asks it too since #2367 row 1, and a rep gets it in the Sales + * group instead (`SALES_REP` below). It had stayed in the Service group for a + * rep, since articles are read while selling, which left a rep a Service group + * of that one entry between Sales Home and the Sales group. A group whose + * every entry is hidden is not drawn, so a rep's sidebar now runs Sales Home, + * then Sales. * * Landing (#2060 item 5) is unmoved: a rep's first shown entry is Sales Home, * ahead of the Service group, and a service persona or the administrator @@ -86,6 +91,20 @@ export type HotCrmServiceCollections = Pick; */ const NOT_A_SALES_REP = P`!("sales_rep" in current_user.positions)`; +/** + * The rep's own Knowledge entry, folded into the Sales group (#2367 row 1): + * exactly the members `NOT_A_SALES_REP` refuses, so every member is shown one + * Knowledge entry and no member two. + * + * Folded, not hidden: the console's search box (⌘K) looks for records only in + * the objects of the entries the member is shown (measured on 18.2.0, its + * `GET /api/v1/search?objects=…`), so a rep with no Knowledge entry could no + * longer find an article by its title. The entry opens the Published view, + * the articles a rep reads, which also keeps it a different destination from + * the Service group's plain list (`test/app-navigation-shape.test.ts`, #1259). + */ +const SALES_REP = P`"sales_rep" in current_user.positions`; + /** * The read gate on this package's dashboard and report entries (#2073 item 1, * #2338 item 1), narrowed to the audience above. @@ -175,7 +194,7 @@ export const createHotCrmServiceStack = ( dependencies: { 'app.objectstack.hotcrm': '^6.0.0' }, /** - * The seven navigation entries this module puts into the app's menu + * The eight navigation entries this module puts into the app's menu * (ADR-0029 D7). * * Not a design choice: an app's own `navigation` may not name another @@ -183,8 +202,9 @@ export const createHotCrmServiceStack = ( * `module-split-plan.md`), so this package's entries became contributions * the moment `crm_case`, `crm_knowledge_article`, `service_dashboard` and * the case reports left the app package. The app keeps the GROUPS - * (`group_service`, `group_work`, `group_reports`) — a container a module - * cannot declare inside an app it does not own — and this module fills them. + * (`group_service`, `group_sales`, `group_work`, `group_reports`) — a + * container a module cannot declare inside an app it does not own — and + * this module fills them. * * `app` is the app's bare `name` (`crm_enterprise`), not the manifest id: a * dotted id is refused as a non-snake_case identifier. @@ -203,8 +223,9 @@ export const createHotCrmServiceStack = ( * member who reads no cases (the marketing user) would otherwise get a * dashboard and three pages of 403s (`CASE_READ_AUDIENCE` above). The app's * `READS_OPPORTUNITIES` note in `crm.app.ts` says why it is `visible` and - * not `requiredPermissions`. Every case entry but Knowledge also asks that - * the member is not a sales rep (`NOT_A_SALES_REP` above, #2244). + * not `requiredPermissions`. Every entry of the Service group, and My Cases, + * also asks that the member is not a sales rep (`NOT_A_SALES_REP` above, + * #2244; Knowledge since #2367). */ navigationContributions: [ { @@ -220,7 +241,18 @@ export const createHotCrmServiceStack = ( items: [ { id: 'nav_service_dashboard', type: 'dashboard', dashboardName: 'service_dashboard', label: 'Service Overview', icon: 'gauge', visible: CASE_READ_AUDIENCE }, { id: 'nav_case', type: 'object', objectName: 'crm_case', label: 'Cases', icon: 'life-buoy', visible: NOT_A_SALES_REP }, - { id: 'nav_knowledge', type: 'object', objectName: 'crm_knowledge_article', label: 'Knowledge', icon: 'book-open' }, + { id: 'nav_knowledge', type: 'object', objectName: 'crm_knowledge_article', label: 'Knowledge', icon: 'book-open', visible: NOT_A_SALES_REP }, + ], + }, + { + app: 'crm_enterprise', + // A rep's Knowledge (#2367 row 1, `SALES_REP` above), last in Sales: + // after the app's own children and after the revenue module's entries + // (priority 40). + group: 'group_sales', + priority: 60, + items: [ + { id: 'nav_knowledge_published', type: 'object', objectName: 'crm_knowledge_article', viewName: 'published_articles', label: 'Knowledge', icon: 'book-open', visible: SALES_REP }, ], }, { diff --git a/src/service/views/case.view.ts b/src/service/views/case.view.ts index cc4e91949..b1f1a3cde 100644 --- a/src/service/views/case.view.ts +++ b/src/service/views/case.view.ts @@ -105,6 +105,12 @@ export const CaseViews = defineView({ label: 'SLA Calendar', data, columns: ['case_number', 'subject', 'priority'], + // Live cases only (#2367 row 3), the same cut as My Open Cases and SLA at + // Risk: a resolved or closed case keeps its `sla_due_date`, and the + // calendar drew those beside the live ones, so an agent planning the day + // read deadlines that no longer bind anyone. `is_closed` alone would keep + // the resolved ones (it is derived from `closed` only). + filter: [{ field: 'status', operator: 'not_in', value: ['resolved', 'closed'] }], calendar: { startDateField: 'sla_due_date', titleField: 'subject', diff --git a/test/app-navigation-audience.test.ts b/test/app-navigation-audience.test.ts index 87cccfc5c..e9adc645e 100644 --- a/test/app-navigation-audience.test.ts +++ b/test/app-navigation-audience.test.ts @@ -59,7 +59,7 @@ const LEAVES: Rec[] = (() => { return walk(NAV); })(); -/** The six entries a rep no longer sees (#2244). Knowledge is not one of them. */ +/** The six entries a rep no longer sees (#2244). Knowledge was not one of them; it left with #2367 (its block below). */ const SERVICE_ONLY = [ 'nav_service_dashboard', 'nav_case', 'nav_my_cases', 'nav_report_cases_by_status', 'nav_report_sla', 'nav_report_cases_opened_by_day', @@ -160,10 +160,8 @@ describe('a sales rep is not shown the service entries (#2244)', () => { } }); - it.each(REPS)('%s is shown none of the six, and still Knowledge', (rep) => { - const shown = shownTo(rep); - expect(shown.filter((id) => SERVICE_ONLY.includes(id))).toEqual([]); - expect(shown).toContain('nav_knowledge'); + it.each(REPS)('%s is shown none of the six', (rep) => { + expect(shownTo(rep).filter((id) => SERVICE_ONLY.includes(id))).toEqual([]); }); it.each(['sales_manager', 'service_agent', 'service_manager', 'admin'])( @@ -174,10 +172,12 @@ describe('a sales rep is not shown the service entries (#2244)', () => { }, ); - it('the two reps are shown the same sidebar: the sales manager\'s, less Home and the six', () => { + it('the two reps are shown the same sidebar: the sales manager\'s, less Home and the six (and, since #2367, Knowledge moved into Sales)', () => { const manager = shownTo('sales_manager'); for (const rep of REPS) { - expect(shownTo(rep), rep).toEqual(manager.filter((id) => id !== 'nav_home' && !SERVICE_ONLY.includes(id))); + expect(shownTo(rep).filter((id) => id !== 'nav_knowledge_published'), rep).toEqual( + manager.filter((id) => id !== 'nav_home' && id !== 'nav_knowledge' && !SERVICE_ONLY.includes(id)), + ); } }); }); @@ -255,10 +255,12 @@ describe('a service persona is not shown the opportunity dashboards and reports expect(shownTo(persona).filter((id) => OPPORTUNITY_ONLY.includes(id))).toEqual([]); }); - it.each(SERVICE)('%s is shown its old sidebar less the six, nothing else, and the object entries stay', (persona) => { + it.each(SERVICE)('%s is shown its old sidebar less the six, nothing else, and the other object entries stay', (persona) => { const shown = shownTo(persona); expect(shown).toEqual(shownTo(persona, BEFORE_2268).filter((id) => !OPPORTUNITY_ONLY.includes(id))); - expect(shown).toEqual(expect.arrayContaining(['nav_opportunity', 'nav_my_deals', 'nav_lead'])); + // The object entries stayed with #2268. Two of them, Opportunities and My + // Deals, left with #2367 (its block below); the rest stay. + expect(shown).toEqual(expect.arrayContaining(['nav_lead', 'nav_account', 'nav_contact'])); }); it.each(SERVICE)('%s still lands on Service Overview', (persona) => { @@ -272,6 +274,97 @@ describe('a service persona is not shown the opportunity dashboards and reports }); }); +/** + * Each persona's own work first (#2367, UX card 3, rows 1 and 6). + * + * Row 6: Opportunities and My Deals are not offered to the two service + * personas. They read opportunities through `crm_user` (#2231) and own none, + * so both lists were always empty for them; the entries now ask the position, + * as #2268 did for the opportunity dashboards and reports. + * + * Row 1: a rep's Service group held Knowledge alone, between Sales Home and + * Sales. Knowledge now asks the rep position too, so a rep is shown no entry + * of the group, and a group with no entry shown is not drawn (measured in the + * 18.2.0 console on the PR): the rep's sidebar runs Sales Home, then Sales. + * The rep's Knowledge is folded into Sales instead of dropped, as a Published + * entry only a rep is shown: the console's search box looks for records only + * in the objects of the entries shown, so without it a rep could no longer + * find an article. + * + * `BEFORE_2367` is the gates the three entries carried before (none), and + * the folded entry, which did not exist, as an entry nobody is shown. + */ +describe("each persona's own work first: service staff lose the empty deal lists, a rep the one-entry Service group (#2367)", () => { + const BEFORE_2367: Record = { + nav_opportunity: undefined, nav_my_deals: undefined, nav_knowledge: undefined, nav_knowledge_published: P`false`, + }; + const DEALS = ['nav_opportunity', 'nav_my_deals']; + const SERVICE = ['service_agent', 'service_manager']; + const OTHERS = ['sales_manager', 'admin', 'marketing_user']; + const ALL = [...PERSONAS, 'admin', 'marketing_user']; + + /** The served groups, each with the ids of its entries. */ + const GROUPS = NAV.filter((n) => n.type === 'group').map((g) => ({ + id: g.id as string, + entries: ((g.children ?? []) as Rec[]).map((c) => c.id as string), + })); + /** The groups `persona` is shown at least one entry of. */ + const groupsShownTo = (persona: string, before?: Record): string[] => { + const shown = shownTo(persona, before); + return GROUPS.filter((g) => g.entries.some((id) => shown.includes(id))).map((g) => g.id); + }; + + it.each(SERVICE)('%s reads opportunities, and the gates before #2367 showed it both deal lists', (persona) => { + expect(who[persona]!.objects.crm_opportunity?.allowRead).toBe(true); + expect(DEALS.filter((id) => !shownTo(persona, BEFORE_2367).includes(id))).toEqual([]); + }); + + it.each(SERVICE)('%s is shown its old sidebar less the two deal lists, nothing else', (persona) => { + expect(shownTo(persona)).toEqual(shownTo(persona, BEFORE_2367).filter((id) => !DEALS.includes(id))); + }); + + it.each(REPS)('the gates before #2367 showed %s a Service group of Knowledge alone', (rep) => { + const before = shownTo(rep, BEFORE_2367); + const service = GROUPS.find((g) => g.id === 'group_service')!; + expect(service.entries.filter((id) => before.includes(id))).toEqual(['nav_knowledge']); + }); + + it.each(REPS)('%s is shown no Service group: Sales follows Sales Home, and ends with Knowledge', (rep) => { + expect(groupsShownTo(rep)).not.toContain('group_service'); + expect(groupsShownTo(rep)[0]).toBe('group_sales'); + expect(shownTo(rep).slice(0, 2)).toEqual(['nav_sales_home', 'nav_lead']); + const sales = GROUPS.find((g) => g.id === 'group_sales')!.entries.filter((id) => shownTo(rep).includes(id)); + expect(sales.at(-1)).toBe('nav_knowledge_published'); + }); + + it.each(REPS)('%s is shown its old sidebar with Knowledge moved, nothing else', (rep) => { + expect(shownTo(rep).filter((id) => id !== 'nav_knowledge_published')).toEqual( + shownTo(rep, BEFORE_2367).filter((id) => id !== 'nav_knowledge'), + ); + expect(shownTo(rep).filter((id) => id.startsWith('nav_knowledge'))).toEqual(['nav_knowledge_published']); + }); + + it('the folded entry opens the published articles, on the object the Service group\'s entry opens', () => { + const folded = LEAVES.find((n) => n.id === 'nav_knowledge_published')!; + const plain = LEAVES.find((n) => n.id === 'nav_knowledge')!; + expect(folded).toMatchObject({ type: 'object', objectName: plain.objectName, viewName: 'published_articles' }); + }); + + it.each(ALL)('%s is shown exactly one Knowledge entry', (persona) => { + expect(shownTo(persona).filter((id) => id.startsWith('nav_knowledge')), persona).toHaveLength( + who[persona]!.objects.crm_knowledge_article?.allowRead === true ? 1 : 0, + ); + }); + + it.each(OTHERS)('%s is shown exactly the sidebar it was shown before', (persona) => { + expect(shownTo(persona)).toEqual(shownTo(persona, BEFORE_2367)); + }); + + it.each(ALL)('%s lands where it landed before', (persona) => { + expect(shownTo(persona)[0]).toBe(shownTo(persona, BEFORE_2367)[0]); + }); +}); + /** * Service Overview is offered only to a member who reads cases (#2338 item 1). * diff --git a/test/app-navigation-shape.test.ts b/test/app-navigation-shape.test.ts index 9957d49d0..b72461888 100644 --- a/test/app-navigation-shape.test.ts +++ b/test/app-navigation-shape.test.ts @@ -200,9 +200,14 @@ describe('entries built on opportunities are gated on reading them (#2060 item 5 const other = LEAVES.filter( (n) => n.type !== 'report' && n.visible !== undefined && sourceOf(n.visible) !== GATE, ).map((n) => n.id); - expect(other, 'entries gated on something else').toEqual(['nav_home']); - for (const id of other) { - expect(String(sourceOf(LEAVES.find((n) => n.id === id)?.visible)), id).toMatch(/^current_user\.can\("crm_opportunity", "read"\) && /); + expect(other, 'entries gated on something else').toEqual(['nav_home', 'nav_opportunity', 'nav_my_deals']); + expect(String(sourceOf(NAV[0].visible)), 'nav_home').toMatch(/^current_user\.can\("crm_opportunity", "read"\) && /); + // The two opportunity OBJECT entries ask the position half alone (#2367 + // row 6): the console prunes an object entry by read access already. + for (const id of ['nav_opportunity', 'nav_my_deals']) { + expect(String(sourceOf(LEAVES.find((n) => n.id === id)?.visible)), id).toBe( + '!("service_agent" in current_user.positions) && !("service_manager" in current_user.positions)', + ); } }); diff --git a/test/docs-sales-index-navigation.test.ts b/test/docs-sales-index-navigation.test.ts index 14aacad2a..c2464199c 100644 --- a/test/docs-sales-index-navigation.test.ts +++ b/test/docs-sales-index-navigation.test.ts @@ -99,7 +99,7 @@ const PAGES = [ lang: 'en', heading: '## Where to find things', /** The group size, stated in prose. */ - count: /nine entries/, + count: /ten entries/, /** The en page names the object entries in English, so they are checked verbatim. */ checkObjectLabels: true, }, @@ -107,14 +107,14 @@ const PAGES = [ file: 'content/docs/sales/index.zh-Hans.mdx', lang: 'zh-Hans', heading: '## 在哪里找到这些功能', - count: /九个条目/, + count: /十个条目/, checkObjectLabels: false, }, { file: 'content/docs/sales/index.zh-Hant.mdx', lang: 'zh-Hant', heading: '## 在哪裡找到這些功能', - count: /九個條目/, + count: /十個條目/, checkObjectLabels: false, }, ] as const; @@ -181,7 +181,10 @@ describe('sales/index lists the whole Sales navigation group (#997)', () => { }); describe('the source facts that section now rests on (#997)', () => { - it('the Sales group holds nine entries, in this order', () => { + // Ten since #2367 row 1: a rep's Knowledge, folded in from the Service + // group the rep is no longer shown. The group holds it for every member; + // only a rep is shown it (`app-navigation-audience.test.ts`). + it('the Sales group holds ten entries, in this order', () => { expect(SALES_CHILDREN.map((c) => c.label)).toEqual([ 'Leads', 'Accounts', @@ -192,6 +195,7 @@ describe('the source facts that section now rests on (#997)', () => { 'Quotes', 'Products', 'Price Books', + 'Knowledge', ]); }); @@ -205,10 +209,10 @@ describe('the source facts that section now rests on (#997)', () => { expect(((authored?.children ?? []) as AnyRec[]).map((c) => c.id)).toEqual([ 'nav_lead', 'nav_account', 'nav_contact', 'nav_opportunity', 'nav_sales_dashboard', 'nav_contract', ]); - expect(SALES_CHILDREN.slice(-3).map((c) => c.id)).toEqual(['nav_quote', 'nav_product', 'nav_price_book']); + expect(SALES_CHILDREN.slice(-4).map((c) => c.id)).toEqual(['nav_quote', 'nav_product', 'nav_price_book', 'nav_knowledge_published']); }); - it('eight of them open an object list; one opens something else', () => { + it('eight of them open an object list; two open something else', () => { expect(OBJECT_ENTRIES.map((c) => c.label)).toEqual([ 'Leads', 'Accounts', @@ -219,7 +223,7 @@ describe('the source facts that section now rests on (#997)', () => { 'Products', 'Price Books', ]); - expect(OTHER_ENTRIES.map((c) => c.label)).toEqual(['Sales Performance']); + expect(OTHER_ENTRIES.map((c) => c.label)).toEqual(['Sales Performance', 'Knowledge']); }); it('Products is a Sales entry, not a Marketing one (#1259)', () => { diff --git a/test/forecast-list-grouping.test.ts b/test/forecast-list-grouping.test.ts index 9fccb55bd..cb7fb71c2 100644 --- a/test/forecast-list-grouping.test.ts +++ b/test/forecast-list-grouping.test.ts @@ -5,18 +5,19 @@ import { ForecastViews } from '../src/sales/views/forecast.view'; import { Forecast } from '../src/sales/objects/forecast.object'; /** - * All Forecasts lists its periods newest first, each under a heading a manager - * can read (#2060 item 11, C-09). + * All Forecasts lists its periods newest first, each under one heading a + * manager can read (#2060 item 11, then #2367 row 5). * * The list grouped by the TEXT `period_label`, descending, so the periods ran * as text: "Sep 2026 → Q4 2026 → Q4 2025 → Q3 2026 … → Oct 2026 → Aug 2026" - * (measured in the console on 18.0.0-next.5). A grouping field orders its - * groups by its own value and the spec offers no key to order one field's - * groups by another, so the view groups on two levels: `period_start` - * (a date, so `desc` is time order) and, under it, `period_label`, the heading - * that names the period. The second level is not decoration: a quarter and - * the month that opens it share a `period_start`, and without it their rows - * would sit under one heading. + * (measured in the console on 18.0.0-next.5). The spec then offered no key to + * order one field's groups by another, so the view grouped on two levels, + * `period_start` then `period_label`, and every period drew two nested + * headings over one row (the #2320 browser pass, 18.2.0). The grouping field's + * `orderBy` (objectstack#25786, in 18.2.0) orders the label's groups by + * `period_start`, so one level does both jobs. The group key stays the label: + * a quarter and the month that opens it share a `period_start`, and grouping + * by the date would put both rows under one heading. * * What the console draws from this declaration was measured in a browser and * is recorded on the PR, not re-implemented here: the header rows and their @@ -27,22 +28,21 @@ import { Forecast } from '../src/sales/objects/forecast.object'; const allForecasts = ForecastViews.list as unknown as { name: string; sort: Array<{ field: string; order: string }>; - grouping: { fields: Array<{ field: string; order?: string }> }; + grouping: { fields: Array<{ field: string; order?: string; orderBy?: string }> }; }; -describe('All Forecasts groups its periods in time order (#2060 item 11)', () => { +describe('All Forecasts groups its periods once, in time order (#2060 item 11, #2367 row 5)', () => { it('is the view this pin is about', () => { expect(allForecasts.name).toBe('all_forecasts'); }); - it('groups by Period Start, newest first, and names each period under it by its label', () => { - expect(allForecasts.grouping.fields.map((g) => [g.field, g.order])).toEqual([ - ['period_start', 'desc'], - ['period_label', 'desc'], + it('groups once, by the period label, ordered by Period Start, newest first', () => { + expect(allForecasts.grouping.fields.map((g) => [g.field, g.order, g.orderBy])).toEqual([ + ['period_label', 'desc', 'period_start'], ]); }); - it('the outer level is a date, so its descending order is time order, and the inner level is the text label', () => { + it('the order key is a date, so its descending order is time order, and the group key is the text label', () => { const fields = Forecast.fields as Record; expect(fields.period_start!.type).toBe('date'); expect(fields.period_label!.type).toBe('text');