Skip to content

Commit 98d8cd7

Browse files
committed
Enhance CRM app with new objects, fields, views, and formula patterns
1 parent d402af3 commit 98d8cd7

37 files changed

Lines changed: 678 additions & 60 deletions

examples/app-crm/src/actions/case.actions.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type { Action } from '@objectstack/spec/ui';
4+
import { P } from '@objectstack/spec';
45

56
/**
67
* Escalate Case.
@@ -35,7 +36,7 @@ export const EscalateCaseAction: Action = {
3536
timeoutMs: 5000,
3637
},
3738
locations: ['record_header', 'list_item'],
38-
visible: 'is_escalated == false && is_closed == false',
39+
visible: P`record.is_escalated == false && record.is_closed == false`,
3940
params: [
4041
{
4142
name: 'reason',
@@ -81,7 +82,7 @@ export const CloseCaseAction: Action = {
8182
timeoutMs: 5000,
8283
},
8384
locations: ['record_header'],
84-
visible: 'is_closed == false',
85+
visible: P`record.is_closed == false`,
8586
params: [
8687
{
8788
name: 'resolution',

examples/app-crm/src/actions/contact.actions.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type { Action } from '@objectstack/spec/ui';
4+
import { P } from '@objectstack/spec';
45

56
/**
67
* Mark Contact as Primary.
@@ -28,7 +29,7 @@ export const MarkPrimaryContactAction: Action = {
2829
timeoutMs: 2000,
2930
},
3031
locations: ['record_header', 'list_item'],
31-
visible: 'is_primary == false',
32+
visible: P`record.is_primary == false`,
3233
confirmText: 'Mark this contact as the primary contact for the account?',
3334
successMessage: 'Contact marked as primary!',
3435
refreshAfter: true,
@@ -69,7 +70,7 @@ export const SendEmailAction: Action = {
6970
timeoutMs: 5000,
7071
},
7172
locations: ['record_header', 'list_item'],
72-
visible: 'email_opt_out == false',
73+
visible: P`record.email_opt_out == false`,
7374
params: [
7475
{ name: 'subject', label: 'Subject', type: 'text', required: true },
7576
{ name: 'body', label: 'Body', type: 'textarea', required: true },

examples/app-crm/src/actions/lead.actions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type { Action } from '@objectstack/spec/ui';
4+
import { P } from '@objectstack/spec';
45

56
/**
67
* Convert Lead to Account, Contact, and Opportunity.
@@ -17,7 +18,7 @@ export const ConvertLeadAction: Action = {
1718
type: 'flow',
1819
target: 'lead_conversion',
1920
locations: ['record_header', 'list_item'],
20-
visible: 'status == "qualified" && is_converted == false',
21+
visible: P`record.status == "qualified" && record.is_converted == false`,
2122
confirmText: 'Are you sure you want to convert this lead?',
2223
successMessage: 'Lead converted successfully!',
2324
refreshAfter: true,

examples/app-crm/src/apps/crm.app.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ export const CrmApp = App.create({
99
defaultAgent: 'sales_copilot',
1010
branding: {
1111
primaryColor: '#4169E1',
12-
secondaryColor: '#00AA00',
1312
logo: '/assets/crm-logo.png',
1413
favicon: '/assets/crm-favicon.ico',
1514
},

examples/app-crm/src/dashboards/sales.dashboard.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,8 @@ export const SalesDashboard: Dashboard = {
265265
filter: { stage: { $nin: ['closed_won', 'closed_lost'] } },
266266
colorVariant: 'default',
267267
layout: { x: 0, y: 14, w: 12, h: 4 },
268+
valueField: 'amount',
269+
aggregate: 'sum',
268270
options: {
269271
rowField: 'stage',
270272
columnField: 'lead_source',

examples/app-crm/src/data/index.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ const contacts = defineDataset(Contact, {
8484
email: 'john.smith@acme.example.com',
8585
phone: '+1-415-555-0101',
8686
title: 'VP of Engineering',
87-
department: 'Engineering',
87+
department: 'engineering',
8888
account: 'Acme Corporation',
8989
},
9090
{
@@ -94,7 +94,7 @@ const contacts = defineDataset(Contact, {
9494
email: 'sarah.j@globex.example.com',
9595
phone: '+1-312-555-0201',
9696
title: 'Chief Procurement Officer',
97-
department: 'Executive',
97+
department: 'executive',
9898
account: 'Globex Industries',
9999
},
100100
{
@@ -104,7 +104,7 @@ const contacts = defineDataset(Contact, {
104104
email: 'mchen@initech.example.com',
105105
phone: '+1-212-555-0301',
106106
title: 'Director of Operations',
107-
department: 'Operations',
107+
department: 'operations',
108108
account: 'Initech Solutions',
109109
},
110110
{
@@ -114,7 +114,7 @@ const contacts = defineDataset(Contact, {
114114
email: 'emily.d@starkmed.example.com',
115115
phone: '+1-617-555-0401',
116116
title: 'Head of Partnerships',
117-
department: 'Sales',
117+
department: 'sales',
118118
account: 'Stark Medical',
119119
},
120120
{
@@ -124,7 +124,7 @@ const contacts = defineDataset(Contact, {
124124
email: 'rwilson@wayne.example.com',
125125
phone: '+1-650-555-0501',
126126
title: 'CTO',
127-
department: 'Engineering',
127+
department: 'engineering',
128128
account: 'Wayne Enterprises',
129129
},
130130
]
@@ -180,7 +180,7 @@ const opportunities = defineDataset(Opportunity, {
180180
stage: 'proposal',
181181
probability: 60,
182182
close_date: cel`daysFromNow(30)`,
183-
type: 'existing_business',
183+
type: 'existing_upgrade',
184184
forecast_category: 'pipeline',
185185
lead_source: 'web',
186186
},
@@ -213,7 +213,7 @@ const opportunities = defineDataset(Opportunity, {
213213
stage: 'needs_analysis',
214214
probability: 25,
215215
close_date: cel`daysFromNow(45)`,
216-
type: 'existing_business',
216+
type: 'existing_upgrade',
217217
forecast_category: 'best_case',
218218
lead_source: 'event',
219219
},

examples/app-crm/src/flows/case-escalation.flow.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const CaseEscalationFlow: Flow = {
2727
id: 'assign_senior_agent', type: 'update_record', label: 'Assign to Senior Agent',
2828
config: {
2929
objectName: 'case', filter: { id: '{caseId}' },
30-
fields: { owner: '{caseRecord.owner.manager}', is_escalated: true, escalated_date: '{NOW()}' },
30+
fields: { owner: '{caseRecord.owner.manager}', is_escalated: true, escalated_date: '{NOW()}', status: 'escalated' },
3131
},
3232
},
3333
{
@@ -36,7 +36,9 @@ export const CaseEscalationFlow: Flow = {
3636
objectName: 'task',
3737
fields: {
3838
subject: 'Follow up on escalated case: {caseRecord.case_number}',
39-
related_to: '{caseId}', owner: '{caseRecord.owner}',
39+
related_to_type: 'case',
40+
related_to_case: '{caseId}',
41+
owner: '{caseRecord.owner}',
4042
priority: 'high', status: 'not_started', due_date: '{TODAY() + 1}',
4143
},
4244
},

examples/app-crm/src/objects/account.object.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const Account = ObjectSchema.create({
2727
// AutoNumber field - Unique account identifier
2828
account_number: Field.autonumber({
2929
label: 'Account Number',
30-
format: 'ACC-{0000}',
30+
format: 'ACC-{000000}',
3131
group: 'basic',
3232
}),
3333

@@ -144,6 +144,51 @@ export const Account = ObjectSchema.create({
144144
readonly: true,
145145
group: 'system',
146146
}),
147+
148+
// ─── Customer Success / Account Health ────────────────────────────
149+
tier: Field.select({
150+
label: 'Customer Tier',
151+
group: 'ownership',
152+
options: [
153+
{ label: 'Strategic', value: 'strategic', color: '#7C3AED' },
154+
{ label: 'Enterprise', value: 'enterprise', color: '#4169E1' },
155+
{ label: 'Mid-Market', value: 'mid_market', color: '#00AA00' },
156+
{ label: 'SMB', value: 'smb', color: '#FFA500', default: true },
157+
],
158+
}),
159+
160+
segment: Field.select({
161+
label: 'Segment',
162+
group: 'ownership',
163+
options: [
164+
{ label: 'Net New', value: 'net_new' },
165+
{ label: 'Growth', value: 'growth' },
166+
{ label: 'At Risk', value: 'at_risk' },
167+
{ label: 'Stable', value: 'stable' },
168+
],
169+
}),
170+
171+
health_score: Field.select({
172+
label: 'Health Score',
173+
group: 'ownership',
174+
description: 'CSM-maintained health indicator',
175+
options: [
176+
{ label: 'Healthy', value: 'healthy', color: '#00AA00' },
177+
{ label: 'Watching', value: 'watching', color: '#FFA500' },
178+
{ label: 'At Risk', value: 'at_risk', color: '#FF4500' },
179+
{ label: 'Churning', value: 'churning', color: '#FF0000' },
180+
],
181+
}),
182+
183+
renewal_owner: Field.lookup('user', {
184+
label: 'Renewal Owner (CSM)',
185+
group: 'ownership',
186+
}),
187+
188+
next_renewal_date: Field.date({
189+
label: 'Next Renewal Date',
190+
group: 'ownership',
191+
}),
147192
},
148193

149194
// Database indexes for performance

examples/app-crm/src/objects/campaign.object.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,8 @@ export const Campaign = ObjectSchema.create({
232232
{
233233
name: 'campaign_completion_check',
234234
objectName: 'campaign',
235-
triggerType: 'on_read',
235+
triggerType: 'scheduled',
236+
schedule: '0 2 * * *',
236237
criteria: P`record.end_date < today() && record.status == "in_progress"`,
237238
actions: [
238239
{
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* Campaign Member Object
7+
*
8+
* Links a Lead OR Contact to a Campaign and tracks the response
9+
* lifecycle (Sent → Opened → Clicked → Responded → Converted).
10+
* Used by the campaign-enrollment flow and by ROI dashboards.
11+
*/
12+
export const CampaignMember = ObjectSchema.create({
13+
name: 'campaign_member',
14+
label: 'Campaign Member',
15+
pluralLabel: 'Campaign Members',
16+
icon: 'user-plus',
17+
description: 'Membership and response tracking for marketing campaigns',
18+
19+
trackHistory: true,
20+
shareModel: 'controlled-by-parent',
21+
22+
compactLayout: ['campaign', 'lead', 'contact', 'status', 'response_date'],
23+
24+
fieldGroups: [
25+
{ key: 'basic', label: 'Basic Information', icon: 'info' },
26+
{ key: 'response', label: 'Response Tracking', icon: 'activity' },
27+
],
28+
29+
fields: {
30+
campaign: Field.lookup('campaign', {
31+
label: 'Campaign',
32+
required: true,
33+
group: 'basic',
34+
}),
35+
36+
lead: Field.lookup('lead', {
37+
label: 'Lead',
38+
group: 'basic',
39+
description: 'Set when the member was a Lead at enrollment time',
40+
}),
41+
42+
contact: Field.lookup('contact', {
43+
label: 'Contact',
44+
group: 'basic',
45+
description: 'Set when the member is an existing Contact',
46+
}),
47+
48+
status: Field.select({
49+
label: 'Status',
50+
required: true,
51+
group: 'response',
52+
options: [
53+
{ label: 'Sent', value: 'sent', default: true, color: '#A0A0A0' },
54+
{ label: 'Opened', value: 'opened', color: '#4169E1' },
55+
{ label: 'Clicked', value: 'clicked', color: '#00AA00' },
56+
{ label: 'Responded', value: 'responded', color: '#00AA00' },
57+
{ label: 'Converted', value: 'converted', color: '#7C3AED' },
58+
{ label: 'Bounced', value: 'bounced', color: '#FF4500' },
59+
{ label: 'Unsubscribed', value: 'unsubscribed', color: '#FF0000' },
60+
],
61+
}),
62+
63+
added_date: Field.datetime({
64+
label: 'Added Date',
65+
readonly: true,
66+
group: 'response',
67+
}),
68+
69+
first_opened_date: Field.datetime({
70+
label: 'First Opened',
71+
group: 'response',
72+
}),
73+
74+
first_clicked_date: Field.datetime({
75+
label: 'First Clicked',
76+
group: 'response',
77+
}),
78+
79+
response_date: Field.datetime({
80+
label: 'Response Date',
81+
group: 'response',
82+
}),
83+
84+
has_responded: Field.boolean({
85+
label: 'Has Responded',
86+
defaultValue: false,
87+
group: 'response',
88+
}),
89+
},
90+
91+
validations: [
92+
{
93+
name: 'lead_or_contact_required',
94+
type: 'script',
95+
severity: 'error',
96+
message: 'A campaign member must reference either a Lead or a Contact',
97+
condition: { dialect: 'cel', source: 'isBlank(record.lead) && isBlank(record.contact)' },
98+
},
99+
],
100+
});

0 commit comments

Comments
 (0)