diff --git a/.changeset/2338-browser-pass-polish.md b/.changeset/2338-browser-pass-polish.md new file mode 100644 index 000000000..950db351a --- /dev/null +++ b/.changeset/2338-browser-pass-polish.md @@ -0,0 +1,16 @@ +--- +"hotcrm": patch +--- + +**Seven small fixes from the 18.2.0 browser pass: copy that pointed nowhere, a timeline that ran backwards, an attendee list with no names, and refusals that showed API names (#2338, items 5 and 7–13).** + +- **Sales Home** no longer shows the *Ask the AI Assistant* card. It told every user to open an assistant panel from the right edge of the page, and an install without the platform's assistant has no such panel. +- **Deal Timeline** marks each deal on its **Close Date**. It used to draw each deal from the day it was created to its close date, so a deal closed before it was entered read backwards ("10/27/2026 → 9/7/2026"). +- The **Event Attendees** list on a meeting now shows who is attending: the **Contact**, **Lead**, **User** or **External Attendee** column, then type, response and organizer. A column nobody on that meeting fills is left out. +- Moving a completed task back to an open status clears its **Completed Date**. Before, the date stayed and nobody could clear it by hand, because the field is read-only. +- The **Reports** sidebar entry **Lead Inflow** is now **Lead Engagement**. Its report has always counted leads by the month they were last contacted, not the month they arrived. +- **Top Resolving Articles** on the Service dashboard no longer lists a "—" row with 0 for closed cases that no article resolved. +- When a closed deal, an accepted or expired quote, or an activated contract refuses an edit, the message the user sees names fields and statuses by their labels ("is closed (Closed Won); only its Description and Next Steps can still be edited"). The error code is still `RECORD_LOCKED` (409). For the deal and the quote, the diagnostic `message` that API callers and logs read still carries the API names and the attempted fields; the user sentence rides `userMessage`. The contract's single message changed from "Cannot shrink end_date (…)" to "An activated contract's End Date cannot move earlier (…)". +- The admin guide lists the twelfth sharing rule, **Forecasts — Sales Manager Edit**. + +**FROM → TO.** A tool that matched the contract refusal's old wording should match on the `RECORD_LOCKED` code instead. No data migration is needed; a task reopened before this release keeps its old Completed Date until it is completed and reopened again. diff --git a/content/docs/analytics/index.mdx b/content/docs/analytics/index.mdx index 18e409b23..d1b05c697 100644 --- a/content/docs/analytics/index.mdx +++ b/content/docs/analytics/index.mdx @@ -48,7 +48,7 @@ This means **one dashboard** works for the whole org — what each person sees d In the HotCRM app, analytics lives in two groups. **Insights** opens itself when the app loads; **Reports** is collapsed: - **Insights** — **CRM Overview**, the cross-functional dashboard, and **Forecasts**, the forecast records themselves. -- **Reports** — every report the app ships, one entry each: **Opportunities by Stage**, **Won Opportunities by Owner**, **Pipeline Coverage**, **Opportunity Funnel by Owner**, **Lead Inflow**, **Accounts by Industry & Type**, **Customer Churn Signals**, **Cases by Status and Priority**, **SLA Performance** and **Cases Opened by Day**. A few entries carry a shorter name than the report's own title; [Reports](/docs/analytics/reports) uses the titles. +- **Reports** — every report the app ships, one entry each: **Opportunities by Stage**, **Won Opportunities by Owner**, **Pipeline Coverage**, **Opportunity Funnel by Owner**, **Lead Engagement**, **Accounts by Industry & Type**, **Customer Churn Signals**, **Cases by Status and Priority**, **SLA Performance** and **Cases Opened by Day**. A few entries carry a shorter name than the report's own title; [Reports](/docs/analytics/reports) uses the titles. You see the entry for a report when you can read the records it counts. A service agent, who reads no opportunities, gets the lead, account and case reports; a user who reads no cases does not get the three case reports. diff --git a/content/docs/analytics/index.zh-Hans.mdx b/content/docs/analytics/index.zh-Hans.mdx index 78b6e3431..5f1bbc3f0 100644 --- a/content/docs/analytics/index.zh-Hans.mdx +++ b/content/docs/analytics/index.zh-Hans.mdx @@ -48,7 +48,7 @@ HotCRM 分析将你的 CRM 活动转化为**决策**。三个层次协同工作 在 HotCRM 应用里,分析内容分在两个分组下。**Insights** 在应用加载时自动展开,**Reports** 是折叠的: - **Insights** —— 跨职能的那块仪表盘 **CRM Overview**,以及预测记录本身 **Forecasts**。 -- **Reports** —— 应用自带的每一张报表,各占一个条目:**Opportunities by Stage**、**Won Opportunities by Owner**、**Pipeline Coverage**、**Opportunity Funnel by Owner**、**Lead Inflow**、**Accounts by Industry & Type**、**Customer Churn Signals**、**Cases by Status and Priority**、**SLA Performance**、**Cases Opened by Day**。有几个条目的名字比报表自身的标题短;[报表](/zh-Hans/docs/analytics/reports) 一页用的是标题。 +- **Reports** —— 应用自带的每一张报表,各占一个条目:**Opportunities by Stage**、**Won Opportunities by Owner**、**Pipeline Coverage**、**Opportunity Funnel by Owner**、**Lead Engagement**、**Accounts by Industry & Type**、**Customer Churn Signals**、**Cases by Status and Priority**、**SLA Performance**、**Cases Opened by Day**。有几个条目的名字比报表自身的标题短;[报表](/zh-Hans/docs/analytics/reports) 一页用的是标题。 能读取一张报表统计的记录,才看得到它的条目。服务坐席读不了商机,看到的是线索、客户与工单报表;读不了工单的人看不到那三张工单报表。 diff --git a/content/docs/analytics/index.zh-Hant.mdx b/content/docs/analytics/index.zh-Hant.mdx index ab58e2426..fc9f91d54 100644 --- a/content/docs/analytics/index.zh-Hant.mdx +++ b/content/docs/analytics/index.zh-Hant.mdx @@ -50,7 +50,7 @@ HotCRM 分析將你的 CRM 活動轉化為**決策**。三個層次協同運作 在 HotCRM 應用裡,分析內容分在兩個群組下。**Insights** 在應用載入時自動展開,**Reports** 是摺疊的: - **Insights** —— 跨職能的那塊儀表板 **CRM Overview**,以及預測記錄本身 **Forecasts**。 -- **Reports** —— 應用自帶的每一張報表,各佔一個條目:**Opportunities by Stage**、**Won Opportunities by Owner**、**Pipeline Coverage**、**Opportunity Funnel by Owner**、**Lead Inflow**、**Accounts by Industry & Type**、**Customer Churn Signals**、**Cases by Status and Priority**、**SLA Performance**、**Cases Opened by Day**。有幾個條目的名字比報表自身的標題短;[報表](/zh-Hant/docs/analytics/reports) 一頁用的是標題。 +- **Reports** —— 應用自帶的每一張報表,各佔一個條目:**Opportunities by Stage**、**Won Opportunities by Owner**、**Pipeline Coverage**、**Opportunity Funnel by Owner**、**Lead Engagement**、**Accounts by Industry & Type**、**Customer Churn Signals**、**Cases by Status and Priority**、**SLA Performance**、**Cases Opened by Day**。有幾個條目的名字比報表自身的標題短;[報表](/zh-Hant/docs/analytics/reports) 一頁用的是標題。 能讀取一張報表統計的記錄,才看得到它的條目。服務專員讀不了商機,看到的是線索、客戶與工單報表;讀不了工單的人看不到那三張工單報表。 diff --git a/content/docs/analytics/reports.mdx b/content/docs/analytics/reports.mdx index f17cbc1b4..077787b78 100644 --- a/content/docs/analytics/reports.mdx +++ b/content/docs/analytics/reports.mdx @@ -11,7 +11,7 @@ Reports are filterable, exportable lists with grouping and summary totals. Where Every report below has an entry in the sidebar's **Reports** group. The group is collapsed when the app loads, so click it open first; ⌘K also finds a report by its sidebar name. You only get an entry for a report whose records you can read: a service agent, who reads no opportunities, has no entry for the four opportunity reports, and a user who reads no cases has none for the three case reports. -Some sidebar names are shorter than the report titles this page uses: **Pipeline Coverage** opens **Pipeline Coverage by Forecast × Quarter**, **Opportunity Funnel by Owner** opens **Opportunity Funnel by Owner → Stage**, **Lead Inflow** opens **Lead Engagement by Month × Source**, **Accounts by Industry & Type** opens **Accounts by Industry and Type**, **SLA Performance** opens **SLA Performance Report**, and **Cases Opened by Day** opens **Cases Opened by Priority × Day**. +Some sidebar names are shorter than the report titles this page uses: **Pipeline Coverage** opens **Pipeline Coverage by Forecast × Quarter**, **Opportunity Funnel by Owner** opens **Opportunity Funnel by Owner → Stage**, **Lead Engagement** opens **Lead Engagement by Month × Source**, **Accounts by Industry & Type** opens **Accounts by Industry and Type**, **SLA Performance** opens **SLA Performance Report**, and **Cases Opened by Day** opens **Cases Opened by Priority × Day**. ## Standard reports diff --git a/content/docs/analytics/reports.zh-Hans.mdx b/content/docs/analytics/reports.zh-Hans.mdx index 7687a3d69..a6946a44c 100644 --- a/content/docs/analytics/reports.zh-Hans.mdx +++ b/content/docs/analytics/reports.zh-Hans.mdx @@ -11,7 +11,7 @@ description: 标准报表库——HotCRM 就商机、线索、工单与客户发 下面每一份报表在侧边栏的 **Reports** 分组里都有一个条目。这个分组在应用加载时是折叠的,先把它点开;⌘K 也能按侧边栏上的名字找到一份报表。只有你能读取其记录的报表才会出现条目:服务坐席读不了商机,看不到那四张商机报表的条目;读不了工单的人看不到那三张工单报表的条目。 -有几个侧边栏名字比本页用的报表标题短:**Pipeline Coverage** 打开 **Pipeline Coverage by Forecast × Quarter**,**Opportunity Funnel by Owner** 打开 **Opportunity Funnel by Owner → Stage**,**Lead Inflow** 打开 **Lead Engagement by Month × Source**,**Accounts by Industry & Type** 打开 **Accounts by Industry and Type**,**SLA Performance** 打开 **SLA Performance Report**,**Cases Opened by Day** 打开 **Cases Opened by Priority × Day**。 +有几个侧边栏名字比本页用的报表标题短:**Pipeline Coverage** 打开 **Pipeline Coverage by Forecast × Quarter**,**Opportunity Funnel by Owner** 打开 **Opportunity Funnel by Owner → Stage**,**Lead Engagement** 打开 **Lead Engagement by Month × Source**,**Accounts by Industry & Type** 打开 **Accounts by Industry and Type**,**SLA Performance** 打开 **SLA Performance Report**,**Cases Opened by Day** 打开 **Cases Opened by Priority × Day**。 ## 标准报表 diff --git a/content/docs/analytics/reports.zh-Hant.mdx b/content/docs/analytics/reports.zh-Hant.mdx index 52d80e086..1415bba74 100644 --- a/content/docs/analytics/reports.zh-Hant.mdx +++ b/content/docs/analytics/reports.zh-Hant.mdx @@ -13,7 +13,7 @@ description: 標準報表庫——HotCRM 就商機、潛在客戶、工單與客 下面每一份報表在側邊欄的 **Reports** 群組裡都有一個條目。這個群組在應用載入時是摺疊的,先把它點開;⌘K 也能按側邊欄上的名字找到一份報表。只有你能讀取其記錄的報表才會出現條目:服務專員讀不了商機,看不到那四張商機報表的條目;讀不了工單的人看不到那三張工單報表的條目。 -有幾個側邊欄名字比本頁用的報表標題短:**Pipeline Coverage** 打開 **Pipeline Coverage by Forecast × Quarter**,**Opportunity Funnel by Owner** 打開 **Opportunity Funnel by Owner → Stage**,**Lead Inflow** 打開 **Lead Engagement by Month × Source**,**Accounts by Industry & Type** 打開 **Accounts by Industry and Type**,**SLA Performance** 打開 **SLA Performance Report**,**Cases Opened by Day** 打開 **Cases Opened by Priority × Day**。 +有幾個側邊欄名字比本頁用的報表標題短:**Pipeline Coverage** 打開 **Pipeline Coverage by Forecast × Quarter**,**Opportunity Funnel by Owner** 打開 **Opportunity Funnel by Owner → Stage**,**Lead Engagement** 打開 **Lead Engagement by Month × Source**,**Accounts by Industry & Type** 打開 **Accounts by Industry and Type**,**SLA Performance** 打開 **SLA Performance Report**,**Cases Opened by Day** 打開 **Cases Opened by Priority × Day**。 ## 標準報表 diff --git a/content/docs/getting-started/quick-tour.mdx b/content/docs/getting-started/quick-tour.mdx index 3160f9e27..23c92c4bb 100644 --- a/content/docs/getting-started/quick-tour.mdx +++ b/content/docs/getting-started/quick-tour.mdx @@ -56,7 +56,7 @@ Inside HotCRM, the left nav opens with two pinned items — **Home**, which is t | **Activity** | Events, Sales Activity | | **Marketing** | Campaigns | | **Insights** | CRM Overview, Forecasts | -| **Reports** | Opportunities by Stage, Won Opportunities by Owner, Pipeline Coverage, Opportunity Funnel by Owner, Lead Inflow, Accounts by Industry & Type, Customer Churn Signals, Cases by Status and Priority, SLA Performance, Cases Opened by Day | +| **Reports** | Opportunities by Stage, Won Opportunities by Owner, Pipeline Coverage, Opportunity Funnel by Owner, Lead Engagement, Accounts by Industry & Type, Customer Churn Signals, Cases by Status and Priority, SLA Performance, Cases Opened by Day | **Reports** is collapsed when the app loads — **Service**, **Sales**, **My Work**, **Activity**, **Marketing** and **Insights** open themselves. Click it open before deciding a report isn't there. diff --git a/content/docs/getting-started/quick-tour.zh-Hans.mdx b/content/docs/getting-started/quick-tour.zh-Hans.mdx index abe7a4edc..d56eb7e14 100644 --- a/content/docs/getting-started/quick-tour.zh-Hans.mdx +++ b/content/docs/getting-started/quick-tour.zh-Hans.mdx @@ -58,7 +58,7 @@ description: HotCRM 的 5 分钟演练 —— 主页、你的第一条线索、 | **Activity** | Events、Sales Activity | | **Marketing** | Campaigns | | **Insights** | CRM Overview、Forecasts | -| **Reports** | Opportunities by Stage、Won Opportunities by Owner、Pipeline Coverage、Opportunity Funnel by Owner、Lead Inflow、Accounts by Industry & Type、Customer Churn Signals、Cases by Status and Priority、SLA Performance、Cases Opened by Day | +| **Reports** | Opportunities by Stage、Won Opportunities by Owner、Pipeline Coverage、Opportunity Funnel by Owner、Lead Engagement、Accounts by Industry & Type、Customer Churn Signals、Cases by Status and Priority、SLA Performance、Cases Opened by Day | 上表用的是源码里的英文 label,也就是英文界面上看到的名字。简体中文界面下,它们显示为语言包里的译名——例如 **Inbox** 显示为「待我审批」、**Knowledge** 显示为「知识库」、**My Work** 显示为「我的工作」。 diff --git a/content/docs/getting-started/quick-tour.zh-Hant.mdx b/content/docs/getting-started/quick-tour.zh-Hant.mdx index 3803e2731..9cc02e3a3 100644 --- a/content/docs/getting-started/quick-tour.zh-Hant.mdx +++ b/content/docs/getting-started/quick-tour.zh-Hant.mdx @@ -58,7 +58,7 @@ description: HotCRM 的 5 分鐘演練 —— 主頁、你的第一條線索、 | **Activity** | Events、Sales Activity | | **Marketing** | Campaigns | | **Insights** | CRM Overview、Forecasts | -| **Reports** | Opportunities by Stage、Won Opportunities by Owner、Pipeline Coverage、Opportunity Funnel by Owner、Lead Inflow、Accounts by Industry & Type、Customer Churn Signals、Cases by Status and Priority、SLA Performance、Cases Opened by Day | +| **Reports** | Opportunities by Stage、Won Opportunities by Owner、Pipeline Coverage、Opportunity Funnel by Owner、Lead Engagement、Accounts by Industry & Type、Customer Churn Signals、Cases by Status and Priority、SLA Performance、Cases Opened by Day | 上表用的是原始碼裡的英文 label,也就是英文介面上看到的名字。本應用提供 en / zh-CN / ja-JP / es-ES 四種介面語言,沒有繁體語言包——簡體中文介面下這些名字會顯示為語言包裡的譯名,例如 **Inbox** 顯示為「待我审批」、**Knowledge** 顯示為「知识库」、**My Work** 顯示為「我的工作」。 diff --git a/content/docs/marketing/index.mdx b/content/docs/marketing/index.mdx index 1233de2a2..43e5fb1d2 100644 --- a/content/docs/marketing/index.mdx +++ b/content/docs/marketing/index.mdx @@ -66,7 +66,7 @@ Every opportunity has a *Campaign* lookup field. Whenever sales closes a deal th ## Standard reports -- **Lead Inflow (by Month × Source)** — where are the leads coming from? +- **Lead Engagement (by Month × Source)** — which channels' leads is the team working, month by month? It counts each lead in the month it was last contacted, not the month it arrived. - **Campaign ROI** *(via campaign list views)* — actual revenue vs. actual cost per campaign. See [Analytics › Reports](/docs/analytics/reports). diff --git a/content/docs/marketing/index.zh-Hans.mdx b/content/docs/marketing/index.zh-Hans.mdx index fe7e6e161..41d228c3c 100644 --- a/content/docs/marketing/index.zh-Hans.mdx +++ b/content/docs/marketing/index.zh-Hans.mdx @@ -66,7 +66,7 @@ Marketing 模块让你能够**规划营销活动、加入合适的线索和联 ## 标准报表 -- **Lead Inflow (by Month × Source)** — 线索来自哪里? +- **Lead Engagement (by Month × Source)** — 各渠道的线索每月跟进了多少?它把每条线索计在最近一次联系的月份,而不是线索进来的月份。 - **Campaign ROI** *(通过营销活动列表视图)* — 每个营销活动的实际收入对比实际成本。 请参阅 [分析 › 报表](/zh-Hans/docs/analytics/reports)。 diff --git a/content/docs/marketing/index.zh-Hant.mdx b/content/docs/marketing/index.zh-Hant.mdx index dadb1f8d2..800886e8b 100644 --- a/content/docs/marketing/index.zh-Hant.mdx +++ b/content/docs/marketing/index.zh-Hant.mdx @@ -68,7 +68,7 @@ Marketing 模組讓你能夠**規劃行銷活動、加入合適的潛在客戶 ## 標準報表 -- **Lead Inflow (by Month × Source)** — 潛在客戶來自哪裡? +- **Lead Engagement (by Month × Source)** — 各渠道的潛在客戶每月跟進了多少?它把每位潛在客戶計在最近一次聯絡的月份,而不是進來的月份。 - **Campaign ROI** *(透過行銷活動清單檢視)* — 每個行銷活動的實際收入對比實際成本。 請參閱 [分析 › 報表](/zh-Hant/docs/analytics/reports)。 diff --git a/content/docs/sales/opportunities.mdx b/content/docs/sales/opportunities.mdx index 68e598226..d03a2fc34 100644 --- a/content/docs/sales/opportunities.mdx +++ b/content/docs/sales/opportunities.mdx @@ -153,7 +153,7 @@ Opportunities opens on **Open Deals**, and the switcher along the top of the lis | **Sales Pipeline** | The kanban board — one column per stage, open deals only, drag a card to move the deal. It has no sidebar entry of its own: this tab is the way in. | | **All Opportunities** | The unfiltered book *including* closed business, latest close date first. Where win/loss reviews and audits start. | | **Forecast Calendar** | Every deal placed on a month calendar by its close date, coloured by stage. | -| **Deal Timeline** | One bar per deal, from the day it was created to its close date, grouped by owner on a month scale. | +| **Deal Timeline** | One entry per deal, on its close date, grouped by owner on a month scale. | | **Deal Cards** | Gallery cards — account, amount, stage, probability, close date, owner — for pipeline and executive reviews. | | **⚠️ Stale Opportunities · Longest in Stage First** | Open deals ordered by how long they have sat in their current stage, longest first. Nothing is cut off by age — **Days in Current Stage** is a column, so you read the wait off the row. The daily [Stalled Deal Alert](/docs/administration/automation) is what acts on it, nudging the owner once a deal passes 14 days in one stage. | | **Closing This Quarter** | Open **Commit** and **Best Case** deals whose close date falls inside the current quarter — see below. | diff --git a/content/docs/sales/opportunities.zh-Hans.mdx b/content/docs/sales/opportunities.zh-Hans.mdx index a3164bd90..86d783980 100644 --- a/content/docs/sales/opportunities.zh-Hans.mdx +++ b/content/docs/sales/opportunities.zh-Hans.mdx @@ -153,7 +153,7 @@ description: 活跃的销售交易——销售管道的核心,包含 7 个阶 | **销售流水线** | 看板视图——每个阶段一列,只含进行中的交易,拖动卡片即可推进阶段。它在侧边栏里没有自己的条目,这个标签页就是入口。 | | **全部商机** | 不过滤的完整台账,*含*已结束的交易,按预计成交日期由远及近排列。赢单/丢单复盘和审计从这里开始。 | | **预测日历** | 所有交易按预计成交日期落在月历上,按阶段着色。 | -| **商机时间线** | 每笔交易一根横条,从创建当天延伸到预计成交日期,按负责人分组、以月为刻度。 | +| **商机时间线** | 每笔交易一个条目,落在它的预计成交日期上,按负责人分组、以月为刻度。 | | **商机卡片** | 卡片墙——客户、金额、阶段、赢单概率、预计成交日期、负责人——用于流水线评审和管理层过会。 | | **⚠️ 停滞商机 · 按阶段停留时间排序** | 进行中的交易按在当前阶段停留的时间排序,停得最久的排在最上面。这里不按停留天数做截断——**当前阶段停留天数**是一列,等了多久直接在行上读。真正据此行动的是每日的[停滞商机提醒](/zh-Hans/docs/administration/automation):一笔交易在同一阶段停满 14 天,就会提醒负责人。 | | **本季度待成交商机** | 处于**承诺(Commit)**或**最佳情况(Best Case)**、且预计成交日期落在当前季度内的进行中交易——见下文。 | diff --git a/content/docs/sales/opportunities.zh-Hant.mdx b/content/docs/sales/opportunities.zh-Hant.mdx index f307ee9da..c3b6e0344 100644 --- a/content/docs/sales/opportunities.zh-Hant.mdx +++ b/content/docs/sales/opportunities.zh-Hant.mdx @@ -155,7 +155,7 @@ description: 活躍的銷售交易——銷售管道的核心,包含 7 個階 | **銷售流水線** | 看板視圖——每個階段一欄,只含進行中的交易,拖動卡片即可推進階段。它在側邊欄裡沒有自己的條目,這個標籤頁就是入口。 | | **全部商機** | 不過濾的完整帳冊,*含*已結束的交易,按預計成交日期由遠及近排列。贏單/丟單複盤和稽核從這裡開始。 | | **預測日曆** | 所有交易按預計成交日期落在月曆上,按階段著色。 | -| **商機時間線** | 每筆交易一根橫條,從建立當天延伸到預計成交日期,按負責人分組、以月為刻度。 | +| **商機時間線** | 每筆交易一個條目,落在它的預計成交日期上,按負責人分組、以月為刻度。 | | **商機卡片** | 卡片牆——客戶、金額、階段、贏單機率、預計成交日期、負責人——用於流水線評審和管理層過會。 | | **⚠️ 停滯商機 · 按階段停留時間排序** | 進行中的交易按在當前階段停留的時間排序,停得最久的排在最上面。這裡不按停留天數做截斷——**當前階段停留天數**是一欄,等了多久直接在列上讀。真正據此行動的是每日的[停滯商機提醒](/zh-Hant/docs/administration/automation):一筆交易在同一階段停滿 14 天,就會提醒負責人。 | | **本季度待成交商機** | 處於**承諾(Commit)**或**最佳情況(Best Case)**、且預計成交日期落在當前季度內的進行中交易——見下文。 | diff --git a/docs/feature-inventory.md b/docs/feature-inventory.md index 58df99e08..19f193a67 100644 --- a/docs/feature-inventory.md +++ b/docs/feature-inventory.md @@ -128,7 +128,7 @@ | 编号 | 功能 | 说明 | 实现锚点 | | --- | --- | --- | --- | -| CON-001 | 合同对象 | 法律协议(`crm_contract`):CTR-{0000} 编号即标题,必填客户/联系人/期限/起止日/合同额,签署与计费字段,私有共享,启用附件 | `src/sales/objects/contract.object.ts` | +| CON-001 | 合同对象 | 法律协议(`crm_contract`):CTR-{0000} 编号即标题,必填客户/联系人/期限/起止日/合同额,签署与计费字段,共享随所属客户(`controlled_by_parent`),启用附件 | `src/sales/objects/contract.object.ts` | | CON-002 | 合同状态机 | `draft → in_approval → activated → expired/terminated`;两个终态,续约=新建合同而非复活旧行 | `src/sales/objects/contract.object.ts`(validations) | | CON-003 | 期限一致性校验 | 起止日与 `contract_term_months` 偏差 >1 个月即拒绝;激活后禁止缩短 `end_date`(应走终止/变更) | `src/sales/objects/contract.hook.ts`(`contract_validation`) | | CON-004 | 激活后置动作 | 激活的同一次写入补 `signed_date`(如未填,计费推送因此带上该日期),随后把客户升级为 customer | `src/sales/objects/contract.hook.ts`(`contract_signed_date_stamp`、`contract_on_activation`) | @@ -175,7 +175,7 @@ | 编号 | 功能 | 说明 | 实现锚点 | | --- | --- | --- | --- | -| MKT-001 | 活动对象 | 市场活动(`crm_campaign`):CPG-{0000} 编号,类型/渠道,预算四件套与 `response_rate`/`roi` 公式,父活动层级,全组织可读 | `src/marketing/objects/campaign.object.ts` | +| MKT-001 | 活动对象 | 市场活动(`crm_campaign`):CPG-{0000} 编号,类型/渠道,预算四件套与 `response_rate`/`roi` 公式,无父活动层级,全组织可读 | `src/marketing/objects/campaign.object.ts` | | MKT-002 | 活动校验 | 结束日须晚于开始日、进入 in_progress 须起止日齐备、实际成本超预算告警 | `src/marketing/objects/campaign.hook.ts` + validations | | MKT-003 | 活动成员联结对象 | `crm_campaign_member`:挂 Lead 或 Contact(二选一强制),4 状态响应生命周期(sent → responded / converted / unsubscribed —— 每个值都有真实写入方),受父活动控共享;人物删除级联(GDPR) | `src/marketing/objects/campaign_member.object.ts` + `campaign_member.hook.ts` | | MKT-004 | 批量入组成员 | 屏幕流(以调用者身份运行):线索按状态、联系人按部门筛选(均要求有邮箱、未退订;线索另须未转化,上限 1000),逐人经 `campaign_lead_member_enroll`/`campaign_contact_member_enroll` 子流程去重插入成员,`added_date` 由字段默认值盖戳;无成员创建权者逐人被拒,结束消息给出两个计数;单条加入走 `create_campaign`(线索)/`add_contact_to_campaign`(联系人),同经这两个子流程 | `src/marketing/flows/campaign-enrollment.flow.ts` + `src/marketing/flows/campaign-member-enroll.flow.ts` + `src/marketing/actions/campaign.actions.ts`(`enroll_leads`) | @@ -237,7 +237,7 @@ | PRM-003 | 字段级安全 | 如 `annual_revenue`/`health_score` 对销售只读、`crm_case.internal_notes` 对销售完全遮蔽、坐席可读写 | `src/sales/profiles/*.profile.ts`(fieldPermissions) | | PRM-004 | 导出权限闸门 | `allowExport` 是真实闸门(未设即拒):仅在有读权限且视图提供导出界面的五个对象上授予,服务端 `canExport` 把关 | `src/sales/profiles/index.ts` | | PRM-005 | 所有权转移轴 | `allowTransfer` 控制把记录种到/改到他人名下:管理员全量、经理随 modifyAll、坐席仅在任务上窄授权(工单升级派单所需) | `src/sales/profiles/index.ts` | -| PRM-006 | 十一条条件共享规则 | 客户团队(customer→经理可编辑)、NA/EU 区域(按 `territory`)、活动领导层(经理/总监)、工单升级(critical→服务经理可编辑、服务总监只读)、无主工单分诊(→坐席可编辑)、升级前负责人(`escalated_from`,只读)、大单(≥$100K→总监/高管只读) | `src/*/sharing/` | +| PRM-006 | 十二条条件共享规则 | 客户团队(customer→经理可编辑)、NA/EU 区域(按 `territory`)、活动领导层(经理/总监)、工单升级(critical→服务经理可编辑、服务总监只读)、无主工单分诊(→坐席可编辑)、升级前负责人(`escalated_from`,只读)、大单(≥$100K→总监/高管只读)、预测(→销售经理可编辑、不可删除) | `src/*/sharing/` | | PRM-007 | 扁平岗位结构 | 12 个岗位(销售/服务/营销三线 + NA/EU 区域组),无层级、可见性不向上滚动,每个层级各带自己的授权 | `src/sales/sharing/positions.ts` | | PRM-008 | 行级安全(RLS) | 私密商机仅 owner 可见(经理与营销两份 select 规则);营销对活动/成员的 update 放宽 | `src/sales/profiles/sales-manager.profile.ts`、`src/sales/profiles/marketing-user.profile.ts` | | PRM-009 | OWD 共享模型分布 | 销售/服务核心对象 private;产品/活动/知识库 public_read;联系人与四个明细/联结对象 controlled_by_parent | `src/*/objects/*.object.ts`(sharingModel) | diff --git a/src/docs/crm_admin.md b/src/docs/crm_admin.md index 4adb1e728..1945c4b40 100644 --- a/src/docs/crm_admin.md +++ b/src/docs/crm_admin.md @@ -70,6 +70,7 @@ Ownership-based access is widened by these rules: | Escalated Cases — Previous Owner | Case | **Read** | The agent an escalation took the case from keeps sight of it until it is closed. | | Unassigned Cases — Triage | Case | **Edit** | Every Service Agent sees and can claim an open case nobody owns yet; the case leaves the rule once it has an owner or is resolved or closed. | | Live Campaigns — Marketing Manager / Director | Campaign | **Edit** | Marketing leadership works any live campaign. | +| Forecasts — Sales Manager Edit | Forecast | **Edit** | Sales Managers adjust any rep's forecast. An edit share never grants delete, so forecasts stay undeletable for them. | Detail objects — opportunity line items, quote line items, campaign members and contacts — carry **no** sharing rules by design: their record access derives diff --git a/src/revenue/objects/quote.hook.ts b/src/revenue/objects/quote.hook.ts index 02255632a..a2a5aede7 100644 --- a/src/revenue/objects/quote.hook.ts +++ b/src/revenue/objects/quote.hook.ts @@ -220,8 +220,21 @@ const quoteValidation: Hook = { ); if (isReferenceCleanup) return; + // Two sentences, one per reader (#2338 item 12): the diagnostic keeps + // API names and the attempted keys, and the `userMessage` the console + // shows uses labels — users were shown "…is accepted; only + // internal_notes may be edited. Attempted: discount.". The full + // reasoning is on the opportunity freeze; the labels are written out + // because a lowered body cannot read the object's metadata, and + // `test/refusal-labels.test.ts` reads them off `crm_quote`. + const STATUS_LABELS: Record = { accepted: 'Accepted', expired: 'Expired' }; const what = `${subject()} is ${previous.status as string}`; - throw refuse(`${what}; only internal_notes may be edited. Attempted: ${violating.join(', ')}.`, 'RECORD_LOCKED', 409); + throw refuse( + `${what}; only internal_notes may be edited. Attempted: ${violating.join(', ')}.`, + 'RECORD_LOCKED', + 409, + `${subject()} is ${STATUS_LABELS[previous.status as string]}; only its Internal Notes can still be edited.`, + ); } } diff --git a/src/sales/apps/crm.app.ts b/src/sales/apps/crm.app.ts index d1d014716..fe704dbd2 100644 --- a/src/sales/apps/crm.app.ts +++ b/src/sales/apps/crm.app.ts @@ -448,7 +448,7 @@ export const CrmApp = App.create({ { id: 'nav_report_won_by_owner', type: 'report', reportName: 'won_opportunities_by_owner', label: 'Won Opportunities by Owner', icon: 'trophy', visible: OPPORTUNITY_AUDIENCE }, { id: 'nav_report_pipeline_coverage', type: 'report', reportName: 'pipeline_coverage_by_quarter', label: 'Pipeline Coverage', icon: 'columns-3', visible: OPPORTUNITY_AUDIENCE }, { id: 'nav_report_funnel_by_owner', type: 'report', reportName: 'opportunity_funnel_owner_stage', label: 'Opportunity Funnel by Owner', icon: 'funnel', visible: OPPORTUNITY_AUDIENCE }, - { id: 'nav_report_lead_inflow', type: 'report', reportName: 'lead_inflow_by_month_source', label: 'Lead Inflow', icon: 'trending-up', visible: READS_LEADS }, + { id: 'nav_report_lead_inflow', type: 'report', reportName: 'lead_inflow_by_month_source', label: 'Lead Engagement', icon: 'trending-up', visible: READS_LEADS }, { id: 'nav_report_accounts_by_industry', type: 'report', reportName: 'accounts_by_industry_type', label: 'Accounts by Industry & Type', icon: 'building', visible: READS_ACCOUNTS }, { id: 'nav_report_churn_signals', type: 'report', reportName: 'customer_churn_signals', label: 'Customer Churn Signals', icon: 'heart-pulse', visible: READS_ACCOUNTS }, ], diff --git a/src/sales/objects/_refusal.ts b/src/sales/objects/_refusal.ts index 94b8e158f..3123d0a17 100644 --- a/src/sales/objects/_refusal.ts +++ b/src/sales/objects/_refusal.ts @@ -77,8 +77,10 @@ * author prose on the wire; it puts the sentence on the one channel no * boundary rewraps, which is what the platform declares the key FOR. * 3. The argument is therefore the seam for DIVERGENCE, not the opt-in: pass - * it where the diagnostic and the user-facing sentence must differ. No - * site needs that today, and one that does no longer costs an API change. + * it where the diagnostic and the user-facing sentence must differ. Two + * sites do since #2338: the closed-deal and settled-quote freezes keep + * API names and the attempted keys in the diagnostic, and give the user + * the same refusal in field and status labels. * * ⛔ A blank or whitespace-only override is dropped at the boundary, so it * suppresses nothing — it only returns the reader to the wrapper. The answer diff --git a/src/sales/objects/contract.hook.ts b/src/sales/objects/contract.hook.ts index 5e679e6d3..1583bdbaf 100644 --- a/src/sales/objects/contract.hook.ts +++ b/src/sales/objects/contract.hook.ts @@ -90,7 +90,9 @@ const contractValidation: Hook = { input.end_date < previous.end_date ) { throw refuse( - `Cannot shrink end_date (${previous.end_date as string} → ${input.end_date}) after activation. Use a termination/amendment workflow instead.`, + // The field by its label, never its API name (#2338 item 12); + // `test/refusal-labels.test.ts` reads the label off `crm_contract`. + `An activated contract's End Date cannot move earlier (${previous.end_date as string} → ${input.end_date}). Use a termination/amendment workflow instead.`, 'RECORD_LOCKED', 409, ); diff --git a/src/sales/objects/event_attendee.object.ts b/src/sales/objects/event_attendee.object.ts index 6d12716cb..07118c7b5 100644 --- a/src/sales/objects/event_attendee.object.ts +++ b/src/sales/objects/event_attendee.object.ts @@ -211,6 +211,21 @@ export const EventAttendee = ObjectSchema.create({ required: true, deleteBehavior: 'restrict', storage: { notNull: true }, + + // Curates the attendee panel on a meeting (#2338 item 8). Without it the + // panel fell back to `highlightFields` minus this lookup — Attendee Type, + // Response, Organizer — and could not say WHO was attending. The four + // party columns lead, in `ATTENDEE_RESOLUTIONS` order; a row fills + // exactly one of them, and the panel drops a column that is empty on + // every fetched row, so a meeting of contacts only shows one name column. + // ⛔ Not solved in `highlightFields`: that strip is the record header, + // where four mostly-empty party fields would crowd out the event. + relatedListColumns: [ + ...ATTENDEE_RESOLUTIONS.map((r) => r.column), + 'attendee_type', + 'response', + 'is_organizer', + ], }), // The discriminator says which of the four columns below is the live one. It diff --git a/src/sales/objects/opportunity.hook.ts b/src/sales/objects/opportunity.hook.ts index 8729381fd..0acc3b55a 100644 --- a/src/sales/objects/opportunity.hook.ts +++ b/src/sales/objects/opportunity.hook.ts @@ -301,10 +301,26 @@ const opportunityValidationHook: Hook = { // the record id — it matches none of them. const name = typeof previous.name === 'string' ? previous.name.trim() : ''; const subject = name ? `Opportunity ${name}` : 'Opportunity'; + // Two sentences, one per reader (#2338 item 12). The message is the + // diagnostic and keeps API names, including the attempted keys — + // nothing gets a turn after this throw, so it is the only place a + // log or an API caller learns which key tripped the freeze. The + // `userMessage` is what the console shows, and it names the stage + // and the fields by their labels: users were shown "closed + // (closed_won); only description, next_step, notes may be edited. + // Attempted: amount". A lowered body cannot read the object's + // metadata, so the labels are written out here; + // `test/refusal-labels.test.ts` reads them off `crm_opportunity` and + // fails when a label and this sentence part ways. `notes` has no + // label because the object has no such field. + const STAGE_LABELS: Record = { closed_won: 'Closed Won', closed_lost: 'Closed Lost' }; + const NARRATIVE_LABELS: Record = { description: 'Description', next_step: 'Next Steps' }; + const editable = [...NARRATIVE_FIELDS].map((k) => NARRATIVE_LABELS[k]).filter(Boolean).join(' and '); throw refuse( `${subject} is closed (${prevStage}); only ${[...NARRATIVE_FIELDS].join(', ')} may be edited. Attempted: ${violating.join(', ')}.`, 'RECORD_LOCKED', 409, + `${subject} is closed (${STAGE_LABELS[prevStage as string]}); only its ${editable} can still be edited.`, ); } } diff --git a/src/sales/objects/opportunity.object.ts b/src/sales/objects/opportunity.object.ts index ed27eb8ff..9437053ab 100644 --- a/src/sales/objects/opportunity.object.ts +++ b/src/sales/objects/opportunity.object.ts @@ -268,7 +268,7 @@ export const Opportunity = ObjectSchema.create({ // every object, and this duplicate had no writer at all — not the seed // data, not a hook, not a flow — so it was permanently null while // `created_at` carried the real value (#575 B2). Surfaces that need the - // creation instant read `created_at` (see the deal_timeline view). + // creation instant read `created_at`. // // Stage-age clock (#489). This is the STORED half of the pair: a real, // indexed date column, so it is what automation and views may filter and diff --git a/src/sales/objects/task.hook.ts b/src/sales/objects/task.hook.ts index 3277d264f..6c02a2f05 100644 --- a/src/sales/objects/task.hook.ts +++ b/src/sales/objects/task.hook.ts @@ -102,6 +102,20 @@ const taskValidation: Hook = { if (typeof input.progress_percent !== 'number') input.progress_percent = 100; } + // Reopening clears the stamp (#2338 item 9). A task moved from Completed + // back to an open status kept its Completed Date, and the field is + // readonly, so nobody could clear it by hand: an open task read as + // finished on the date it had been closed. Same shape as the member + // lifecycle hook clearing `response_date` on leaving a responded state. + // The key is hook-written, so the readonly strip leaves it alone. + if ( + previous?.status === 'completed' && + typeof input.status === 'string' && + input.status !== 'completed' + ) { + input.completed_date = null; + } + // Derived flags. const effStatus = (typeof input.status === 'string' && input.status) || diff --git a/src/sales/pages/home.page.ts b/src/sales/pages/home.page.ts index 3bec7c891..4f08ea50c 100644 --- a/src/sales/pages/home.page.ts +++ b/src/sales/pages/home.page.ts @@ -290,46 +290,14 @@ export const SalesHomePage: Page = { name: 'aside', width: 'medium', components: [ - { - type: 'page:card', - id: 'ai_briefing', - // The assistant is the PLATFORM's (`ask`), not an app-owned persona: - // HotCRM contributes skills, the agent lives in the cloud side - // (maintainer ruling on #612, 2026-08-04). The retired `sales_copilot` - // agent (#512, ADR-0063 §2) must not be named in live UI copy — and - // the entry point is the assistant panel the platform opens from the - // right edge of every page, which is the wording - // `content/docs/ai-copilot/index.mdx` landed in #611/PR #1001. - label: 'Today with the AI Assistant', - properties: { - title: 'Ask the AI Assistant', - bordered: true, - // The paragraph is an `element:text` child (#1581). It used to be - // `properties.description`, which `page:card` does not declare, so - // the card rendered as a title over nothing. `content` is not on - // the locale pack's page-component face, so the four languages - // live here as an inline locale map, the spec's route for page - // prose. The #1002 persona guard in - // `test/metadata-references.test.ts` reads this child. - children: [ - { - type: 'element:text', - id: 'ai_briefing_text', - properties: { - content: { - en: 'Open the assistant panel from the right edge of the page and ask "what should I focus on today?" — it sees your live pipeline, schema, and accounts.', - 'zh-CN': - '从页面右侧打开助手面板,询问"我今天应该关注什么?"——它可以实时查看您的销售管道、架构与客户信息。', - 'es-ES': - 'Abra el panel del asistente desde el borde derecho de la página y pregunte "¿en qué debería concentrarme hoy?" — ve su flujo de ventas, esquema y cuentas en tiempo real.', - 'ja-JP': - 'ページ右端からアシスタントパネルを開き、「今日は何に集中すべきか?」と尋ねてください — リアルタイムのパイプライン、スキーマ、取引先情報を把握しています。', - }, - }, - }, - ], - }, - }, + // No AI card (#2338 item 5). `ai_briefing` told every user to "open the + // assistant panel from the right edge of the page", and an install + // without the platform's assistant has no such panel (measured on + // 18.2.0: `GET /api/v1/ai/agents` answers `[]`), so the card sent + // people looking for a control that was not there. A page component + // cannot be shown only where the `ai` capability is present, so the + // card is dropped rather than reworded; the assistant documents + // itself where it exists (`content/docs/ai-copilot/`). { type: 'page:card', id: 'upcoming_events', diff --git a/src/sales/translations/en/app.ts b/src/sales/translations/en/app.ts index 136470ed7..3d989f62a 100644 --- a/src/sales/translations/en/app.ts +++ b/src/sales/translations/en/app.ts @@ -211,10 +211,6 @@ export const appSurface: Omit = { my_open_tasks: { label: 'My Open Tasks' }, }, }, - ai_briefing: { - title: 'Ask the AI Assistant', - label: 'Today with the AI Assistant', - }, upcoming_events: { title: 'Upcoming Events', label: 'Upcoming Events' }, }, }, diff --git a/src/sales/translations/es-ES/app.ts b/src/sales/translations/es-ES/app.ts index 2bf5d0898..be418c777 100644 --- a/src/sales/translations/es-ES/app.ts +++ b/src/sales/translations/es-ES/app.ts @@ -71,7 +71,7 @@ export const appSurface: Omit = { nav_report_won_by_owner: { label: 'Ganadas por Propietario' }, nav_report_pipeline_coverage: { label: 'Cobertura del Pipeline' }, nav_report_funnel_by_owner: { label: 'Embudo por Propietario' }, - nav_report_lead_inflow: { label: 'Entrada de Prospectos' }, + nav_report_lead_inflow: { label: 'Interacción con Prospectos' }, nav_report_accounts_by_industry: { label: 'Cuentas por Industria y Tipo' }, nav_report_churn_signals: { label: 'Señales de Abandono' }, nav_report_cases_by_status: { label: 'Casos por Estado y Prioridad' }, @@ -587,10 +587,6 @@ export const appSurface: Omit = { home_my_leads: { label: 'Mis Prospectos' }, home_my_deals: { label: 'Mis Negocios Abiertos' }, home_my_tasks: { label: 'Mis Tareas Abiertas' }, - ai_briefing: { - title: 'Pregúntale al Asistente de IA', - label: 'Hoy con el Asistente de IA', - }, upcoming_events: { title: 'Próximos Eventos', label: 'Próximos Eventos' }, }, }, diff --git a/src/sales/translations/ja-JP/app.ts b/src/sales/translations/ja-JP/app.ts index 002397078..2fb4187db 100644 --- a/src/sales/translations/ja-JP/app.ts +++ b/src/sales/translations/ja-JP/app.ts @@ -71,7 +71,7 @@ export const appSurface: Omit = { nav_report_won_by_owner: { label: '担当者別成立商談' }, nav_report_pipeline_coverage: { label: 'パイプラインカバレッジ' }, nav_report_funnel_by_owner: { label: '担当者別商談ファネル' }, - nav_report_lead_inflow: { label: 'リード流入' }, + nav_report_lead_inflow: { label: 'リード対応' }, nav_report_accounts_by_industry: { label: '業種・種別別取引先' }, nav_report_churn_signals: { label: '顧客離反シグナル' }, nav_report_cases_by_status: { label: 'ステータス・優先度別ケース' }, @@ -589,10 +589,6 @@ export const appSurface: Omit = { home_my_leads: { label: '私のリード' }, home_my_deals: { label: '私のオープン商談' }, home_my_tasks: { label: '私のオープンタスク' }, - ai_briefing: { - title: 'AI アシスタントに質問', - label: '今日の AI アシスタント', - }, upcoming_events: { title: '今後の予定', label: '今後の予定' }, }, }, diff --git a/src/sales/translations/zh-CN/app.ts b/src/sales/translations/zh-CN/app.ts index 7fd365b59..a433e47f6 100644 --- a/src/sales/translations/zh-CN/app.ts +++ b/src/sales/translations/zh-CN/app.ts @@ -76,7 +76,7 @@ export const appSurface: Omit = { nav_report_won_by_owner: { label: '各负责人成交商机' }, nav_report_pipeline_coverage: { label: '管道覆盖率' }, nav_report_funnel_by_owner: { label: '各负责人商机漏斗' }, - nav_report_lead_inflow: { label: '线索流入' }, + nav_report_lead_inflow: { label: '线索跟进' }, nav_report_accounts_by_industry: { label: '客户行业与类型' }, nav_report_churn_signals: { label: '客户流失信号' }, nav_report_cases_by_status: { label: '工单状态与优先级' }, @@ -594,10 +594,6 @@ export const appSurface: Omit = { home_my_leads: { label: '我的线索' }, home_my_deals: { label: '我的进行中商机' }, home_my_tasks: { label: '我的待办任务' }, - ai_briefing: { - title: '询问 AI 助手', - label: 'AI 助手今日速览', - }, upcoming_events: { title: '即将开始的活动', label: '即将开始的活动' }, }, }, diff --git a/src/sales/views/event_attendee.view.ts b/src/sales/views/event_attendee.view.ts index 82acf0c84..d7f7ad068 100644 --- a/src/sales/views/event_attendee.view.ts +++ b/src/sales/views/event_attendee.view.ts @@ -31,21 +31,22 @@ import { defineView } from '@objectstack/spec/ui'; * * **`list` — not what the related list reads.** A detail-page related list * takes its columns from the CHILD's lookup field — `relatedListColumns` on - * `crm_event`, which this app authors nowhere — and falls back to the child + * `crm_event`, authored since #2338 — and falls back to the child * object's `highlightFields` minus the parent lookup, capped at six, with * columns that are empty on every fetched row dropped. Only an object with no * `highlightFields` reaches the last resort, and that is a heuristic over the * field map (title-ish names first, audit columns last, `rich_text` / `html` / * `json` excluded) — not declaration order, and never this view. So the - * attendee panel on a meeting shows `attendee_type`, `response` and - * `is_organizer`, straight off `crm_event_attendee.highlightFields`, whatever - * the columns below say. + * attendee panel on a meeting shows the columns `relatedListColumns` names + * (the party columns, then type, response and organizer), whatever the + * columns below say. Until #2338 it fell back to `highlightFields` and showed + * `attendee_type`, `response` and `is_organizer` with no attendee name. * * The grid is therefore reachable only at this object's own list URL, which no * navigation entry links to. It is kept as the curated answer to "what do you - * show about an attendee", and it is what a future `relatedListColumns` would - * be copied from — but nothing in this repo may cite it as what makes a - * related list readable. The load-bearing metadata is `highlightFields`, and + * show about an attendee", and `relatedListColumns` was copied from it — but + * nothing in this repo may cite it as what makes a related list readable. The + * fallback metadata is `highlightFields`, and * `test/view-references.test.ts` pins it for every object reached only through * a parent: strip it and the panel really does fall to the heuristic, which * leads with the autonumber these objects use as their `nameField`. diff --git a/src/sales/views/opportunity.view.ts b/src/sales/views/opportunity.view.ts index ec4212de6..cfca133d3 100644 --- a/src/sales/views/opportunity.view.ts +++ b/src/sales/views/opportunity.view.ts @@ -168,12 +168,17 @@ export const OpportunityViews = defineView({ data: { provider: 'object', object: 'crm_opportunity' }, columns: ['name', 'crm_account', 'amount'], timeline: { - // `created_at`, the platform's own creation stamp — the object's - // duplicate `created_date` was removed in #575 B2 because nothing ever - // wrote it, so this timeline started every bar at null. Same spelling - // as the lead activity calendar. - startDateField: 'created_at', - endDateField: 'close_date', + // One mark per deal, on its close date, and no end field (#2338 + // item 7). It used to run each deal from `created_at` to `close_date`, + // and nothing orders those two: `close_date` is the date a rep TYPES + // (the expected close, or the real one once the deal is closed), so a + // deal entered after it closed, a backdated close, or an overdue open + // deal drew its bar backwards — measured on the seeded book as + // "10/27/2026 → 9/7/2026". No stored date is earlier than `close_date` + // on every deal (`stage_entry_date` moves with each stage), so a range + // could not be made safe. `close_date` is required and NOT NULL, so + // every deal has its mark. + startDateField: 'close_date', titleField: 'name', groupByField: 'owner_id', colorField: 'stage', diff --git a/src/service/dashboards/service.dashboard.ts b/src/service/dashboards/service.dashboard.ts index fd9dbe037..b8f25435a 100644 --- a/src/service/dashboards/service.dashboard.ts +++ b/src/service/dashboards/service.dashboard.ts @@ -303,7 +303,11 @@ export const ServiceDashboard: Dashboard = { title: 'Top Resolving Articles', description: 'Knowledge articles ranked by the closed cases they resolved', type: 'table', - filter: { is_closed: true }, + // Only cases an article resolved (#2338 item 11). Without the second + // key every closed case with no article formed a blank bucket, drawn as + // a "—" row with a count of 0 (0, because `kb_resolved_count` counts + // the article column, which that bucket never fills). + filter: { is_closed: true, resolved_by_article: { $ne: null } }, colorVariant: 'default', dataset, dimensions: ['resolved_article'], values: ['kb_resolved_count'], layout: { x: 0, y: 12, w: 12, h: 4 }, diff --git a/src/service/views/case.view.ts b/src/service/views/case.view.ts index e1550ed31..4549552dd 100644 --- a/src/service/views/case.view.ts +++ b/src/service/views/case.view.ts @@ -122,7 +122,7 @@ export const CaseViews = defineView({ // `created_at`, the platform's own creation stamp — the object's // duplicate `created_date` was removed in #1992 because nothing wrote // it on a real case, so every user-created case started its bar at - // null. Same spelling as `deal_timeline` on crm_opportunity (#575 B2). + // null. Same spelling as `crm_lead`'s activity calendar. startDateField: 'created_at', endDateField: 'closed_date', titleField: 'subject', diff --git a/test/attendee-type-resolution.test.ts b/test/attendee-type-resolution.test.ts index ecc33a413..dce7a38a7 100644 --- a/test/attendee-type-resolution.test.ts +++ b/test/attendee-type-resolution.test.ts @@ -112,6 +112,13 @@ describe('the four resolutions are one declared correspondence', () => { } }); + it('shows every resolution column in the attendee panel on a meeting (#2338 item 8)', () => { + // The panel reads `relatedListColumns` on this lookup. Without it, it fell + // back to `highlightFields` and showed no name at all. + const columns = (EVENT_ATTENDEE.fields.crm_event.relatedListColumns ?? []) as string[]; + expect(columns.slice(0, ATTENDEE_RESOLUTIONS.length)).toEqual(ATTENDEE_RESOLUTIONS.map((r) => r.column)); + }); + it('reads every resolution column in both rules, and pairs it with its own type', () => { // The mechanical guard against a fifth resolution being added to the table // while a hand-written rule branch is forgotten — the #740 failure exactly. diff --git a/test/dashboard-agent-global-filter.test.ts b/test/dashboard-agent-global-filter.test.ts index a9207b18d..1d1707df8 100644 --- a/test/dashboard-agent-global-filter.test.ts +++ b/test/dashboard-agent-global-filter.test.ts @@ -199,7 +199,8 @@ const utcNoonDaysAgo = (offset: number): string => { * dashboard actually authors are understood — field equality, a * `{N_days_ago}` floor on the range field, and the SLA Violations tile's `$or` * of the flag with "open and past due" (`$nin` on `status`, `$lt '{now}'` on - * `sla_due_date`, #2070). Anything else THROWS, so a widget filter this + * `sla_due_date`, #2070), and a `$ne: null` "has a value" key (Top Resolving + * Articles, #2338 item 11). Anything else THROWS, so a widget filter this * function silently mis-models cannot pass by accident. */ const truthMatches = (row: FixtureCase, filter: AnyRec | undefined): boolean => { @@ -218,6 +219,11 @@ const truthMatches = (row: FixtureCase, filter: AnyRec | undefined): boolean => if (row.due === null || row.due >= 0) return false; continue; } + if (ops === '$ne' && (cond as AnyRec).$ne === null) { + // The driver reads `$ne: null` as IS NOT NULL (measured on 18.2.0): a null never passes. + if ((row as AnyRec)[field] === null) return false; + continue; + } if (field === AGENT_FIELD) { // `{current_user_id}` is resolved by the caller (see `truthFor`), so an // unresolved placeholder here is a mis-modelled filter: throw. diff --git a/test/freeze-guard-reference-cleanup.test.ts b/test/freeze-guard-reference-cleanup.test.ts index e08317b4e..a0fd8c809 100644 --- a/test/freeze-guard-reference-cleanup.test.ts +++ b/test/freeze-guard-reference-cleanup.test.ts @@ -579,6 +579,10 @@ describe('an ordinary user edit to a settled record is still refused', () => { expect(err!.message).not.toContain(opportunity.id); expect(err!.message).toContain('only description, next_step, notes may be edited'); expect(err!.message).toContain('Attempted: amount'); + // What the console shows: the same refusal in labels (#2338 item 12). + expect((err as Error & { userMessage?: string }).userMessage).toBe( + `Opportunity Settled Deal ${n} is closed (Closed Won); only its Description and Next Steps can still be edited.`, + ); // Refused for real, not merely announced. expect((await rowOf('crm_opportunity', opportunity.id)).amount).toBe(1000); }); @@ -615,6 +619,10 @@ describe('an ordinary user edit to a settled record is still refused', () => { expect(err!.message).not.toContain(quote.id); expect(err!.message).toContain('only internal_notes may be edited'); expect(err!.message).toContain('Attempted: crm_opportunity'); + // What the console shows: the same refusal in labels (#2338 item 12). + expect((err as Error & { userMessage?: string }).userMessage).toMatch( + new RegExp(`^Quote QTE-\\d+ - QR-${n} is Accepted; only its Internal Notes can still be edited\\.$`), + ); }); }); diff --git a/test/hooks-runtime-service.test.ts b/test/hooks-runtime-service.test.ts index c774d7244..9d07397eb 100644 --- a/test/hooks-runtime-service.test.ts +++ b/test/hooks-runtime-service.test.ts @@ -314,12 +314,12 @@ describe('contract_validation', () => { await expect(managerContract({ start_date: '2026-01-01', end_date: '2026-12-15', contract_term_months: 12 })).resolves.toBeTruthy(); }); - it('refuses to shrink end_date after activation', async () => { + it('refuses to move End Date earlier after activation, naming the field by its label', async () => { // Shrunk within the term's one-month tolerance, so the shrink rule is the // one that answers. const contract = await contractOf({ status: 'activated', start_date: '2026-01-01', end_date: '2027-01-01', contract_term_months: 12 }); await expect(verify.hooks.run('crm_contract', 'update', { id: contract.id, end_date: '2026-12-15' }, as(manager))) - .rejects.toThrow(/Cannot shrink end_date/); + .rejects.toThrow(/End Date cannot move earlier/); }); it('allows extending end_date after activation', async () => { @@ -1505,6 +1505,24 @@ describe('task_completion', () => { expect(done.is_completed).toBe(true); }); + it('clears completed_date when a completed task is reopened (#2338 item 9)', async () => { + const task = await taskOf({ status: 'in_progress' }); + await verify.hooks.run('crm_task', 'update', { id: task.id, status: 'completed' }, as(rep)); + expect((await stored('crm_task', task.id)).completed_date).toBeTruthy(); + await verify.hooks.run('crm_task', 'update', { id: task.id, status: 'in_progress' }, as(rep)); + const reopened = await stored('crm_task', task.id); + expect(reopened.completed_date ?? null).toBeNull(); + expect(reopened.is_completed).toBe(false); + }); + + it('keeps completed_date on an edit that leaves a completed task completed', async () => { + const task = await taskOf({ status: 'in_progress' }); + await verify.hooks.run('crm_task', 'update', { id: task.id, status: 'completed' }, as(rep)); + const stamp = (await stored('crm_task', task.id)).completed_date; + await verify.hooks.run('crm_task', 'update', { id: task.id, description: 'Follow-up notes' }, as(rep)); + expect((await stored('crm_task', task.id)).completed_date).toBe(stamp); + }); + it('flags an overdue open task and clears the flag once completed', async () => { const task = await taskOf({ due_date: daysFromNow(-3), status: 'not_started' }); await verify.hooks.run('crm_task', 'update', { id: task.id, status: 'in_progress' }, as(rep)); diff --git a/test/knowledge-deflection.test.ts b/test/knowledge-deflection.test.ts index 7f296ec47..6507679fc 100644 --- a/test/knowledge-deflection.test.ts +++ b/test/knowledge-deflection.test.ts @@ -392,6 +392,9 @@ describe('the service dashboard reads the deflection metric', () => { expect(w, 'service_dashboard has no top_resolving_articles widget').toBeTruthy(); expect(w.dimensions).toEqual(['resolved_article']); expect(w.filter).toMatchObject({ is_closed: true }); + // A closed case with no article is not a ranked article: the blank bucket + // drew a "—" row with 0 (#2338 item 11). + expect(w.filter).toMatchObject({ resolved_by_article: { $ne: null } }); }); /** diff --git a/test/metadata-references.test.ts b/test/metadata-references.test.ts index 5526788fd..a5aadf820 100644 --- a/test/metadata-references.test.ts +++ b/test/metadata-references.test.ts @@ -1059,103 +1059,29 @@ describe('app AI bindings resolve to a platform agent', () => { }); /** - * The home page's AI card does not name a retired persona (#1002). + * Sales Home carries no AI card (#2338 item 5). * - * The two guards above check agent BINDINGS — `defaultAgent`, `stack.agents` — - * because those are keys a schema can be pointed at. This one checks a - * paragraph, and that is the whole point: the card below said - * "Ask the Sales Copilot" for months while every gate stayed green. `os - * validate` and `pnpm lint` walk authored metadata and treat a card's `title` / - * `description` as free text; PR #1001's persona rule is deliberately scoped to - * `content/docs/**` (the range the maintainer drew for #612), so it never - * opened a `.page.ts`. A retired persona in `src/` had no guard at all — and - * this one was live UI copy, visible to a user today, not documentation. + * `ai_briefing` told every user to open "the assistant panel from the right + * edge of the page". The panel is the platform's, and an install without its + * assistant has none: the #2320 browser pass measured `GET /api/v1/ai/agents` + * answering `[]` on 18.2.0, so the card sent people looking for a control that + * was not there. A page component cannot be shown only where the `ai` + * capability is present, so the card was dropped rather than reworded. * - * Two drifts were fixed here, both from the maintainer's 2026-08-04 ruling on - * #612 (Option A) and the architecture note that followed it: - * - * - **The persona.** `sales_copilot` was retired in #512 and ADR-0063 §2 made - * the surface skills-only. AI capability is implemented by agents in - * `objectstack-ai/cloud`; HotCRM contributes domain skills. So the copy names - * the platform's assistant, never an app-owned one. - * - **The entry point.** The card sent users to "the floating Copilot - * (bottom-right)"; `content/docs/ai-copilot/index.mdx` documents "the chat - * panel the platform opens from the right edge of every page" (#611 / PR - * #1001). Two descriptions of one entry point is drift whichever is right, so - * the card now uses the documented one. - * - * SCOPE — read before extending. This pins the ONE card this PR rewrote. It is - * not the general rule, and it cannot catch the same persona reappearing in a - * different card, view, or skill description: that is a scan-surface question - * over every user-visible string in `src/` (`title` / `label` / `description` / - * `help`), which touches the public wording surface and needs its own ruling. - * Filed as #1003 for that reason — do not quietly grow this into it. - * - * Reverse verification: predicted red-before / green-after, the ordinary - * direction for a forbidden-string pin over copy that plainly contained the - * string. Measured by restoring the old three lines: 2 of 3 assertions fail - * ("Ask the Sales Copilot" on `title`, "Today with Copilot" on `label`, and the - * floating/bottom-right entry point), then green once rewritten. - * - * The paragraph moved, and this guard moved with it (#1581). It was - * `properties.description`, which `page:card` does not declare, so it never - * reached the screen. It is now the card's `element:text` child, whose - * `content` is an inline locale map, so the rule reads every language of it. + * This replaces the #1002 guard, which read that card's copy for a retired + * copilot persona. With the card gone it would read nothing, and a copy rule + * over nothing passes forever. The wider scan #1003 asked for is unchanged. */ -describe('live UI copy does not name a retired copilot persona (#1002)', () => { +describe('Sales Home offers no AI card (#2338 item 5)', () => { const homePage = pages.find((p) => p.name === 'sales_home_page'); + const components = [...walk(homePage?.regions), ...walk(homePage?.slots)] as AnyRec[]; - /** The card carries the app's only AI-facing home-page copy. */ - const card = [...walk(homePage?.regions), ...walk(homePage?.slots)].find( - (c: AnyRec) => c.id === 'ai_briefing', - ); - - /** The card's paragraph: its `element:text` child. */ - const text = ((card?.properties?.children ?? []) as AnyRec[]).find( - (c) => c?.type === 'element:text', - ); - const content: unknown = text?.properties?.content; - /** Every language of the paragraph; a plain string is one language. */ - const paragraphs: string[] = - typeof content === 'string' - ? [content] - : content && typeof content === 'object' - ? Object.values(content).filter((s): s is string => typeof s === 'string') - : []; - const english: string = - typeof content === 'string' ? content : ((content as AnyRec | undefined)?.en ?? ''); - - it('the card this rule reads is still on the home page', () => { - // Guard the guard: renamed away, every assertion below would pass by - // reading `undefined` — the empty-pass failure mode a copy rule dies of. + it('the page this rule reads is still registered and has components', () => { expect(homePage, 'sales_home_page is no longer registered in the stack').toBeDefined(); - expect(card, 'no component with id "ai_briefing" on sales_home_page').toBeDefined(); - expect(typeof card?.properties?.title).toBe('string'); - expect(text, 'the ai_briefing card has no element:text child — its paragraph is gone').toBeDefined(); - expect(typeof english, 'the paragraph has no English copy').toBe('string'); - expect(english.length, 'the paragraph has no English copy').toBeGreaterThan(0); - }); - - it('no retired persona name in the card copy', () => { - const copy = [card?.label, card?.properties?.title, ...paragraphs] - .filter((s): s is string => typeof s === 'string') - .join('\n'); - // The bare word is included on purpose: "Copilot" alone reads as an - // app-owned assistant in UI copy, even though the DOCS keep *AI Copilot* as - // a section name (#611's line, which applies to a docs nav, not to a card). - const found = ['Sales Copilot', 'Service Copilot', 'Copilot'].filter((name) => - copy.includes(name), - ); - expect(found, `retired persona named in home-page card copy: ${found.join(', ')}`).toEqual([]); + expect(components.length).toBeGreaterThan(0); }); - it('the card points at the documented assistant entry point', () => { - // The stale spelling, not a paraphrase of the new one: pinning the exact - // sentence would fail on any harmless rewording, while "floating" / - // "bottom-right" is precisely the claim that contradicts the docs. - const all = paragraphs.join('\n').toLowerCase(); - expect(all).not.toContain('floating'); - expect(all).not.toContain('bottom-right'); - expect(english).toContain('right edge'); + it('no component points users at the assistant panel', () => { + expect(components.filter((c) => c.id === 'ai_briefing').map((c) => c.id)).toEqual([]); }); }); diff --git a/test/opportunity-creation-date.test.ts b/test/opportunity-creation-date.test.ts index 3f817c8a2..d72410f2a 100644 --- a/test/opportunity-creation-date.test.ts +++ b/test/opportunity-creation-date.test.ts @@ -84,10 +84,15 @@ describe('the deal timeline starts from a date that exists', () => { expect(timelineViews.length, 'no timeline or calendar view on crm_opportunity').toBeGreaterThan(0); }); - it('deal_timeline reads created_at', () => { + it('deal_timeline marks each deal on its close date, with no range to run backwards (#2338 item 7)', () => { + // It ran `created_at` → `close_date`, and a close date earlier than the + // creation stamp drew the bar backwards. `close_date` is required, so the + // single mark exists on every deal; no end field means no range at all. const timeline = (opportunityView?.listViews ?? {}).deal_timeline as AnyRec | undefined; expect(timeline, 'deal_timeline view missing').toBeTruthy(); - expect(timeline!.timeline?.startDateField).toBe('created_at'); + expect(timeline!.timeline?.startDateField).toBe('close_date'); + expect(timeline!.timeline?.endDateField).toBeUndefined(); + expect(opportunity?.fields?.close_date?.required).toBe(true); }); it('no date-driven opportunity view points at a field the object does not have', () => { diff --git a/test/refusal-envelope.test.ts b/test/refusal-envelope.test.ts index 3c01096b7..2f19da21d 100644 --- a/test/refusal-envelope.test.ts +++ b/test/refusal-envelope.test.ts @@ -248,19 +248,28 @@ const refusalFrom = async (hook: AnyRec, opts: AnyRec): Promise = * would see. `userMessage` is pinned EQUAL to `innerMessage`: since #1869 * `refuse()` marks the sentence, and that mark is the half a consumer can read * without the wrapper. `innerMessage` is not a wire field, so asserting only it - * would leave the user-facing outcome unpinned. + * would leave the user-facing outcome unpinned. A site that passes its own + * `userMessage` (the diagnostic keeps API names, the user sentence uses labels, + * #2338 item 12) names that sentence as `userWording`, and the pin becomes: + * the marked channel carries THAT sentence, not the diagnostic. */ const expectEnvelope = ( err: AnyRec | null, cls: keyof typeof REFUSAL_CODES, hookName: string, wording: RegExp, + userWording?: RegExp, ): void => { expect(err, `expected ${hookName} to refuse`).toBeTruthy(); expect(err!.name, 'the shipped path re-throws as SandboxError').toBe('SandboxError'); expect([err!.code, err!.status]).toEqual([REFUSAL_CODES[cls].code, REFUSAL_CODES[cls].status]); expect(String(err!.innerMessage)).toMatch(wording); expect(String(err!.message)).toBe(`hook '${hookName}' threw: Error: ${err!.innerMessage}`); + if (userWording) { + expect(String(err!.userMessage), 'the marked channel carries the user sentence').toMatch(userWording); + expect(err!.userMessage).not.toBe(String(err!.innerMessage)); + return; + } expect(err!.userMessage, 'the marked channel carries the sentence, unwrapped').toBe( String(err!.innerMessage), ); @@ -294,7 +303,7 @@ describe('every refusal class survives the QuickJS boundary (#1167)', () => { previous: { id: row.opportunity, name: 'Big Deal', stage: 'closed_won', amount: 100 }, as: rep.token, }); - expectEnvelope(err, 'locked', 'opportunity_lifecycle', /is closed \(closed_won\)/); + expectEnvelope(err, 'locked', 'opportunity_lifecycle', /is closed \(closed_won\)/, /is closed \(Closed Won\); only its Description and Next Steps can still be edited\.$/); }); it('delete_restricted — product_catalog holds a referenced product', async () => { @@ -344,6 +353,7 @@ const expectInProcess = ( err: unknown, cls: keyof typeof REFUSAL_CODES, wording: RegExp, + userWording?: RegExp, ): void => { expect(err, `expected a refusal matching ${wording}`).toBeInstanceOf(Error); const e = err as AnyRec; @@ -352,7 +362,13 @@ const expectInProcess = ( expect(e.innerMessage, 'only the sandbox adds innerMessage').toBeUndefined(); expect([e.code, e.status]).toEqual([REFUSAL_CODES[cls].code, REFUSAL_CODES[cls].status]); // The mark is written by `refuse()`, so it is identical on both paths — here - // `message` is unrewritten, so the two are the same string. + // `message` is unrewritten, so the two are the same string, unless the site + // passed its own user sentence (see `expectEnvelope`). + if (userWording) { + expect(String(e.userMessage)).toMatch(userWording); + expect(e.userMessage).not.toBe(e.message); + return; + } expect(e.userMessage, 'refuse() marks the sentence on both paths').toBe(e.message); }; @@ -393,6 +409,7 @@ describe('the same envelope on the in-process path (#1075)', () => { await inProcess('crm_opportunity', 'update', { id: opp.id, amount: 999 }), 'locked', /is closed \(closed_won\)/, + /is closed \(Closed Won\); only its Description and Next Steps can still be edited\.$/, ); }); diff --git a/test/refusal-labels.test.ts b/test/refusal-labels.test.ts new file mode 100644 index 000000000..58a119755 --- /dev/null +++ b/test/refusal-labels.test.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { extractHookBody } from '@objectstack/cli/hook-body'; +import { allHooks } from '../objectstack.composition'; +import { Opportunity } from '../src/sales/objects/opportunity.object'; +import { Contract } from '../src/sales/objects/contract.object'; +import { Quote } from '../src/revenue/objects/quote.object'; + +/** + * Three freeze refusals name fields and states by the labels the user sees + * (#2338 item 12). + * + * They used to show API names: "…is accepted; only internal_notes may be + * edited. Attempted: discount.", "Cannot shrink end_date (…)", and "closed + * (closed_won); only description, next_step, notes may be edited". A lowered + * hook body cannot read the object's metadata, so each body writes its labels + * out — the same route `campaign_validation` took for #2060 item 12. This file + * reads those written-out labels back out of the LOWERED bodies and holds them + * to the objects, so a label renamed on the object without its refusal turns + * this red instead of showing the old word. + */ + +type AnyRec = Record; + +const lowered = (name: string): string => { + const hook = (allHooks as AnyRec[]).find((h) => h.name === name); + expect(hook, `hook '${name}' is not registered`).toBeTruthy(); + return extractHookBody(hook!.handler, `hook '${name}'`).source; +}; + +/** `NAME = { key: 'Label', … }` out of a lowered body, as `[key, label]` pairs. */ +const table = (source: string, name: string): Array<[string, string]> => { + const m = new RegExp(`${name}\\s*=\\s*\\{([^}]*)\\}`).exec(source); + expect(m, `${name} is not in the lowered body`).toBeTruthy(); + return [...m![1]!.matchAll(/(\w+)\s*:\s*['"]([^'"]+)['"]/g)].map((e) => [e[1]!, e[2]!]); +}; + +const fieldLabel = (obj: AnyRec, field: string): string => obj.fields[field]?.label; +const optionLabel = (obj: AnyRec, field: string, value: string): string => + (obj.fields[field].options as AnyRec[]).find((o) => o.value === value)?.label; + +describe('freeze refusals use the labels the user sees (#2338 item 12)', () => { + it('opportunity_lifecycle: the closed stages and the editable fields', () => { + const source = lowered('opportunity_lifecycle'); + const stages = table(source, 'STAGE_LABELS'); + expect(stages.map(([k]) => k).sort()).toEqual(['closed_lost', 'closed_won']); + for (const [value, label] of stages) expect(label, value).toBe(optionLabel(Opportunity, 'stage', value)); + const fields = table(source, 'NARRATIVE_LABELS'); + expect(fields.length).toBeGreaterThan(0); + for (const [field, label] of fields) expect(label, field).toBe(fieldLabel(Opportunity, field)); + }); + + it('quote_workflow: the frozen statuses and the one editable field', () => { + const source = lowered('quote_workflow'); + const statuses = table(source, 'STATUS_LABELS'); + expect(statuses.map(([k]) => k).sort()).toEqual(['accepted', 'expired']); + for (const [value, label] of statuses) expect(label, value).toBe(optionLabel(Quote, 'status', value)); + expect(source).toContain(`only its ${fieldLabel(Quote, 'internal_notes')} can still be edited`); + }); + + it('contract_validation: the end date', () => { + expect(lowered('contract_validation')).toContain(`contract's ${fieldLabel(Contract, 'end_date')} cannot move earlier`); + }); +}); diff --git a/test/view-references.test.ts b/test/view-references.test.ts index 9d887dfb5..c85610d71 100644 --- a/test/view-references.test.ts +++ b/test/view-references.test.ts @@ -415,7 +415,7 @@ describe('every named list view is reachable', () => { * * It does not. MEASURED against the shipped Console (17.0.0-rc.3): a * detail-page related list takes its columns from the CHILD's lookup field - * (`relatedListColumns`, which this app authors nowhere), then falls back to + * (`relatedListColumns`), then falls back to * the child object's `highlightFields` minus the lookup the panel is scoped * by — capped at six, with columns empty on every fetched row dropped. Only * with neither of those does it reach a heuristic over the whole field map.