Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/opportunity-reference-rail-localized-headings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'hotcrm': patch
---

Let the opportunity detail page's reference rail take its card headings from the
translation bundle, and stop one of them claiming a filter the component cannot
apply.

The three entries in `src/pages/opportunity_detail.page.ts` each declared a
literal English `title` — `Quotes`, `Products`, `Open Tasks`. The rail resolves a
heading as `entry.title || i18n.objectLabel({ name: objectName, … })`, so a
literal does not supply a default: it wins, and the locale bundle is never
consulted. `objects.crm_quote.label`, `objects.crm_opportunity_line_item.label`
and `objects.crm_task.label` are translated in all four locales this app ships,
yet the rail printed English into every one of them. Dropping the three literals
hands the heading back to the translation bundle, so the cards now read 报价单 /
商机产品明细 / 任务 in Simplified Chinese and follow any locale added later
without a page edit.

Two consequences worth knowing before you look at the page:

- The English headings change with it, from the plural nouns the literals spelled
to the objects' own singular labels — **Quote**, **Opportunity Line Item**,
**Task**. The rail reads `label`, never `pluralLabel`, so a plural heading is
not reachable from metadata today.
- The task card loses the word *Open*, which it was never entitled to. A rail
entry carries no filter and cannot be given one — the rail queries
`{ $filter: { [relationshipField]: parentId }, $top: limit }` and reads nothing
else off the entry — so that card has always counted and listed this deal's
tasks whatever their status. The genuinely filtered view is the **Open Tasks**
related list on the *Related* tab, which does carry `status neq completed`.

The `sales/opportunities` page in all three doc languages is updated to match,
and three new guards pin the behaviour: rail entries must resolve to real objects
and relationship fields, must declare no literal title, and must declare no
filter — the last one so that an author reaching for the *Related* tab's
predicate gets a red test instead of a key that parses, ships and does nothing.
2 changes: 1 addition & 1 deletion content/docs/sales/opportunities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ When you open an opportunity, you'll see:
- **Line Items** — with an **Add Product** button.
- **Activity timeline** — a unified feed of comments, logged calls (`sys_activity`), outbound emails (`sys_email`), task completions, and field-history audit entries. Every tracked change on the opportunity (stage, amount, close-date, owner) shows up here automatically; there is no separate "history" tab.
- **Competitors & Notes** — there is no such panel. The page (`src/pages/opportunity_detail.page.ts`) declares three regions: a header, the main column carrying the tab strip, and one narrow side column (`aside`) whose only occupant is the reference rail described below. Nothing on the page renders talking points. **Competitors** is a real field — an ordinary multi-select on the opportunity, whose options today are the placeholders *Competitor A*, *Competitor B* and *Competitor C* (`src/objects/opportunity.object.ts`) — but it is not on this page at all: you set it in the edit form's *Sales Strategy* section, beside **Next Steps**. The closest thing to notes on the page itself is the *Details* tab's collapsible **Description** section, which carries **Description** and **Next Steps**.
- **AI Reference Rail** — the rail is real, but there is not one suggestion in it, and no component on this page is AI-driven. It is a `record:reference_rail` in that side column, and each of its three cards is a snapshot of records already linked to this deal: **Quotes** (`crm_quote` through its **Opportunity** lookup), **Products** (this deal's `crm_opportunity_line_item` rows) and **Open Tasks** (`crm_task` through **Related Opportunity**). A card carries the total related count as a badge, at most three records, and a *View all* link into the full list; cards that come back empty fold into a single *+ N empty* chip. Two caveats. The **Open Tasks** card applies no status filter — a rail entry declares none, and the rail queries on the relationship alone — so it counts and lists this deal's tasks whatever their status; the list that really filters to not-*Completed*, ten at a time, is **Open Tasks** on the *Related* tab. And there is no Customer 360, no revenue forecast and no "related signal" in the rail or anywhere else on this page: those skills are reached by asking — see *How the AI Copilot helps* below.
- **AI Reference Rail** — the rail is real, but there is not one suggestion in it, and no component on this page is AI-driven. It is a `record:reference_rail` in that side column, and each of its three cards is a snapshot of records already linked to this deal: `crm_quote` through its **Opportunity** lookup, this deal's `crm_opportunity_line_item` rows, and `crm_task` through **Related Opportunity**. A card carries the total related count as a badge, at most three records, and a *View all* link into the full list; cards that come back empty fold into a single *+ N empty* chip. Each card is headed by its object's label in the language you are reading the app in — **Quote**, **Opportunity Line Item** and **Task** in English — because the rail entries declare no heading of their own; the heading is looked up in the translation bundle, the same place the rest of the app gets an object's name. Two caveats. A rail entry cannot filter, and none of these declares one: the rail queries on the relationship alone, so the task card counts and lists this deal's tasks whatever their status. That is exactly why the card is headed **Task** rather than *Open Tasks* — the view that really filters to not-*Completed*, ten at a time, is **Open Tasks** on the *Related* tab. And there is no Customer 360, no revenue forecast and no "related signal" in the rail or anywhere else on this page: those skills are reached by asking — see *How the AI Copilot helps* below.

## How the AI Copilot helps

Expand Down
2 changes: 1 addition & 1 deletion content/docs/sales/opportunities.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ description: 活跃的销售交易——销售管道的核心,包含 7 个阶
- **行项目**——带有一个**添加产品**按钮。
- **活动时间线**——统一汇集评论、已记录通话(`sys_activity`)、外发邮件(`sys_email`)、任务完成以及字段历史审计条目。商机上每个被跟踪的更改(阶段、金额、成交日期、负责人)都会自动出现在这里;没有单独的"历史"标签页。
- **竞争对手与备注**——不存在这样一个面板。这一页(`src/pages/opportunity_detail.page.ts`)声明了三个区域:一个头部、一个装着那组标签页的主列,以及一条窄侧栏(`aside`),而侧栏里唯一的组件就是下一条讲的参考栏。页面上没有任何组件渲染「谈话要点」。**竞争对手**这个字段是真实存在的——商机上一个普通的多选字段,目前的选项还是占位的 *Competitor A*、*Competitor B*、*Competitor C*(`src/objects/opportunity.object.ts`)——但它根本不在这一页上:你是在编辑表单的 *Sales Strategy* 分区里填它,紧挨着 **下一步**。这一页上最接近「备注」的,是 *Details* 标签页里那个可折叠的 **描述** 分区,里面放着 **描述** 与 **下一步**。
- **AI 参考栏**——参考栏确实存在,但里面没有任何一条建议,这一页上也没有任何一个组件是 AI 驱动的。它是那条侧栏里的一个 `record:reference_rail`,三张卡片每一张都只是已经关联到本商机的记录快照:**Quotes**(报价单 `crm_quote`经其 **商机** 查找字段)、**Products**(本商机的 `crm_opportunity_line_item` 明细行)、**Open Tasks**(任务 `crm_task`经 **关联商机** 字段)。每张卡片给出关联记录总数的徽章、最多三条记录,以及一个跳转到完整列表的 *View all* 链接;查出来是空的卡片会折叠成一个 *+ N empty* 小按钮。两点提醒。**Open Tasks** 这张卡片并不按状态过滤——参考栏的条目不声明过滤条件,查询只按关联字段走——所以它统计和列出的是本商机的全部任务,不分状态真正过滤掉 *Completed*、一次十条的,是 *Related* 标签页上的 **Open Tasks** 列表。另外,参考栏里没有 Customer 360、没有营收预测,也没有任何「相关信号」,整页都没有:那些技能是靠提问去用的——见下文《AI Copilot 如何提供帮助》。
- **AI 参考栏**——参考栏确实存在,但里面没有任何一条建议,这一页上也没有任何一个组件是 AI 驱动的。它是那条侧栏里的一个 `record:reference_rail`,三张卡片每一张都只是已经关联到本商机的记录快照:报价单 `crm_quote`经其 **商机** 查找字段)、本商机的 `crm_opportunity_line_item` 明细行任务 `crm_task`经 **关联商机** 字段)。每张卡片给出关联记录总数的徽章、最多三条记录,以及一个跳转到完整列表的 *View all* 链接;查出来是空的卡片会折叠成一个 *+ N empty* 小按钮。卡片的标题按你当前阅读界面的语言取自对象标签——中文界面下是**报价单**、**商机产品明细**、**任务**——因为参考栏的条目自己不声明标题,标题是到译文包里查的,与全站其他地方取对象名的来源是同一处。两点提醒。参考栏的条目无法过滤,这三条也都没有声明过滤条件:查询只按关联字段走,所以任务那张卡片统计和列出的是本商机的全部任务,不分状态。这也正是它的标题是**任务**而不是「待办任务」的原因——真正过滤掉 *Completed*、一次十条的,是 *Related* 标签页上的 **Open Tasks** 列表。另外,参考栏里没有 Customer 360、没有营收预测,也没有任何「相关信号」,整页都没有:那些技能是靠提问去用的——见下文《AI Copilot 如何提供帮助》。

## AI Copilot 如何提供帮助

Expand Down
2 changes: 1 addition & 1 deletion content/docs/sales/opportunities.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ description: 活躍的銷售交易——銷售管道的核心,包含 7 個階
- **行項目**——帶有一個**新增產品**按鈕。
- **活動時間線**——統一彙集評論、已記錄通話(`sys_activity`)、外發郵件(`sys_email`)、任務完成以及欄位歷史稽核條目。商機上每個被追蹤的更改(階段、金額、成交日期、負責人)都會自動出現在這裡;沒有單獨的「歷史」標籤頁。
- **競爭對手與備註**——不存在這樣一個面板。這一頁(`src/pages/opportunity_detail.page.ts`)宣告了三個區域:一個頭部、一個裝著那組標籤頁的主欄,以及一條窄側欄(`aside`),而側欄裡唯一的元件就是下一條講的參考欄。頁面上沒有任何元件渲染「談話要點」。**競爭對手**這個欄位是真實存在的——商機上一個普通的多選欄位,目前的選項還是佔位的 *Competitor A*、*Competitor B*、*Competitor C*(`src/objects/opportunity.object.ts`)——但它根本不在這一頁上:你是在編輯表單的 *Sales Strategy* 分區裡填它,緊挨著 **下一步**。這一頁上最接近「備註」的,是 *Details* 標籤頁裡那個可摺疊的 **描述** 分區,裡面放著 **描述** 與 **下一步**。
- **AI 參考欄**——參考欄確實存在,但裡面沒有任何一條建議,這一頁上也沒有任何一個元件是 AI 驅動的。它是那條側欄裡的一個 `record:reference_rail`,三張卡片每一張都只是已經關聯到本商機的記錄快照:**Quotes**(報價單 `crm_quote`經其 **商機** 查找欄位)、**Products**(本商機的 `crm_opportunity_line_item` 明細行)、**Open Tasks**(任務 `crm_task`經 **關聯商機** 欄位)。每張卡片給出關聯記錄總數的徽章、最多三筆記錄,以及一個跳轉到完整列表的 *View all* 連結;查出來是空的卡片會摺疊成一個 *+ N empty* 小按鈕。兩點提醒。**Open Tasks** 這張卡片並不按狀態過濾——參考欄的條目不宣告過濾條件,查詢只按關聯欄位走——所以它統計和列出的是本商機的全部任務,不分狀態真正過濾掉 *Completed*、一次十筆的,是 *Related* 標籤頁上的 **Open Tasks** 列表。另外,參考欄裡沒有 Customer 360、沒有營收預測,也沒有任何「相關訊號」,整頁都沒有:那些技能是靠提問去用的——見下文《AI Copilot 如何提供協助》。
- **AI 參考欄**——參考欄確實存在,但裡面沒有任何一條建議,這一頁上也沒有任何一個元件是 AI 驅動的。它是那條側欄裡的一個 `record:reference_rail`,三張卡片每一張都只是已經關聯到本商機的記錄快照:報價單 `crm_quote`經其 **商機** 查找欄位)、本商機的 `crm_opportunity_line_item` 明細行任務 `crm_task`經 **關聯商機** 欄位)。每張卡片給出關聯記錄總數的徽章、最多三筆記錄,以及一個跳轉到完整列表的 *View all* 連結;查出來是空的卡片會摺疊成一個 *+ N empty* 小按鈕。卡片的標題取自對象標籤,隨你當前介面語言而變——英文介面下是 **Quote**、**Opportunity Line Item**、**Task**,簡體中文介面下是「报价单」「商机产品明细」「任务」——因為參考欄的條目自己不宣告標題,標題是到譯文包裡查的,與全站其他地方取對象名的來源是同一處。兩點提醒。參考欄的條目無法過濾,這三條也都沒有宣告過濾條件:查詢只按關聯欄位走,所以任務那張卡片統計和列出的是本商機的全部任務,不分狀態。這也正是它的標題是「任務」而不是「待辦任務」的原因——真正過濾掉 *Completed*、一次十筆的,是 *Related* 標籤頁上的 **Open Tasks** 列表。另外,參考欄裡沒有 Customer 360、沒有營收預測,也沒有任何「相關訊號」,整頁都沒有:那些技能是靠提問去用的——見下文《AI Copilot 如何提供協助》。

## AI Copilot 如何提供協助

Expand Down
37 changes: 34 additions & 3 deletions src/pages/opportunity_detail.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,23 +231,54 @@ export const OpportunityDetailPage: Page = {
type: 'record:reference_rail',
id: 'opp_reference_rail',
properties: {
/**
* No entry declares a `title` — deliberately (#972).
*
* The rail resolves a card's heading as
* `entry.title || i18n.objectLabel({ name: objectName, … })`, so a
* literal `title` does not merely provide a default: it WINS, and
* the locale bundle is never consulted. The three literals that
* used to sit here (`Quotes` / `Products` / `Open Tasks`) therefore
* printed English into every one of the four locales this app
* ships, on top of `objects.<name>.label` already being translated
* in all of them. Dropping them hands the heading back to the
* translation bundle — the single source of truth for what an
* object is called — so a locale added later is covered for free.
*
* A translated literal is not available as an alternative: unlike
* `record:alert`, whose `title` is run through the inline
* translation-map resolver, the rail renders `entry.title` as a
* raw React child. An `{ en, 'zh-CN' }` map here would not be
* resolved — it would be handed to React as an object.
*
* The third card losing the word "Open" is a correction, not a
* casualty. A rail entry has no filter at all — the rail queries
* `{ $filter: { [relationshipField]: parentId }, $top: limit }` and
* reads nothing else — so that card always counted and listed this
* deal's tasks whatever their status. The heading claimed a filter
* the component cannot apply. The genuinely filtered view is the
* `opp_tasks` related list on the *Related* tab above, which does
* carry `status neq completed`.
*
* Pinned by `test/metadata-references.test.ts` (entries resolve,
* no literal titles) and `test/i18n-references.test.ts` (every
* rail object has a `label` in every locale, so the fallback
* lands on a translation rather than on a humanized object name).
*/
entries: [
{
objectName: 'crm_quote',
relationshipField: 'crm_opportunity',
title: 'Quotes',
limit: 3,
},
{
objectName: 'crm_opportunity_line_item',
relationshipField: 'crm_opportunity',
title: 'Products',
limit: 3,
},
{
objectName: 'crm_task',
relationshipField: 'related_to_opportunity',
title: 'Open Tasks',
limit: 3,
},
],
Expand Down
39 changes: 39 additions & 0 deletions test/i18n-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,45 @@ describe('every locale is complete on every authored surface', () => {
expect(bad, `pages with untranslated copy:\n ${bad.join('\n ')}`).toEqual([]);
});

/**
* Reference-rail cards are headed by the OBJECT's label, not by any copy the
* page owns. The rail resolves
* `entry.title || i18n.objectLabel({ name, label: humanize(name) })`, and
* `objectLabel` looks up `objects.<name>.label` in the active locale bundle
* before falling back to the label passed in — which the rail sets to
* `humanize(objectName)`, i.e. `Crm Opportunity Line Item`.
*
* So the rail is one of the few surfaces where a MISSING object translation
* is worse than no translation system at all: the fallback is not the
* authored English label, it is a de-underscored object NAME. #972 removed
* the literal `title` from all three entries to let this lookup happen; this
* guard is what makes that safe, and it is scoped to the rail's objects
* deliberately — `objects.<name>.label` completeness at large is the
* surface-completeness test further down, which does not know that these
* three names have a second, harsher consumer.
*/
it('every reference-rail object has a translated label in every locale', () => {
const railObjects = [
...new Set(
pages
.flatMap((p: AnyRec) => (p.regions ?? []).flatMap((r: AnyRec) => r.components ?? []))
.filter((c: AnyRec) => c?.type === 'record:reference_rail')
.flatMap((c: AnyRec) => (c.properties?.entries ?? []) as AnyRec[])
.map((e: AnyRec) => e.objectName as string),
),
];
expect(railObjects.length, 'no rail entries found — this guard would be vacuous').toBeGreaterThan(0);

const bad: string[] = [];
for (const [locale, pack] of packs()) {
for (const name of railObjects) {
const label = pack.objects?.[name]?.label;
if (!label) bad.push(`${locale}: objects.${name}.label (rail card would read "${name}" humanized)`);
}
}
expect(bad, `reference-rail headings with no translation:\n ${bad.join('\n ')}`).toEqual([]);
});

it('every dashboard widget has a translated title and description', () => {
const dashboards: AnyRec[] = (stack as any).dashboards ?? [];
const bad: string[] = [];
Expand Down
Loading
Loading