From 3965b5f3581fe305f6ad87ea7b3c8fef57bbed7c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:13:15 +0000 Subject: [PATCH 1/2] wip(ux): forms, highlights and related tabs for #2369 rows 1-3, 5, 7, 9 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01J8VVAnq1x2jshPfejjsKuT --- src/marketing/objects/campaign.object.ts | 5 ++- src/revenue/objects/product.object.ts | 5 ++- src/revenue/objects/quote.object.ts | 9 ++++- src/sales/actions/contact.actions.ts | 4 +++ src/sales/objects/account.object.ts | 10 +++++- src/sales/objects/lead.object.ts | 4 ++- src/sales/pages/opportunity_detail.page.ts | 34 ++++++++----------- src/sales/translations/en/objects.pipeline.ts | 2 +- .../translations/es-ES/objects.pipeline.ts | 2 +- .../translations/ja-JP/objects.pipeline.ts | 2 +- .../translations/zh-CN/objects.pipeline.ts | 2 +- src/sales/views/contact.view.ts | 14 ++++++-- src/sales/views/lead.view.ts | 6 ++-- .../objects/knowledge_article.object.ts | 10 ++++-- src/service/pages/case_detail.page.ts | 5 +++ 15 files changed, 79 insertions(+), 35 deletions(-) diff --git a/src/marketing/objects/campaign.object.ts b/src/marketing/objects/campaign.object.ts index eda1e4449..b88588156 100644 --- a/src/marketing/objects/campaign.object.ts +++ b/src/marketing/objects/campaign.object.ts @@ -25,7 +25,10 @@ export const Campaign = ObjectSchema.create({ // title was a formula with no column for `$search` to default to. The // title is stored now; these stay because they name what is searched. searchableFields: ['name', 'campaign_code'], - highlightFields: ['campaign_code', 'name', 'type', 'status', 'start_date'], + // The record header's strip (#2369 row 5): the title already reads + // "CPG-0001 - Name". The Add to Campaign picker shows the same fields under + // each campaign, where the code used to repeat the title. + highlightFields: ['type', 'status', 'start_date', 'end_date', 'owner_id'], // Every other business object with a detail page groups its fields; campaign // was one of the two that did not, so its detail page fell back to one flat diff --git a/src/revenue/objects/product.object.ts b/src/revenue/objects/product.object.ts index 0b311c4bb..65421db36 100644 --- a/src/revenue/objects/product.object.ts +++ b/src/revenue/objects/product.object.ts @@ -25,7 +25,10 @@ export const Product = ObjectSchema.create({ // title was a formula with no column for `$search` to default to. The // title is stored now; these stay because they name what is searched. searchableFields: ['name', 'product_code', 'sku'], - highlightFields: ['product_code', 'name', 'category', 'is_active'], + // The record header's strip (#2369 row 5): the title already reads + // "PRD-0001 - Name". The lookup picker shows the same fields under each + // product, so a line item's product picker now shows the list price. + highlightFields: ['category', 'list_price', 'is_active'], fieldGroups: [ { key: 'basic', label: 'Product Information', icon: 'info' }, diff --git a/src/revenue/objects/quote.object.ts b/src/revenue/objects/quote.object.ts index d08dce710..b05c7c5f8 100644 --- a/src/revenue/objects/quote.object.ts +++ b/src/revenue/objects/quote.object.ts @@ -28,7 +28,11 @@ export const Quote = ObjectSchema.create({ // title was a formula with no column for `$search` to default to. The // title is stored now; these stay because they name what is searched. searchableFields: ['name', 'quote_number'], - highlightFields: ['quote_number', 'name', 'crm_account', 'status', 'total_price'], + // The record header's strip (#2369 row 5): the title already reads + // "QTE-0003 - Name", so the strip carries what it does not. The quote panels + // on an account, a contact and an opportunity keep their old columns through + // `relatedListColumns` on the three lookups below. + highlightFields: ['crm_account', 'crm_opportunity', 'status', 'total_price', 'expiration_date'], fieldGroups: [ { key: 'basic', label: 'Quote Information', icon: 'info' }, @@ -86,11 +90,13 @@ export const Quote = ObjectSchema.create({ required: true, storage: { notNull: true }, deleteBehavior: 'cascade', + relatedListColumns: ['quote_number', 'name', 'status', 'total_price'], }), crm_contact: Field.lookup('crm_contact', { label: 'Contact', group: 'basic', + relatedListColumns: ['quote_number', 'name', 'crm_account', 'status', 'total_price'], // A TRANSITION GATE, not an invariant (#1017, narrowed by #2161; AGENTS.md // Metadata semantics rule 7). Optional at draft time. Required on the // write that takes the quote INTO `presented` or `accepted`, and on every @@ -176,6 +182,7 @@ export const Quote = ObjectSchema.create({ group: 'basic', // Scope opportunities to the quote's account (was dead referenceFilters). dependsOn: ['crm_account'], + relatedListColumns: ['quote_number', 'name', 'crm_account', 'status', 'total_price'], }), diff --git a/src/sales/actions/contact.actions.ts b/src/sales/actions/contact.actions.ts index c5cbe698b..7c7ab2553 100644 --- a/src/sales/actions/contact.actions.ts +++ b/src/sales/actions/contact.actions.ts @@ -127,6 +127,10 @@ export const AddContactToCampaignAction: Action = { export const SendEmailAction: Action = { name: 'send_email', label: 'Send Email', + // The dialog's line under the title (#2369 row 3): who the email goes to. + // Without it the dialog read the platform's generic "Please provide the + // required information to continue." The holes read the clicked contact. + description: 'To: {{ record.full_name }} <{{ record.email }}>', objectName: 'crm_contact', icon: 'mail', // script, not modal: modal submits die on GET /api/v1/meta/object/ diff --git a/src/sales/objects/account.object.ts b/src/sales/objects/account.object.ts index 205357b80..3f5020487 100644 --- a/src/sales/objects/account.object.ts +++ b/src/sales/objects/account.object.ts @@ -22,7 +22,12 @@ export const Account = ObjectSchema.create({ // title was a formula with no column for `$search` to default to. The // title is stored now; these stay because they name what is searched. searchableFields: ['name', 'account_number', 'registration_number'], - highlightFields: ['account_number', 'name', 'type', 'owner_id'], + // The record header's strip (#2369 row 5): what the title does not say. The + // page header shows the name and `{industry} · {type}` (account_detail.page.ts), + // so the strip carries the number, the tier, the health, the revenue and the + // owner. The child-account panel keeps its old columns through + // `relatedListColumns` on `parent_account`. + highlightFields: ['account_number', 'tier', 'health_score', 'annual_revenue', 'owner_id'], // The record's picture (#1199): the record chrome draws the logo beside the // title in every account page header. No logo, no picture: the platform // draws no placeholder or initials in its place. @@ -546,6 +551,9 @@ export const Account = ObjectSchema.create({ label: 'Parent Account', description: 'Parent company in hierarchy', group: 'ownership', + // The child-account panel's columns. Without them it falls back to + // `highlightFields` minus this lookup, which is the header strip. + relatedListColumns: ['account_number', 'name', 'type', 'owner_id'], }), // Boolean field diff --git a/src/sales/objects/lead.object.ts b/src/sales/objects/lead.object.ts index 73e22fa62..4748ab6f2 100644 --- a/src/sales/objects/lead.object.ts +++ b/src/sales/objects/lead.object.ts @@ -255,7 +255,9 @@ export const Lead = ObjectSchema.create({ rating: Field.rating(5, { label: 'Lead Score', - description: 'Lead quality score (1-5 stars)', + // Says what a blank does (#2369 row 2): `lead_automation` scores a lead + // created without one (`computeRating` in `lead.hook.ts`). + description: 'Leave blank and a new lead is scored for you (1-5 stars) from its email domain, phone, job title, industry and company size.', group: 'qualification', }), diff --git a/src/sales/pages/opportunity_detail.page.ts b/src/sales/pages/opportunity_detail.page.ts index 82865cade..0aeefbca8 100644 --- a/src/sales/pages/opportunity_detail.page.ts +++ b/src/sales/pages/opportunity_detail.page.ts @@ -278,6 +278,7 @@ export const OpportunityDetailPage: Page = { type: 'page:accordion', id: 'opp_related_accordion', properties: { + allowMultiple: true, items: [ { // Accordion item `key` is DELETED, not renamed to @@ -304,26 +305,19 @@ export const OpportunityDetailPage: Page = { // and the component's own designer text says the same // ("collapsed: false opens a panel by default"). // - // ⚠️ ONLY the first panel, and that is the renderer's - // choice rather than a preference: with `allowMultiple` - // absent (it defaults false) the accordion mounts as - // `type="single"` and takes `defaultValue={open[0]}` — - // one panel, the first in declaration order. Marking - // Products or Open Tasks too would change nothing on - // screen. Opening more than one means declaring - // `allowMultiple: true`, which changes the interaction - // model (panels stop closing each other) and is not - // this card's ask. + // EVERY panel opens (#2369 row 9), the lead page's + // shape: `allowMultiple: true` above mounts the + // accordion as `type="multiple"`, so the renderer + // opens each item marked `collapsed: false` instead of + // only the first. Before, Products and Open Tasks + // started shut and closed each other, and a deal's + // line items (and their Add) sat behind a click. // - // ⛔ NOT "default-open the sections that have rows", - // which the card asked for first: that is not - // expressible here. `collapsed` is a static boolean on - // a strict item shape (`label` / `icon` / `collapsed` / - // `children` — nothing else parses), the page is - // authored metadata evaluated before any row is read, - // and the related lists below fetch their own rows - // after mount. Reported rather than worked around; ⛔ - // the component is not this repo's to change. + // ⛔ Still NOT "open the sections that have rows": + // `collapsed` is a static boolean on a strict item + // shape (`label` / `icon` / `collapsed` / `children`), + // the page is evaluated before any row is read, and + // the related lists fetch their own rows after mount. collapsed: false, children: [ { @@ -340,6 +334,7 @@ export const OpportunityDetailPage: Page = { }, { label: 'Products', + collapsed: false, children: [ { type: 'record:related_list', @@ -435,6 +430,7 @@ export const OpportunityDetailPage: Page = { }, { label: 'Open Tasks', + collapsed: false, children: [ { type: 'record:related_list', diff --git a/src/sales/translations/en/objects.pipeline.ts b/src/sales/translations/en/objects.pipeline.ts index 43586a76a..613eefc6a 100644 --- a/src/sales/translations/en/objects.pipeline.ts +++ b/src/sales/translations/en/objects.pipeline.ts @@ -45,7 +45,7 @@ export const pipeline: Record = { industry: { label: 'Industry' }, mobile: { label: 'Mobile' }, website: { label: 'Website' }, - rating: { label: 'Lead Score', help: 'Lead quality score (1-5 stars)' }, + rating: { label: 'Lead Score', help: 'Leave blank and a new lead is scored for you (1-5 stars) from its email domain, phone, job title, industry and company size.' }, converted_account: { label: 'Converted Account' }, converted_contact: { label: 'Converted Contact' }, converted_opportunity: { label: 'Converted Opportunity' }, diff --git a/src/sales/translations/es-ES/objects.pipeline.ts b/src/sales/translations/es-ES/objects.pipeline.ts index 5ecfd8445..de72a128a 100644 --- a/src/sales/translations/es-ES/objects.pipeline.ts +++ b/src/sales/translations/es-ES/objects.pipeline.ts @@ -90,7 +90,7 @@ export const pipeline: Record = { industry: { label: 'Industria' }, mobile: { label: 'Móvil' }, website: { label: 'Sitio Web' }, - rating: { label: 'Puntuación de Prospecto', help: 'Puntuación de calidad del prospecto (1-5 estrellas)' }, + rating: { label: 'Puntuación de Prospecto', help: 'Si se deja en blanco, el prospecto nuevo se puntúa automáticamente (1-5 estrellas) según el dominio del correo, el teléfono, el cargo, el sector y el tamaño de la empresa.' }, next_followup_date: { label: 'Próxima Fecha de Seguimiento' }, last_contacted_date: { label: 'Último Contacto' }, need_type: { diff --git a/src/sales/translations/ja-JP/objects.pipeline.ts b/src/sales/translations/ja-JP/objects.pipeline.ts index 6f4d1dbe0..8075d73fd 100644 --- a/src/sales/translations/ja-JP/objects.pipeline.ts +++ b/src/sales/translations/ja-JP/objects.pipeline.ts @@ -93,7 +93,7 @@ export const pipeline: Record = { description: { label: '説明' }, mobile: { label: '携帯電話' }, website: { label: 'Webサイト' }, - rating: { label: 'リードスコア', help: 'リードの品質スコア(1〜5 段階)' }, + rating: { label: 'リードスコア', help: '空欄のままにすると、新しいリードはメールのドメイン、電話番号、役職、業種、会社規模から自動で採点されます(1〜5 段階)。' }, converted_account: { label: '変換後の取引先' }, converted_contact: { label: '変換後の取引先責任者' }, converted_opportunity: { label: '変換後の商談' }, diff --git a/src/sales/translations/zh-CN/objects.pipeline.ts b/src/sales/translations/zh-CN/objects.pipeline.ts index 3ffb50add..05d78989f 100644 --- a/src/sales/translations/zh-CN/objects.pipeline.ts +++ b/src/sales/translations/zh-CN/objects.pipeline.ts @@ -84,7 +84,7 @@ export const pipeline: Record = { description: { label: '描述' }, mobile: { label: '手机' }, website: { label: '网站' }, - rating: { label: '线索评分', help: '线索质量评分(1-5 星)' }, + rating: { label: '线索评分', help: '留空时,新线索会根据邮箱域名、电话、职位、行业和公司规模自动评分(1-5 星)。' }, converted_account: { label: '转化客户' }, converted_contact: { label: '转化联系人' }, converted_opportunity: { label: '转化商机' }, diff --git a/src/sales/views/contact.view.ts b/src/sales/views/contact.view.ts index c41b60d3f..883193401 100644 --- a/src/sales/views/contact.view.ts +++ b/src/sales/views/contact.view.ts @@ -89,16 +89,24 @@ export const ContactViews = defineView({ type: 'tabbed', sections: [ { + // The first tab holds everything Create requires (#2369 row 1). + // `email` is required on the object, and on the Contact Info tab + // Create jumped there with "Email is required" after the rep had + // filled this one. Three columns put the three name parts on one row + // (Last Name used to take a whole row of its own), then the account, + // then how to reach the person. name: 'identity', label: 'Identity', - columns: 2, + columns: 3, fields: [ 'salutation', { field: 'first_name', required: true }, - { field: 'last_name', required: true, span: 'full' }, + { field: 'last_name', required: true }, { field: 'crm_account', required: true }, 'title', 'department', + 'email', + 'phone', 'owner_id', ], }, @@ -133,7 +141,7 @@ export const ContactViews = defineView({ name: 'contact_details', label: 'Contact Info', columns: 2, - fields: ['email', 'phone', 'mobile', 'avatar'], + fields: ['mobile', 'avatar'], }, { // Named `mailing_address` — deliberately the SAME key as the diff --git a/src/sales/views/lead.view.ts b/src/sales/views/lead.view.ts index 6aca1e369..e2f89a1cb 100644 --- a/src/sales/views/lead.view.ts +++ b/src/sales/views/lead.view.ts @@ -248,7 +248,10 @@ export const LeadViews = defineView({ label: 'Contact Information', collapsible: true, collapsed: false, - columns: 2, // 2-column layout + // Three columns put Salutation, First Name and Last Name on one row + // (#2369 row 2). On two, Salutation took half a row and Last Name a + // whole one. The nine fields fill three rows exactly. + columns: 3, fields: [ { field: 'salutation', @@ -260,7 +263,6 @@ export const LeadViews = defineView({ { field: 'last_name', required: true, - span: 'full', }, 'company', 'title', diff --git a/src/service/objects/knowledge_article.object.ts b/src/service/objects/knowledge_article.object.ts index 5b81c41cb..f49c76de8 100644 --- a/src/service/objects/knowledge_article.object.ts +++ b/src/service/objects/knowledge_article.object.ts @@ -110,11 +110,16 @@ export const KnowledgeArticle = ObjectSchema.create({ // title was a formula with no column for `$search` to default to. The // title is stored now; these stay because they name what is searched. searchableFields: ['title', 'article_number', 'summary'], - highlightFields: ['article_number', 'title', 'category', 'status', 'audience'], + // The record header's strip (#2369 rows 5 and 7): the title already reads + // "KA-0001 - Title", so the strip carries what it does not, the vote counts + // among them. + highlightFields: ['category', 'status', 'audience', 'helpful_count', 'not_helpful_count'], + // `content` leads (#2369 row 7): the record page draws the groups in this + // order, and a reader opens an article for its body, not its metadata. fieldGroups: [ - { key: 'basic', label: 'Article Information', icon: 'info' }, { key: 'content', label: 'Content', icon: 'file-text' }, + { key: 'basic', label: 'Article Information', icon: 'info' }, { key: 'taxonomy', label: 'Categorization', icon: 'tag' }, { key: 'metrics', label: 'Engagement', icon: 'activity', collapse: 'collapsed' }, ], @@ -247,6 +252,7 @@ export const KnowledgeArticle = ObjectSchema.create({ label: 'Originating Case', description: 'Case this article was authored from (optional).', group: 'content', + relatedListColumns: ['article_number', 'title', 'category', 'status', 'audience'], }), diff --git a/src/service/pages/case_detail.page.ts b/src/service/pages/case_detail.page.ts index 0069789ca..db54ba762 100644 --- a/src/service/pages/case_detail.page.ts +++ b/src/service/pages/case_detail.page.ts @@ -224,6 +224,11 @@ export const CaseDetailPage: Page = { // `page:accordion` does not. Nothing addresses these // panels, so the identifier has nowhere to land. label: 'Open Tasks', + // Open on arrival (#2369 row 9): the tab's only + // panel started shut, so the Related tab looked + // empty. The renderer opens an item only on a strict + // `collapsed === false` (see opportunity_detail.page.ts). + collapsed: false, children: [ { type: 'record:related_list', From 6c9b308df24ca762d69cf2cc3956f5729f21747c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:24:14 +0000 Subject: [PATCH 2/2] feat(ux): forms, record pages and record headers for #2369 rows 1-9 Contact email and phone on the first tab, lead name row and score hint, Send Email description, highlights that carry what the title does not (product picker shows List Price), case type on the internal form and an SLA-breached banner instead of a red "No", article body first with vote counts and outcome messages, attendee hint on the activity actions, and related panels open by default. Row 10 is measured only. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01J8VVAnq1x2jshPfejjsKuT --- .changeset/2369-forms-record-pages.md | 8 + src/docs/crm_sales_activities.md | 3 +- src/docs/crm_sales_activities.zh.md | 2 +- src/docs/crm_sales_leads.md | 2 + src/docs/crm_sales_leads.zh.md | 2 +- src/marketing/objects/campaign.object.ts | 7 +- src/sales/actions/activity-actions.ts | 9 + src/sales/actions/contact.actions.ts | 11 +- src/sales/translations/en/_shared.ts | 15 +- src/sales/translations/en/app.ts | 1 + src/sales/translations/en/objects.customer.ts | 1 + src/sales/translations/en/objects.service.ts | 10 ++ src/sales/translations/es-ES/_shared.ts | 15 +- src/sales/translations/es-ES/app.ts | 1 + .../translations/es-ES/objects.customer.ts | 1 + .../translations/es-ES/objects.service.ts | 10 ++ src/sales/translations/ja-JP/_shared.ts | 15 +- src/sales/translations/ja-JP/app.ts | 1 + .../translations/ja-JP/objects.customer.ts | 1 + .../translations/ja-JP/objects.service.ts | 10 ++ src/sales/translations/zh-CN/_shared.ts | 15 +- src/sales/translations/zh-CN/app.ts | 1 + .../translations/zh-CN/objects.customer.ts | 1 + .../translations/zh-CN/objects.service.ts | 10 ++ .../actions/knowledge_article.actions.ts | 55 ++++-- src/service/docs/crm_service_cases.md | 3 + src/service/docs/crm_service_cases.zh.md | 2 + src/service/docs/crm_service_knowledge.md | 5 + src/service/docs/crm_service_knowledge.zh.md | 2 + src/service/pages/case_detail.page.ts | 36 +++- src/service/views/case.view.ts | 6 +- test/case-create-form-narrowing.test.ts | 17 +- test/forms-record-pages-2369.test.ts | 156 ++++++++++++++++++ test/knowledge-feedback.test.ts | 39 +++++ 34 files changed, 436 insertions(+), 37 deletions(-) create mode 100644 .changeset/2369-forms-record-pages.md create mode 100644 test/forms-record-pages-2369.test.ts diff --git a/.changeset/2369-forms-record-pages.md b/.changeset/2369-forms-record-pages.md new file mode 100644 index 000000000..61f835742 --- /dev/null +++ b/.changeset/2369-forms-record-pages.md @@ -0,0 +1,8 @@ +--- +"hotcrm": patch +--- + +Clause-②: no +Metadata-protocol: none + +**Forms and record pages are laid out for the work done on them (#2369).** **New Contact** asks for Email and Phone on its first tab, beside the required names, so a bad email is flagged where you are typing. **New Lead** puts Salutation, First Name and Last Name on one row, and **Lead Score** says that a blank score is filled in for you. The **Send Email** dialog says where the email goes. The record headers of accounts, quotes, products, campaigns and knowledge articles show what the title does not: tier, health and revenue on an account; account, deal, status, total and expiry on a quote; type, status, start date and budget on a campaign; and List Price on a product, which the product picker on a line item now shows too. The related lists that used to borrow those headers keep their columns. A staff-created case can now set **Case Type**. A case on track no longer shows a red "× No" for SLA Violated; a breached case shows a red **SLA breached** banner (「已违反 SLA」) instead. A knowledge article opens on its body and shows its Helpful and Not Helpful counts in the header, and after a vote the message says whether it was recorded, changed or already your verdict, with the current counts. **Log a Call**, **Log a Meeting** and **Schedule a Meeting** say that the contact or lead you start from is added for you. The Related tabs of opportunities and cases open their Quotes, Products and Open Tasks panels. New and changed text is in English, Chinese, Spanish and Japanese, and the Sales, Service and Knowledge manual pages describe the new behaviour. diff --git a/src/docs/crm_sales_activities.md b/src/docs/crm_sales_activities.md index b10bff1d0..626dc2be3 100644 --- a/src/docs/crm_sales_activities.md +++ b/src/docs/crm_sales_activities.md @@ -25,7 +25,8 @@ Leads, contacts, accounts, opportunities and cases each carry three buttons: - **Log a Call** and **Log a Meeting** record something that already happened. The event is saved as **held**, and the people you name are added as its - attendees. + attendees. You are added as the organizer, and the contact or lead you start + from is added for you, so **Contact Attendees** is only for anyone else. - **Schedule a Meeting** books one in the future. It is saved as **planned** and appears in **My Calendar**. diff --git a/src/docs/crm_sales_activities.zh.md b/src/docs/crm_sales_activities.zh.md index a50196c53..c5504b667 100644 --- a/src/docs/crm_sales_activities.zh.md +++ b/src/docs/crm_sales_activities.zh.md @@ -11,7 +11,7 @@ description: 任务、电话和会议记录、安排跟进,以及它们背后 线索、联系人、客户、商机和工单上都有三个按钮: -- **记录通话**和**记录会议**用来记录已经发生的事。活动以**已举行**状态保存,你填写的人会成为它的参与者。 +- **记录通话**和**记录会议**用来记录已经发生的事。活动以**已举行**状态保存,你填写的人会成为它的参与者。你自己作为组织者加入,你发起操作的那位联系人或线索也会自动加入,所以**联系人参与者**里只需填其他人。 - **安排会议**用来预约将来的会议。它以**已计划**状态保存,并出现在**我的日历**里。 活动会关联到你发起操作的那条记录,因此会出现在那条记录的相关列表里,以及**互动 › 活动**下的日历视图中。 diff --git a/src/docs/crm_sales_leads.md b/src/docs/crm_sales_leads.md index 07bf28145..0fd83dfe3 100644 --- a/src/docs/crm_sales_leads.md +++ b/src/docs/crm_sales_leads.md @@ -28,6 +28,8 @@ automatically: **Lead Score** is the 1–5 star field on each lead (shown as *Lead Score* in the UI). A score of 4★ or more marks the lead "hot" and triggers the 1-day SLA. +Leave it blank on a new lead and HotCRM fills it from the email domain, phone, +job title, industry and company size; a score you type stands. A lead submitted through a web-to-lead form arrives with nobody behind it, so it is **auto-assigned to the sales rep with the fewest open leads** — a diff --git a/src/docs/crm_sales_leads.zh.md b/src/docs/crm_sales_leads.zh.md index 83d656c7b..7e9aece0c 100644 --- a/src/docs/crm_sales_leads.zh.md +++ b/src/docs/crm_sales_leads.zh.md @@ -16,7 +16,7 @@ description: 新线索如何获得跟进期限和负责人,以及转化一条 | **热线索**——4★ 或以上 | **1 天** | 提醒负责人在 *24 小时内跟进*。 | | 其余线索 | **3 天** | 负责人获得一个标准的跟进期限。 | -**线索评分**是每条线索上的 1–5 星字段(界面上显示为*线索评分*)。4★ 或以上即视为"热线索",触发 1 天的 SLA。 +**线索评分**是每条线索上的 1–5 星字段(界面上显示为*线索评分*)。4★ 或以上即视为"热线索",触发 1 天的 SLA。新建线索时留空,HotCRM 会根据邮箱域名、电话、职位、行业和公司规模替你打分;你自己填写的评分会保留。 通过网页表单提交的线索(网页转线索)背后没有具体的人,所以会**自动分配给名下未结线索最少的销售代表**——在所有持有*销售代表*岗位的人之间自我均衡地轮询。只有在还没有任何销售代表时,这类线索才会暂时无人负责,等待经理认领。 diff --git a/src/marketing/objects/campaign.object.ts b/src/marketing/objects/campaign.object.ts index b88588156..f6f2b3b1a 100644 --- a/src/marketing/objects/campaign.object.ts +++ b/src/marketing/objects/campaign.object.ts @@ -27,8 +27,11 @@ export const Campaign = ObjectSchema.create({ searchableFields: ['name', 'campaign_code'], // The record header's strip (#2369 row 5): the title already reads // "CPG-0001 - Name". The Add to Campaign picker shows the same fields under - // each campaign, where the code used to repeat the title. - highlightFields: ['type', 'status', 'start_date', 'end_date', 'owner_id'], + // each campaign, where the code used to repeat the title. `end_date` and + // `owner_id` stay in the body: each is the last visible member of its group + // ("schedule", "assignment"), which would then never render on the detail + // page (lint `field-group-shadowed`). + highlightFields: ['type', 'status', 'start_date', 'budgeted_cost'], // Every other business object with a detail page groups its fields; campaign // was one of the two that did not, so its detail page fell back to one flat diff --git a/src/sales/actions/activity-actions.ts b/src/sales/actions/activity-actions.ts index faad374dc..6740fcb47 100644 --- a/src/sales/actions/activity-actions.ts +++ b/src/sales/actions/activity-actions.ts @@ -102,6 +102,7 @@ export const ACTIVITY_TARGETS: Record = { crm_case: 'related_to_case', }; + /** JS string literal, safely quoted for splicing into a body source. */ const lit = (value: string): string => JSON.stringify(value); @@ -447,6 +448,14 @@ function activityAction(spec: ActivitySpec, objectName: string, nameField: strin reference: 'crm_contact', multiple: true, required: false, + // Says what the body does with the record itself (#2369 row 8): on a + // contact or a lead that person is added as an attendee (the attendees + // step above) while this picker stays empty. One sentence true on all + // five objects, because the locale packs share one params block. + // ⛔ Not a prefill: on 18.2.0 the dialog gets no row from the record + // header or the list row menu, so `defaultFromRow` fills nothing and a + // `{{ record.x }}` hole prints raw (measured; platform finding). + helpText: 'The contact or lead you log this on is added automatically. Add anyone else here.', }, { name: 'attendee_users', diff --git a/src/sales/actions/contact.actions.ts b/src/sales/actions/contact.actions.ts index 7c7ab2553..d5c5bc0e8 100644 --- a/src/sales/actions/contact.actions.ts +++ b/src/sales/actions/contact.actions.ts @@ -127,10 +127,13 @@ export const AddContactToCampaignAction: Action = { export const SendEmailAction: Action = { name: 'send_email', label: 'Send Email', - // The dialog's line under the title (#2369 row 3): who the email goes to. - // Without it the dialog read the platform's generic "Please provide the - // required information to continue." The holes read the clicked contact. - description: 'To: {{ record.full_name }} <{{ record.email }}>', + // The line under the dialog's title (#2369 row 3), which read the platform's + // generic "Please provide the required information to continue.": where the + // email goes and what happens to it. It names no address: on 18.2.0 the + // dialog gets no row from the record header or the list row menu, so a + // `{{ record.email }}` hole prints raw and a `defaultFromRow` param stays + // empty (measured; platform finding). + description: "Goes to this contact's email address. It is queued, then logged on the contact's timeline.", objectName: 'crm_contact', icon: 'mail', // script, not modal: modal submits die on GET /api/v1/meta/object/ diff --git a/src/sales/translations/en/_shared.ts b/src/sales/translations/en/_shared.ts index 88e52fff6..fdf48b93a 100644 --- a/src/sales/translations/en/_shared.ts +++ b/src/sales/translations/en/_shared.ts @@ -33,7 +33,10 @@ export const activityActions = { params: { subject: { label: 'Call Subject' }, duration: { label: 'Duration (minutes)' }, - attendee_contacts: { label: 'Contact Attendees' }, + attendee_contacts: { + label: 'Contact Attendees', + helpText: 'The contact or lead you log this on is added automatically. Add anyone else here.', + }, attendee_users: { label: 'Internal Attendees' }, notes: { label: 'Call Notes' }, }, @@ -44,7 +47,10 @@ export const activityActions = { params: { subject: { label: 'Meeting Subject' }, duration: { label: 'Duration (minutes)' }, - attendee_contacts: { label: 'Contact Attendees' }, + attendee_contacts: { + label: 'Contact Attendees', + helpText: 'The contact or lead you log this on is added automatically. Add anyone else here.', + }, attendee_users: { label: 'Internal Attendees' }, notes: { label: 'Meeting Notes' }, }, @@ -58,7 +64,10 @@ export const activityActions = { start_time: { label: 'Start Time (UTC)' }, location: { label: 'Location' }, duration: { label: 'Duration (minutes)' }, - attendee_contacts: { label: 'Contact Attendees' }, + attendee_contacts: { + label: 'Contact Attendees', + helpText: 'The contact or lead you log this on is added automatically. Add anyone else here.', + }, attendee_users: { label: 'Internal Attendees' }, notes: { label: 'Agenda' }, }, diff --git a/src/sales/translations/en/app.ts b/src/sales/translations/en/app.ts index 514e61835..d330af682 100644 --- a/src/sales/translations/en/app.ts +++ b/src/sales/translations/en/app.ts @@ -170,6 +170,7 @@ export const appSurface: Omit = { subtitle: '{crm_account}', components: { case_highlights: { label: 'Key Information' }, + case_sla_breached: { label: 'SLA Breached' }, case_status_path: { label: 'Case Status Path' }, }, }, diff --git a/src/sales/translations/en/objects.customer.ts b/src/sales/translations/en/objects.customer.ts index 71fa60b24..a1403af42 100644 --- a/src/sales/translations/en/objects.customer.ts +++ b/src/sales/translations/en/objects.customer.ts @@ -200,6 +200,7 @@ export const customer: Record = { }, send_email: { label: 'Send Email', + description: 'Goes to this contact\'s email address. It is queued, then logged on the contact\'s timeline.', params: { subject: { label: 'Subject' }, body: { label: 'Body' }, diff --git a/src/sales/translations/en/objects.service.ts b/src/sales/translations/en/objects.service.ts index 338adea52..efc43f024 100644 --- a/src/sales/translations/en/objects.service.ts +++ b/src/sales/translations/en/objects.service.ts @@ -187,10 +187,20 @@ export const service: Record = { mark_article_helpful: { label: 'Helpful', successMessage: 'Thanks — recorded as helpful.', + outcomeMessages: { + recorded: 'Thanks — recorded as helpful. Helpful: ${result.helpful} · Not helpful: ${result.notHelpful}.', + changed: 'Your vote is now Helpful. Helpful: ${result.helpful} · Not helpful: ${result.notHelpful}.', + already: 'You already marked this article helpful. Helpful: ${result.helpful} · Not helpful: ${result.notHelpful}.', + }, }, mark_article_not_helpful: { label: 'Not Helpful', successMessage: 'Thanks — recorded as not helpful.', + outcomeMessages: { + recorded: 'Thanks — recorded as not helpful. Helpful: ${result.helpful} · Not helpful: ${result.notHelpful}.', + changed: 'Your vote is now Not Helpful. Helpful: ${result.helpful} · Not helpful: ${result.notHelpful}.', + already: 'You already marked this article not helpful. Helpful: ${result.helpful} · Not helpful: ${result.notHelpful}.', + }, }, }, }, diff --git a/src/sales/translations/es-ES/_shared.ts b/src/sales/translations/es-ES/_shared.ts index cac5931bd..b2973ad33 100644 --- a/src/sales/translations/es-ES/_shared.ts +++ b/src/sales/translations/es-ES/_shared.ts @@ -31,7 +31,10 @@ export const activityActions = { params: { subject: { label: 'Asunto de la llamada' }, duration: { label: 'Duración (minutos)' }, - attendee_contacts: { label: 'Contactos participantes' }, + attendee_contacts: { + label: 'Contactos participantes', + helpText: 'El contacto o lead en el que lo registras se añade automáticamente. Añade aquí a cualquier otra persona.', + }, attendee_users: { label: 'Participantes internos' }, notes: { label: 'Notas de la llamada' }, }, @@ -42,7 +45,10 @@ export const activityActions = { params: { subject: { label: 'Asunto de la reunión' }, duration: { label: 'Duración (minutos)' }, - attendee_contacts: { label: 'Contactos participantes' }, + attendee_contacts: { + label: 'Contactos participantes', + helpText: 'El contacto o lead en el que lo registras se añade automáticamente. Añade aquí a cualquier otra persona.', + }, attendee_users: { label: 'Participantes internos' }, notes: { label: 'Notas de la reunión' }, }, @@ -56,7 +62,10 @@ export const activityActions = { start_time: { label: 'Hora de inicio (UTC)' }, location: { label: 'Ubicación' }, duration: { label: 'Duración (minutos)' }, - attendee_contacts: { label: 'Contactos participantes' }, + attendee_contacts: { + label: 'Contactos participantes', + helpText: 'El contacto o lead en el que lo registras se añade automáticamente. Añade aquí a cualquier otra persona.', + }, attendee_users: { label: 'Participantes internos' }, notes: { label: 'Agenda' }, }, diff --git a/src/sales/translations/es-ES/app.ts b/src/sales/translations/es-ES/app.ts index 36c6833c2..e2ecd9850 100644 --- a/src/sales/translations/es-ES/app.ts +++ b/src/sales/translations/es-ES/app.ts @@ -522,6 +522,7 @@ export const appSurface: Omit = { subtitle: '{crm_account}', components: { case_highlights: { label: 'Información Clave' }, + case_sla_breached: { label: 'SLA incumplido' }, case_status_path: { label: 'Progreso del Estado del Caso' }, case_details: { label: 'Detalles del Caso' }, // `page:tabs` item labels, keyed by each item's `value` (spec 18.1.0, diff --git a/src/sales/translations/es-ES/objects.customer.ts b/src/sales/translations/es-ES/objects.customer.ts index d3d931229..285f0d350 100644 --- a/src/sales/translations/es-ES/objects.customer.ts +++ b/src/sales/translations/es-ES/objects.customer.ts @@ -246,6 +246,7 @@ export const customer: Record = { }, send_email: { label: 'Enviar Correo', + description: 'Se envía a la dirección de correo de este contacto. Se pone en cola y luego se registra en la cronología del contacto.', params: { subject: { label: 'Asunto' }, body: { label: 'Cuerpo' }, diff --git a/src/sales/translations/es-ES/objects.service.ts b/src/sales/translations/es-ES/objects.service.ts index 8dccfb74a..f71c23e0b 100644 --- a/src/sales/translations/es-ES/objects.service.ts +++ b/src/sales/translations/es-ES/objects.service.ts @@ -221,10 +221,20 @@ export const service: Record = { mark_article_helpful: { label: 'Útil', successMessage: 'Gracias — registrado como útil.', + outcomeMessages: { + recorded: 'Gracias — registrado como útil. Útil: ${result.helpful} · No útil: ${result.notHelpful}.', + changed: 'Tu voto ahora es Útil. Útil: ${result.helpful} · No útil: ${result.notHelpful}.', + already: 'Ya marcaste este artículo como útil. Útil: ${result.helpful} · No útil: ${result.notHelpful}.', + }, }, mark_article_not_helpful: { label: 'No útil', successMessage: 'Gracias — registrado como no útil.', + outcomeMessages: { + recorded: 'Gracias — registrado como no útil. Útil: ${result.helpful} · No útil: ${result.notHelpful}.', + changed: 'Tu voto ahora es No útil. Útil: ${result.helpful} · No útil: ${result.notHelpful}.', + already: 'Ya marcaste este artículo como no útil. Útil: ${result.helpful} · No útil: ${result.notHelpful}.', + }, }, }, }, diff --git a/src/sales/translations/ja-JP/_shared.ts b/src/sales/translations/ja-JP/_shared.ts index 0e3552a47..233641cd4 100644 --- a/src/sales/translations/ja-JP/_shared.ts +++ b/src/sales/translations/ja-JP/_shared.ts @@ -31,7 +31,10 @@ export const activityActions = { params: { subject: { label: '電話の件名' }, duration: { label: '所要時間(分)' }, - attendee_contacts: { label: '取引先責任者の参加者' }, + attendee_contacts: { + label: '取引先責任者の参加者', + helpText: '記録先の取引先責任者またはリードは自動的に追加されます。その他の参加者をここに追加してください。', + }, attendee_users: { label: '社内参加者' }, notes: { label: '通話メモ' }, }, @@ -42,7 +45,10 @@ export const activityActions = { params: { subject: { label: '会議の件名' }, duration: { label: '所要時間(分)' }, - attendee_contacts: { label: '取引先責任者の参加者' }, + attendee_contacts: { + label: '取引先責任者の参加者', + helpText: '記録先の取引先責任者またはリードは自動的に追加されます。その他の参加者をここに追加してください。', + }, attendee_users: { label: '社内参加者' }, notes: { label: '議事メモ' }, }, @@ -56,7 +62,10 @@ export const activityActions = { start_time: { label: '開始時刻 (UTC)' }, location: { label: '場所' }, duration: { label: '所要時間(分)' }, - attendee_contacts: { label: '取引先責任者の参加者' }, + attendee_contacts: { + label: '取引先責任者の参加者', + helpText: '記録先の取引先責任者またはリードは自動的に追加されます。その他の参加者をここに追加してください。', + }, attendee_users: { label: '社内参加者' }, notes: { label: 'アジェンダ' }, }, diff --git a/src/sales/translations/ja-JP/app.ts b/src/sales/translations/ja-JP/app.ts index 0e1f14813..f58e72d5b 100644 --- a/src/sales/translations/ja-JP/app.ts +++ b/src/sales/translations/ja-JP/app.ts @@ -524,6 +524,7 @@ export const appSurface: Omit = { subtitle: '{crm_account}', components: { case_highlights: { label: '重要情報' }, + case_sla_breached: { label: 'SLA違反' }, case_status_path: { label: 'ケースステータスの進捗' }, case_details: { label: 'ケース詳細情報' }, // `page:tabs` item labels, keyed by each item's `value` (spec 18.1.0, diff --git a/src/sales/translations/ja-JP/objects.customer.ts b/src/sales/translations/ja-JP/objects.customer.ts index 34388bf80..f1ce68789 100644 --- a/src/sales/translations/ja-JP/objects.customer.ts +++ b/src/sales/translations/ja-JP/objects.customer.ts @@ -232,6 +232,7 @@ export const customer: Record = { }, send_email: { label: 'メール送信', + description: 'この取引先責任者のメールアドレスに送信されます。送信キューに入った後、取引先責任者のタイムラインに記録されます。', params: { subject: { label: '件名' }, body: { label: '本文' }, diff --git a/src/sales/translations/ja-JP/objects.service.ts b/src/sales/translations/ja-JP/objects.service.ts index fee543b07..36576b50f 100644 --- a/src/sales/translations/ja-JP/objects.service.ts +++ b/src/sales/translations/ja-JP/objects.service.ts @@ -218,10 +218,20 @@ export const service: Record = { mark_article_helpful: { label: '役に立った', successMessage: '「役に立った」として記録しました。ありがとうございます。', + outcomeMessages: { + recorded: '「役に立った」として記録しました。ありがとうございます。役に立った:${result.helpful} · 役に立たなかった:${result.notHelpful}。', + changed: '投票を「役に立った」に変更しました。役に立った:${result.helpful} · 役に立たなかった:${result.notHelpful}。', + already: 'この記事はすでに「役に立った」と評価済みです。役に立った:${result.helpful} · 役に立たなかった:${result.notHelpful}。', + }, }, mark_article_not_helpful: { label: '役に立たなかった', successMessage: '「役に立たなかった」として記録しました。ありがとうございます。', + outcomeMessages: { + recorded: '「役に立たなかった」として記録しました。ありがとうございます。役に立った:${result.helpful} · 役に立たなかった:${result.notHelpful}。', + changed: '投票を「役に立たなかった」に変更しました。役に立った:${result.helpful} · 役に立たなかった:${result.notHelpful}。', + already: 'この記事はすでに「役に立たなかった」と評価済みです。役に立った:${result.helpful} · 役に立たなかった:${result.notHelpful}。', + }, }, }, }, diff --git a/src/sales/translations/zh-CN/_shared.ts b/src/sales/translations/zh-CN/_shared.ts index 9f3f5c601..6e3488631 100644 --- a/src/sales/translations/zh-CN/_shared.ts +++ b/src/sales/translations/zh-CN/_shared.ts @@ -30,7 +30,10 @@ export const activityActions = { params: { subject: { label: '通话主题' }, duration: { label: '时长(分钟)' }, - attendee_contacts: { label: '联系人参与者' }, + attendee_contacts: { + label: '联系人参与者', + helpText: '你正在记录的联系人或线索会自动加入。其他参与者请在此添加。', + }, attendee_users: { label: '内部参与者' }, notes: { label: '通话记录' }, }, @@ -41,7 +44,10 @@ export const activityActions = { params: { subject: { label: '会议主题' }, duration: { label: '时长(分钟)' }, - attendee_contacts: { label: '联系人参会人' }, + attendee_contacts: { + label: '联系人参会人', + helpText: '你正在记录的联系人或线索会自动加入。其他参与者请在此添加。', + }, attendee_users: { label: '内部参会人' }, notes: { label: '会议纪要' }, }, @@ -55,7 +61,10 @@ export const activityActions = { start_time: { label: '开始时间(UTC)' }, location: { label: '地点' }, duration: { label: '时长(分钟)' }, - attendee_contacts: { label: '联系人参会人' }, + attendee_contacts: { + label: '联系人参会人', + helpText: '你正在记录的联系人或线索会自动加入。其他参与者请在此添加。', + }, attendee_users: { label: '内部参会人' }, notes: { label: '会议议程' }, }, diff --git a/src/sales/translations/zh-CN/app.ts b/src/sales/translations/zh-CN/app.ts index 5efbe255d..dbbc7cdec 100644 --- a/src/sales/translations/zh-CN/app.ts +++ b/src/sales/translations/zh-CN/app.ts @@ -529,6 +529,7 @@ export const appSurface: Omit = { subtitle: '{crm_account}', components: { case_highlights: { label: '关键信息' }, + case_sla_breached: { label: '已违反 SLA' }, case_status_path: { label: '工单状态进度' }, case_details: { label: '工单明细' }, // `page:tabs` item labels, keyed by each item's `value` (spec 18.1.0, diff --git a/src/sales/translations/zh-CN/objects.customer.ts b/src/sales/translations/zh-CN/objects.customer.ts index 68c827c5a..0e8e6a701 100644 --- a/src/sales/translations/zh-CN/objects.customer.ts +++ b/src/sales/translations/zh-CN/objects.customer.ts @@ -213,6 +213,7 @@ export const customer: Record = { }, send_email: { label: '发送邮件', + description: '发送到该联系人的邮箱地址。邮件先进入发送队列,随后记录在联系人的时间线上。', params: { subject: { label: '主题' }, body: { label: '正文' }, diff --git a/src/sales/translations/zh-CN/objects.service.ts b/src/sales/translations/zh-CN/objects.service.ts index c0edc800f..4755bf8f5 100644 --- a/src/sales/translations/zh-CN/objects.service.ts +++ b/src/sales/translations/zh-CN/objects.service.ts @@ -222,10 +222,20 @@ export const service: Record = { mark_article_helpful: { label: '有用', successMessage: '已记录为「有用」,谢谢反馈。', + outcomeMessages: { + recorded: '已记录为「有用」,谢谢反馈。有用:${result.helpful} · 没用:${result.notHelpful}。', + changed: '你的投票已改为「有用」。有用:${result.helpful} · 没用:${result.notHelpful}。', + already: '你已标记过这篇文章「有用」。有用:${result.helpful} · 没用:${result.notHelpful}。', + }, }, mark_article_not_helpful: { label: '没用', successMessage: '已记录为「没用」,谢谢反馈。', + outcomeMessages: { + recorded: '已记录为「没用」,谢谢反馈。有用:${result.helpful} · 没用:${result.notHelpful}。', + changed: '你的投票已改为「没用」。有用:${result.helpful} · 没用:${result.notHelpful}。', + already: '你已标记过这篇文章「没用」。有用:${result.helpful} · 没用:${result.notHelpful}。', + }, }, }, }, diff --git a/src/service/actions/knowledge_article.actions.ts b/src/service/actions/knowledge_article.actions.ts index a09f78d1f..898454fda 100644 --- a/src/service/actions/knowledge_article.actions.ts +++ b/src/service/actions/knowledge_article.actions.ts @@ -53,23 +53,44 @@ const feedbackBody = (verdict: 'helpful' | 'not_helpful'): string => ` where: { crm_knowledge_article: id, owner_id: userId }, }); + // \`outcome\` keys the action's \`outcomeMessages\` (#2369 row 7): the + // reader learns whether this click recorded, moved or repeated their vote. + let outcome = 'recorded'; + let feedbackId = null; if (existing && existing.id) { + feedbackId = existing.id; if (existing.verdict === '${verdict}') { - return { ok: true, id: existing.id, verdict: '${verdict}', changed: false }; + outcome = 'already'; + } else { + await ctx.api.object('crm_article_feedback').update( + { id: existing.id, verdict: '${verdict}' }, + { where: { id: existing.id } }, + ); + outcome = 'changed'; } - await ctx.api.object('crm_article_feedback').update( - { id: existing.id, verdict: '${verdict}' }, - { where: { id: existing.id } }, - ); - return { ok: true, id: existing.id, verdict: '${verdict}', changed: true }; + } else { + const inserted = await ctx.api.object('crm_article_feedback').insert({ + crm_knowledge_article: id, + verdict: '${verdict}', + owner_id: userId, + }); + feedbackId = inserted?.id ?? null; } - const inserted = await ctx.api.object('crm_article_feedback').insert({ - crm_knowledge_article: id, - verdict: '${verdict}', - owner_id: userId, + // Where the article stands once this vote is in, for the same message: + // the header buttons can show neither a count nor the reader's own vote. + // Counted from the rows, as \`article_feedback_metrics_refresh\` counts. + // Double-quoted on purpose: the two bodies must differ only in the + // single-quoted verdict above (test/knowledge-feedback.test.ts). + const tally = (v) => ctx.api.object('crm_article_feedback').count({ + where: { crm_knowledge_article: id, verdict: v }, }); - return { ok: true, id: inserted?.id ?? null, verdict: '${verdict}', changed: true }; + const helpful = await tally("helpful"); + const notHelpful = await tally("not_helpful"); + return { + ok: true, id: feedbackId, verdict: '${verdict}', changed: outcome !== 'already', + outcome, helpful, notHelpful, + }; `; /** @@ -94,6 +115,13 @@ export const MarkArticleHelpfulAction: Action = { locations: ['record_header'], visible: publishedOnly, successMessage: 'Thanks — recorded as helpful.', + // By the body's `outcome` (#2369 row 7), with the article's tally. The + // console fills `${result.x}` here; a `{{ result.x }}` hole prints raw. + outcomeMessages: { + recorded: 'Thanks — recorded as helpful. Helpful: ${result.helpful} · Not helpful: ${result.notHelpful}.', + changed: 'Your vote is now Helpful. Helpful: ${result.helpful} · Not helpful: ${result.notHelpful}.', + already: 'You already marked this article helpful. Helpful: ${result.helpful} · Not helpful: ${result.notHelpful}.', + }, refreshAfter: true, }; @@ -112,5 +140,10 @@ export const MarkArticleNotHelpfulAction: Action = { locations: ['record_header'], visible: publishedOnly, successMessage: 'Thanks — recorded as not helpful.', + outcomeMessages: { + recorded: 'Thanks — recorded as not helpful. Helpful: ${result.helpful} · Not helpful: ${result.notHelpful}.', + changed: 'Your vote is now Not Helpful. Helpful: ${result.helpful} · Not helpful: ${result.notHelpful}.', + already: 'You already marked this article not helpful. Helpful: ${result.helpful} · Not helpful: ${result.notHelpful}.', + }, refreshAfter: true, }; diff --git a/src/service/docs/crm_service_cases.md b/src/service/docs/crm_service_cases.md index d4a8edd4b..b84833114 100644 --- a/src/service/docs/crm_service_cases.md +++ b/src/service/docs/crm_service_cases.md @@ -99,6 +99,9 @@ its **SLA Due Date** is marked **SLA Violated** on that save — it is not escalated and nobody is alerted, because the work is already done, but the SLA report and the compliance gauge count it. +A case marked **SLA Violated** shows a red **SLA breached** banner under its +header. A case on track shows none; the header strip keeps the **SLA Due Date**. + ## Critical cases escalate instantly (automatic) The moment a case is set to **Critical** priority, it escalates without waiting diff --git a/src/service/docs/crm_service_cases.zh.md b/src/service/docs/crm_service_cases.zh.md index 7f2838901..c3b5bf6d5 100644 --- a/src/service/docs/crm_service_cases.zh.md +++ b/src/service/docs/crm_service_cases.zh.md @@ -66,6 +66,8 @@ stateDiagram-v2 在任何安装上,无论有没有扫描,在 **SLA 到期**之后才被解决或关闭的工单,都会在那次保存时被标记为 **SLA 违反**——它不会被升级,也不会提醒任何人,因为工作已经完成,但 SLA 报表和合规仪表都会把它计入。 +被标记为 **SLA 违反**的工单,会在页头下方显示红色的**已违反 SLA**提示条。按时进行中的工单不显示;页头信息条保留 **SLA 到期**时间。 + ## 严重工单立即升级(自动) 工单一旦被设为**严重**优先级,就会立即升级,不等 SLA 时钟: diff --git a/src/service/docs/crm_service_knowledge.md b/src/service/docs/crm_service_knowledge.md index b107c22d5..09b57cc50 100644 --- a/src/service/docs/crm_service_knowledge.md +++ b/src/service/docs/crm_service_knowledge.md @@ -41,6 +41,11 @@ article's two vote counts are recounted from those rows — so they mean *how many people*, not how many clicks, and changing your mind moves your own verdict instead of adding another. +After each press a message says whether your vote was recorded, changed, or was +already that verdict, with the article's current **Helpful** and **Not +Helpful** counts. The same two counts sit in the article's header, next to its +category, status and audience; the article page opens on its body. + > *Public* audience is still a statement of intent rather than a publication > channel: no customer-facing surface serves articles today, and the guest > permission set cannot read the object at all, by design. diff --git a/src/service/docs/crm_service_knowledge.zh.md b/src/service/docs/crm_service_knowledge.zh.md index f41a4a2d8..1116c3788 100644 --- a/src/service/docs/crm_service_knowledge.zh.md +++ b/src/service/docs/crm_service_knowledge.zh.md @@ -19,6 +19,8 @@ description: 知识文章如何关联到它解决的工单、自助解决率如 读者可以在任何已发布的文章上点 **Helpful** / **Not Helpful** 来评价。每次点击为每位读者记录一行**文章反馈**,文章的两个计数(**有用**、**没用**)由这些行重新统计——所以它们表示*多少人*,而不是多少次点击;改变主意只会改你自己的评价,不会再加一票。 +每次点击后会有一条提示,说明你的评价是新记录的、已改过来的,还是本来就是这个评价,并附上文章当前的**有用**和**没用**计数。这两个计数也显示在文章页头,和分类、状态、受众放在一起;文章页先显示正文。 + > *公开*受众目前仍只是一种意图声明,而不是发布渠道:现在没有任何面向客户的界面提供文章,访客权限集按设计根本无法读取该对象。 --- diff --git a/src/service/pages/case_detail.page.ts b/src/service/pages/case_detail.page.ts index db54ba762..2449379b7 100644 --- a/src/service/pages/case_detail.page.ts +++ b/src/service/pages/case_detail.page.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { Page } from '@objectstack/spec/ui'; +import { P } from '@objectstack/spec'; /** * Case Detail Record Page @@ -85,16 +86,45 @@ export const CaseDetailPage: Page = { // `is_sla_violated` flag — the split response/resolution deadlines // named here do not exist, so the agent's most time-critical fact // was missing from the strip entirely. + // + // `is_sla_violated` left the strip (#2369 row 6). The console draws + // a boolean false as a red "× No" and true as a green "✓ Yes" + // whatever the field means, so every case on track wore a red pill + // and every breached one a green tick. The breach is the alert + // below instead, and the strip keeps the due date. fields: [ 'status', 'priority', 'sla_due_date', - 'is_sla_violated', 'owner_id', 'crm_account', ], }, }, + { + // Shown only on a breached case (#2369 row 6), so a case on track + // shows nothing red. `has()` first: this renderer fails soft to + // SHOWN on an unevaluable predicate (see lead_detail.page.ts). + type: 'record:alert', + id: 'case_sla_breached', + label: 'SLA Breached', + properties: { + severity: 'error', + visible: P`has(record.is_sla_violated) && record.is_sla_violated == true`, + title: { + en: 'SLA breached', + 'zh-CN': '已违反 SLA', + 'ja-JP': 'SLA違反', + 'es-ES': 'SLA incumplido', + }, + body: { + en: 'This case was not resolved by its SLA due date.', + 'zh-CN': '该工单未在 SLA 截止时间前解决。', + 'ja-JP': 'このケースは SLA 期限までに解決されませんでした。', + 'es-ES': 'Este caso no se resolvió antes de su fecha límite de SLA.', + }, + }, + }, { type: 'record:path', id: 'case_status_path', @@ -160,8 +190,8 @@ export const CaseDetailPage: Page = { // // The highlights strip still wins (#1211): the renderer // drops what `record:highlights` registered (`status`, - // `priority`, `sla_due_date`, `is_sla_violated`, - // `owner_id`, `crm_account`) and the title candidate + // `priority`, `sla_due_date`, `owner_id`, + // `crm_account`) and the title candidate // `subject` from the derived members. // // `escalated_date` — written by three flows and shown diff --git a/src/service/views/case.view.ts b/src/service/views/case.view.ts index 1d29197c8..7af87f8d9 100644 --- a/src/service/views/case.view.ts +++ b/src/service/views/case.view.ts @@ -402,7 +402,7 @@ export const CaseViews = defineView({ * detail-page highlights * first_response_date `event.hook.ts` is its SINGLE writer * resolution_time_hours readonly; derived at close; detail page - * is_sla_violated list column + detail-page highlights + * is_sla_violated list column + detail-page SLA alert * is_escalated, escalation_reason written by `case_escalation` / * `case_sla_monitor` / `escalate_case`; * detail page's Status & SLA section @@ -487,6 +487,10 @@ export const CaseViews = defineView({ visibleWhen: P`has(record.status) && (record.status == "resolved" || record.status == "closed")`, }, 'priority', + // Beside Priority (#2369 row 6): the public support form asks a + // guest for the case type, and staff cases had no field for it, so + // they saved `type: null`, blank in the case dataset's Type dimension. + 'type', 'origin', 'owner_id', // Required on the object with no default — a form without it could diff --git a/test/case-create-form-narrowing.test.ts b/test/case-create-form-narrowing.test.ts index e04a6399b..30e00d60a 100644 --- a/test/case-create-form-narrowing.test.ts +++ b/test/case-create-form-narrowing.test.ts @@ -100,6 +100,10 @@ const CREATOR_AUTHORABLE = new Set([ 'supplied_email', 'status', 'priority', + // What kind of request it is (#2369 row 6): the public support form asks a + // guest for it, and a staff-created case saved `type: null` with no field + // to set it. + 'type', 'origin', 'owner_id', 'description', @@ -147,7 +151,10 @@ const LIFECYCLE_MAINTAINED: Record = { }, is_sla_violated: { why: 'derived from the SLA sweep, never authored', - keeps: ['list.columns', 'detail.highlights'], + // Off the highlights strip since #2369 row 6 (a boolean there is drawn as + // a red "No" on every case on track): the breach is the record alert, and + // the flag stays in the `sla` group on the Details tab. + keeps: ['list.columns', 'detail.alert', 'detail.details'], }, is_escalated: { // readonly since #1434 — stamped by `case_escalation`, `case_sla_monitor` @@ -188,6 +195,7 @@ const surfaces = (): Record> => { const detailFields = new Set(); const highlightFields = new Set(); + const alertFields = new Set(); const walk = (node: any, into: 'highlights' | 'details' | null): void => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { @@ -195,6 +203,12 @@ const surfaces = (): Record> => { return; } let next = into; + // A `record:alert` reads its fields through its `visible` predicate. + if (node.type === 'record:alert') { + for (const [, name] of JSON.stringify(node.properties?.visible ?? '').matchAll(/record\.(\w+)/g)) { + alertFields.add(name); + } + } if (node.type === 'record:highlights') next = 'highlights'; else if (node.type === 'record:details') next = 'details'; if (next && Array.isArray(node.fields)) { @@ -225,6 +239,7 @@ const surfaces = (): Record> => { 'my_open_cases.filter': names(lv.my_open_cases?.filter, (f: any) => f?.field), 'escalated_cases.filter': names(lv.escalated_cases?.filter, (f: any) => f?.field), 'detail.highlights': highlightFields, + 'detail.alert': alertFields, 'detail.details': detailFields, }; }; diff --git a/test/forms-record-pages-2369.test.ts b/test/forms-record-pages-2369.test.ts new file mode 100644 index 000000000..7310382be --- /dev/null +++ b/test/forms-record-pages-2369.test.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { ContactViews } from '../src/sales/views/contact.view'; +import { LeadViews } from '../src/sales/views/lead.view'; +import { Lead } from '../src/sales/objects/lead.object'; +import { Account } from '../src/sales/objects/account.object'; +import { Quote } from '../src/revenue/objects/quote.object'; +import { Product } from '../src/revenue/objects/product.object'; +import { Campaign } from '../src/marketing/objects/campaign.object'; +import { KnowledgeArticle } from '../src/service/objects/knowledge_article.object'; +import { OpportunityDetailPage } from '../src/sales/pages/opportunity_detail.page'; +import { CaseDetailPage } from '../src/service/pages/case_detail.page'; +import { SendEmailAction } from '../src/sales/actions/contact.actions'; +import { + ContactLogCallAction, + OpportunityLogMeetingAction, + AccountScheduleMeetingAction, +} from '../src/sales/actions/activity-actions'; + +type AnyRec = Record; + +/** + * UX card 5 (#2369): forms, record pages and the record header. Each block + * below pins one row's metadata, measured in the console on 18.2.0 before it + * was written; the before / after screenshots are on the PR. + */ + +const fieldName = (f: unknown): string => (typeof f === 'string' ? f : (f as AnyRec)?.field); +const section = (view: AnyRec, name: string): AnyRec => + (view.form?.sections ?? []).find((s: AnyRec) => s.name === name); + +/** Every node of a page, depth first. */ +const nodes = (node: unknown, out: AnyRec[] = []): AnyRec[] => { + if (Array.isArray(node)) node.forEach((n) => nodes(n, out)); + else if (node && typeof node === 'object') { + out.push(node as AnyRec); + Object.values(node).forEach((v) => nodes(v, out)); + } + return out; +}; +const byId = (page: unknown, id: string): AnyRec | undefined => nodes(page).find((n) => n.id === id); + +describe('row 1: a new contact’s required and reachable fields share the first tab', () => { + it('puts email and phone in the identity section, beside the required names', () => { + // On the old layout Email sat on the second tab, so its format error + // appeared on a tab the user was not looking at. + const identity = section(ContactViews as AnyRec, 'identity'); + const fields = (identity.fields as unknown[]).map(fieldName); + expect(fields).toEqual(expect.arrayContaining(['first_name', 'last_name', 'crm_account', 'email', 'phone'])); + expect(identity.columns).toBe(3); + }); +}); + +describe('row 2: the lead form', () => { + it('lays salutation, first and last name out on one row', () => { + const info = section(LeadViews as AnyRec, 'contact_information'); + expect(info.columns).toBe(3); + const fields = info.fields as AnyRec[]; + expect(fields.slice(0, 3).map(fieldName)).toEqual(['salutation', 'first_name', 'last_name']); + // `defineView` fills `span: 'auto'`; a `'full'` last name took a row alone. + expect(fields.slice(0, 3).map((f) => f.span)).toEqual(['auto', 'auto', 'auto']); + }); + + it('says a blank rating is scored for you', () => { + expect(String((Lead.fields as AnyRec).rating.description)).toMatch(/Leave blank/); + }); +}); + +describe('row 3: the Send Email dialog says where the email goes', () => { + it('carries a description and no unfilled template hole', () => { + // 18.2.0 opens the dialog without the row, so `{{ record.x }}` would print raw. + expect(SendEmailAction.description).toMatch(/this contact's email address/); + expect(SendEmailAction.description).not.toMatch(/\{\{/); + }); +}); + +describe('rows 4 and 5: the record header and pickers lead with what people scan for', () => { + it.each([ + ['crm_account', Account, ['account_number', 'tier', 'health_score', 'annual_revenue', 'owner_id']], + ['crm_quote', Quote, ['crm_account', 'crm_opportunity', 'status', 'total_price', 'expiration_date']], + // The product picker's subtitle is [name, ...highlightFields]: List Price + // shows while picking a product for a quote line (row 4). + ['crm_product', Product, ['category', 'list_price', 'is_active']], + ['crm_campaign', Campaign, ['type', 'status', 'start_date', 'budgeted_cost']], + ['crm_knowledge_article', KnowledgeArticle, ['category', 'status', 'audience', 'helpful_count', 'not_helpful_count']], + ])('%s highlights', (_name, object, expected) => { + expect((object as AnyRec).highlightFields).toEqual(expected); + }); + + it('keeps related-list columns that the highlights used to supply', () => { + // Without `relatedListColumns` a related list shows the child's + // highlightFields, so each lookup the new highlights changed pins its own. + const f = (o: AnyRec, name: string) => (o.fields as AnyRec)[name].relatedListColumns; + expect(f(Account, 'parent_account')).toEqual(['account_number', 'name', 'type', 'owner_id']); + expect(f(Quote, 'crm_account')).toEqual(['quote_number', 'name', 'status', 'total_price']); + expect(f(Quote, 'crm_contact')).toEqual(['quote_number', 'name', 'crm_account', 'status', 'total_price']); + expect(f(Quote, 'crm_opportunity')).toEqual(['quote_number', 'name', 'crm_account', 'status', 'total_price']); + expect(f(KnowledgeArticle, 'related_to_case')).toEqual(['article_number', 'title', 'category', 'status', 'audience']); + }); +}); + +describe('row 6: a case shows a breached SLA, and nothing red when on track', () => { + it('leaves the flag out of the highlights strip', () => { + expect(byId(CaseDetailPage, 'case_highlights')?.properties.fields).not.toContain('is_sla_violated'); + }); + + it('shows an error alert only when the flag is true', () => { + const alert = byId(CaseDetailPage, 'case_sla_breached'); + expect(alert?.type).toBe('record:alert'); + expect(alert?.properties.severity).toBe('error'); + const visible = JSON.stringify(alert?.properties.visible); + expect(visible).toContain('has(record.is_sla_violated)'); + expect(visible).toContain('record.is_sla_violated == true'); + for (const key of ['title', 'body']) { + expect(Object.keys(alert?.properties[key]).sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']); + } + }); +}); + +describe('row 7: an article opens on its body', () => { + it('orders the content group first', () => { + expect((KnowledgeArticle as AnyRec).fieldGroups[0].key).toBe('content'); + }); +}); + +describe('row 8: the attendee picker says the record is added for you', () => { + it.each([ContactLogCallAction, OpportunityLogMeetingAction, AccountScheduleMeetingAction])( + '$name', + (action) => { + const param = (action.params as AnyRec[]).find((p) => p.name === 'attendee_contacts'); + expect(param?.helpText).toMatch(/added automatically/); + }, + ); +}); + +describe('row 9: the Related tabs open the panels people work from', () => { + const opened = (id: string, page: unknown) => { + const acc = byId(page, id); + const items = acc?.properties.items as AnyRec[]; + return { multi: acc?.properties.allowMultiple, open: items.filter((i) => i.collapsed === false).map((i) => i.label) }; + }; + + it('opens Quotes, Products and Open Tasks on an opportunity', () => { + // Without `allowMultiple` only the first `collapsed: false` item opens. + expect(opened('opp_related_accordion', OpportunityDetailPage)).toEqual({ + multi: true, + open: ['Quotes', 'Products', 'Open Tasks'], + }); + }); + + it('opens Open Tasks on a case', () => { + // Its first item, so one open panel needs no `allowMultiple`. + expect(opened('case_related_accordion', CaseDetailPage).open[0]).toBe('Open Tasks'); + }); +}); diff --git a/test/knowledge-feedback.test.ts b/test/knowledge-feedback.test.ts index 8ab91008f..736fba0ca 100644 --- a/test/knowledge-feedback.test.ts +++ b/test/knowledge-feedback.test.ts @@ -171,6 +171,45 @@ describe('the feedback actions record a row (they cannot bump a counter)', () => expect(await feedbackOn(ka)).toHaveLength(1); }); + /** + * The toast is the only place a reader learns what their click did (#2369 + * row 7): the header buttons show neither a count nor the reader's own vote. + * The console picks `outcomeMessages[result.outcome]` and fills + * `{{ result.helpful }}` / `{{ result.notHelpful }}`, so the body must return + * all three, and each outcome it returns must have a message. + */ + it('returns the outcome and the article’s tally for the toast', async () => { + const ka = await publishedArticle(); + const other = await signUpPerson(verify, `other${n}@knowledge-feedback.test`, { + name: 'Otto Other', positions: ['service_agent'], permissionSets: ['service_agent'], + }); + await verify.actions.run('crm_knowledge_article', 'mark_article_helpful', { as: other.token, recordId: ka }); + + // `actions.run` resolves with the body's own return value. + const results = [ + await vote('mark_article_helpful', ka), + await vote('mark_article_helpful', ka), + await vote('mark_article_not_helpful', ka), + ] as Rec[]; + + expect(results.map((r) => [r.outcome, r.helpful, r.notHelpful])).toEqual([ + ['recorded', 2, 0], + ['already', 2, 0], + ['changed', 1, 1], + ]); + for (const action of [MarkArticleHelpfulAction, MarkArticleNotHelpfulAction]) { + expect(Object.keys(action.outcomeMessages ?? {}).sort()).toEqual(['already', 'changed', 'recorded']); + // The console interpolates `${result.x}` (measured: `{{ result.x }}` + // printed raw), and each hole must name a key the body returns. + for (const message of Object.values(action.outcomeMessages ?? {})) { + expect(String(message)).not.toMatch(/\{\{/); + const holes = [...String(message).matchAll(/\$\{result\.(\w+)\}/g)].map(([, k]) => k); + expect(holes.length).toBeGreaterThan(0); + for (const k of holes) expect(Object.keys(results[0]!)).toContain(k); + } + } + }); + it('refuses an unauthenticated vote rather than writing an ownerless row', async () => { // The body refuses a caller with no user ("Sign in to rate an article"). // Through the real door an anonymous vote is refused one step earlier — the