realtimex-crm 0.12.2 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "realtimex-crm",
3
- "version": "0.12.2",
3
+ "version": "0.13.1",
4
4
  "description": "RealTimeX CRM - A full-featured CRM built with React, shadcn-admin-kit, and Supabase. Fork of Atomic CRM with RealTimeX App SDK integration.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -79,6 +79,7 @@
79
79
  "@radix-ui/react-alert-dialog": "^1.1.15",
80
80
  "@radix-ui/react-avatar": "^1.1.11",
81
81
  "@radix-ui/react-checkbox": "^1.3.3",
82
+ "@radix-ui/react-collapsible": "^1.1.12",
82
83
  "@radix-ui/react-dialog": "^1.1.15",
83
84
  "@radix-ui/react-dropdown-menu": "^2.1.16",
84
85
  "@radix-ui/react-label": "^2.1.8",
package/src/App.tsx CHANGED
@@ -9,6 +9,12 @@ import { isSupabaseConfigured } from "@/lib/supabase-config";
9
9
  * Customize Atomic CRM by passing props to the CRM component:
10
10
  * - contactGender
11
11
  * - companySectors
12
+ * - companyLifecycleStages (NEW)
13
+ * - companyTypes (NEW)
14
+ * - companyQualificationStatuses (NEW)
15
+ * - companyRevenueRanges (NEW)
16
+ * - externalHeartbeatStatuses (NEW)
17
+ * - internalHeartbeatStatuses (NEW)
12
18
  * - darkTheme
13
19
  * - dealCategories
14
20
  * - dealPipelineStatuses
@@ -28,6 +34,62 @@ import { isSupabaseConfigured } from "@/lib/supabase-config";
28
34
  * />
29
35
  * );
30
36
  */
37
+
38
+ // Company lifecycle stages configuration
39
+ const companyLifecycleStages = [
40
+ { id: 'prospect', name: 'Prospect' },
41
+ { id: 'customer', name: 'Customer' },
42
+ { id: 'churned', name: 'Churned' },
43
+ { id: 'lost', name: 'Lost' },
44
+ { id: 'archived', name: 'Archived' },
45
+ ];
46
+
47
+ // Company type configuration
48
+ const companyTypes = [
49
+ { id: 'customer', name: 'Customer' },
50
+ { id: 'prospect', name: 'Prospect' },
51
+ { id: 'partner', name: 'Partner' },
52
+ { id: 'vendor', name: 'Vendor' },
53
+ { id: 'competitor', name: 'Competitor' },
54
+ { id: 'internal', name: 'Internal' },
55
+ ];
56
+
57
+ // Company qualification status configuration
58
+ const companyQualificationStatuses = [
59
+ { id: 'qualified', name: 'Qualified' },
60
+ { id: 'unqualified', name: 'Unqualified' },
61
+ { id: 'duplicate', name: 'Duplicate' },
62
+ { id: 'spam', name: 'Spam' },
63
+ { id: 'test', name: 'Test' },
64
+ ];
65
+
66
+ // Company revenue ranges configuration
67
+ const companyRevenueRanges = [
68
+ { id: '0-1M', name: 'Under $1M' },
69
+ { id: '1M-10M', name: '$1M - $10M' },
70
+ { id: '10M-50M', name: '$10M - $50M' },
71
+ { id: '50M-100M', name: '$50M - $100M' },
72
+ { id: '100M+', name: 'Over $100M' },
73
+ { id: 'unknown', name: 'Unknown' },
74
+ ];
75
+
76
+ // External heartbeat status configuration
77
+ const externalHeartbeatStatuses = [
78
+ { id: 'healthy', name: 'Healthy', color: '#10b981' },
79
+ { id: 'risky', name: 'Risky', color: '#f59e0b' },
80
+ { id: 'dead', name: 'Dead', color: '#ef4444' },
81
+ { id: 'unknown', name: 'Not Checked', color: '#6b7280' },
82
+ ];
83
+
84
+ // Internal heartbeat status configuration
85
+ const internalHeartbeatStatuses = [
86
+ { id: 'engaged', name: 'Engaged', color: '#10b981' },
87
+ { id: 'quiet', name: 'Quiet', color: '#3b82f6' },
88
+ { id: 'at_risk', name: 'At Risk', color: '#f59e0b' },
89
+ { id: 'unresponsive', name: 'Unresponsive', color: '#ef4444' },
90
+ { id: 'unknown', name: 'Unknown', color: '#6b7280' },
91
+ ];
92
+
31
93
  const App = () => {
32
94
  const [needsSetup, setNeedsSetup] = useState<boolean>(() => {
33
95
  // Check immediately on mount
@@ -51,7 +113,16 @@ const App = () => {
51
113
  );
52
114
  }
53
115
 
54
- return <CRM />;
116
+ return (
117
+ <CRM
118
+ companyLifecycleStages={companyLifecycleStages}
119
+ companyTypes={companyTypes}
120
+ companyQualificationStatuses={companyQualificationStatuses}
121
+ companyRevenueRanges={companyRevenueRanges}
122
+ externalHeartbeatStatuses={externalHeartbeatStatuses}
123
+ internalHeartbeatStatuses={internalHeartbeatStatuses}
124
+ />
125
+ );
55
126
  };
56
127
 
57
128
  export default App;
@@ -6,6 +6,9 @@ import { ArrayInput } from "@/components/admin/array-input";
6
6
  import { SimpleFormIterator } from "@/components/admin/simple-form-iterator";
7
7
  import { Separator } from "@/components/ui/separator";
8
8
  import { useIsMobile } from "@/hooks/use-mobile";
9
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
10
+ import { ChevronDown } from "lucide-react";
11
+ import { useState } from "react";
9
12
 
10
13
  import ImageEditorField from "../misc/ImageEditorField";
11
14
  import { isLinkedinUrl } from "../misc/isLinkedInUrl";
@@ -41,6 +44,8 @@ export const CompanyInputs = () => {
41
44
  <CompanyAccountManagerInput />
42
45
  </div>
43
46
  </div>
47
+ <Separator className="my-4" />
48
+ <CompanyAdvancedSettings />
44
49
  </div>
45
50
  );
46
51
  };
@@ -72,22 +77,76 @@ const CompanyContactInputs = () => {
72
77
  return (
73
78
  <div className="flex flex-col gap-4">
74
79
  <h6 className="text-lg font-semibold">Contact</h6>
80
+ <TextInput source="email" helperText={false} type="email" />
75
81
  <TextInput source="website" helperText={false} validate={isUrl} />
76
- <TextInput
77
- source="linkedin_url"
78
- helperText={false}
79
- validate={isLinkedinUrl}
80
- />
81
82
  <TextInput source="phone_number" helperText={false} />
83
+
84
+ <div className="mt-2">
85
+ <p className="text-sm font-medium mb-2">Social Profiles</p>
86
+ <div className="flex flex-col gap-3 ml-2">
87
+ <TextInput
88
+ source="linkedin_url"
89
+ label="LinkedIn"
90
+ helperText={false}
91
+ validate={isLinkedinUrl}
92
+ />
93
+ <TextInput
94
+ source="social_profiles.x"
95
+ label="Twitter/X"
96
+ helperText={false}
97
+ validate={isUrl}
98
+ />
99
+ <TextInput
100
+ source="social_profiles.facebook"
101
+ label="Facebook"
102
+ helperText={false}
103
+ validate={isUrl}
104
+ />
105
+ <TextInput
106
+ source="social_profiles.github"
107
+ label="GitHub"
108
+ helperText={false}
109
+ validate={isUrl}
110
+ />
111
+ </div>
112
+ </div>
113
+
114
+ <TextInput source="logo_url" label="Logo URL" helperText={false} validate={isUrl} />
82
115
  </div>
83
116
  );
84
117
  };
85
118
 
86
119
  const CompanyContextInputs = () => {
87
- const { companySectors } = useConfigurationContext();
120
+ const {
121
+ companySectors,
122
+ companyLifecycleStages,
123
+ companyTypes,
124
+ companyRevenueRanges,
125
+ } = useConfigurationContext();
126
+
88
127
  return (
89
128
  <div className="flex flex-col gap-4">
90
129
  <h6 className="text-lg font-semibold">Context</h6>
130
+
131
+ {/* Classification */}
132
+ {companyLifecycleStages && (
133
+ <SelectInput
134
+ source="lifecycle_stage"
135
+ label="Lifecycle Stage"
136
+ choices={companyLifecycleStages}
137
+ helperText={false}
138
+ />
139
+ )}
140
+ {companyTypes && (
141
+ <SelectInput
142
+ source="company_type"
143
+ label="Company Type"
144
+ choices={companyTypes}
145
+ helperText={false}
146
+ />
147
+ )}
148
+
149
+ {/* Industry & Sector */}
91
150
  <SelectInput
92
151
  source="sector"
93
152
  choices={companySectors.map((sector) => ({
@@ -96,8 +155,23 @@ const CompanyContextInputs = () => {
96
155
  }))}
97
156
  helperText={false}
98
157
  />
158
+ <TextInput source="industry" helperText={false} />
159
+
160
+ {/* Size & Revenue */}
99
161
  <SelectInput source="size" choices={sizes} helperText={false} />
162
+ <TextInput source="employee_count" label="Employee Count" helperText={false} type="number" />
100
163
  <TextInput source="revenue" helperText={false} />
164
+ {companyRevenueRanges && (
165
+ <SelectInput
166
+ source="revenue_range"
167
+ label="Revenue Range"
168
+ choices={companyRevenueRanges}
169
+ helperText={false}
170
+ />
171
+ )}
172
+
173
+ {/* Additional */}
174
+ <TextInput source="founded_year" label="Founded Year" helperText={false} type="number" />
101
175
  <TextInput source="tax_identifier" helperText={false} />
102
176
  </div>
103
177
  );
@@ -158,3 +232,56 @@ const CompanyAccountManagerInput = () => {
158
232
 
159
233
  const saleOptionRenderer = (choice: Sale) =>
160
234
  `${choice.first_name} ${choice.last_name}`;
235
+
236
+ const CompanyAdvancedSettings = () => {
237
+ const [isOpen, setIsOpen] = useState(false);
238
+ const { companyQualificationStatuses } = useConfigurationContext();
239
+
240
+ return (
241
+ <Collapsible open={isOpen} onOpenChange={setIsOpen}>
242
+ <CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
243
+ <ChevronDown className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`} />
244
+ Advanced Settings
245
+ </CollapsibleTrigger>
246
+ <CollapsibleContent className="mt-4">
247
+ <div className="flex flex-col gap-6 pl-6 border-l-2 border-muted">
248
+ {/* External System Integration */}
249
+ <div className="flex flex-col gap-4">
250
+ <h6 className="text-sm font-semibold text-muted-foreground">External System Integration</h6>
251
+ <TextInput
252
+ source="external_id"
253
+ label="External ID"
254
+ helperText={false}
255
+ placeholder="e.g., Salesforce Account ID"
256
+ />
257
+ <SelectInput
258
+ source="external_system"
259
+ label="External System"
260
+ choices={[
261
+ { id: 'salesforce', name: 'Salesforce' },
262
+ { id: 'hubspot', name: 'HubSpot' },
263
+ { id: 'clearbit', name: 'Clearbit' },
264
+ { id: 'apollo', name: 'Apollo' },
265
+ { id: 'zoominfo', name: 'ZoomInfo' },
266
+ ]}
267
+ helperText={false}
268
+ />
269
+ </div>
270
+
271
+ {/* Data Quality */}
272
+ {companyQualificationStatuses && (
273
+ <div className="flex flex-col gap-4">
274
+ <h6 className="text-sm font-semibold text-muted-foreground">Data Quality</h6>
275
+ <SelectInput
276
+ source="qualification_status"
277
+ label="Qualification Status"
278
+ choices={companyQualificationStatuses}
279
+ helperText={false}
280
+ />
281
+ </div>
282
+ )}
283
+ </div>
284
+ </CollapsibleContent>
285
+ </Collapsible>
286
+ );
287
+ };
@@ -76,6 +76,12 @@ export const mergeCompanies = async (
76
76
  loserCompany.context_links || [],
77
77
  );
78
78
 
79
+ // Merge social profiles from both companies
80
+ const mergedSocialProfiles = {
81
+ ...(loserCompany.social_profiles || {}),
82
+ ...(winnerCompany.social_profiles || {}),
83
+ };
84
+
79
85
  const winnerUpdate = dataProvider.update<Company>("companies", {
80
86
  id: winnerId,
81
87
  data: {
@@ -99,6 +105,45 @@ export const mergeCompanies = async (
99
105
  winnerCompany.tax_identifier ?? loserCompany.tax_identifier,
100
106
  sales_id: winnerCompany.sales_id ?? loserCompany.sales_id,
101
107
  context_links: mergedContextLinks,
108
+
109
+ // Phase 1: Lifecycle & Classification (prefer winner's values)
110
+ lifecycle_stage: winnerCompany.lifecycle_stage ?? loserCompany.lifecycle_stage,
111
+ company_type: winnerCompany.company_type ?? loserCompany.company_type,
112
+ qualification_status: winnerCompany.qualification_status ?? loserCompany.qualification_status,
113
+
114
+ // Phase 1: External Integration (prefer winner's values)
115
+ external_id: winnerCompany.external_id ?? loserCompany.external_id,
116
+ external_system: winnerCompany.external_system ?? loserCompany.external_system,
117
+
118
+ // Phase 1: Contact Information (prefer winner's email)
119
+ email: winnerCompany.email ?? loserCompany.email,
120
+
121
+ // Phase 1: Firmographics (prefer winner's values)
122
+ industry: winnerCompany.industry ?? loserCompany.industry,
123
+ revenue_range: winnerCompany.revenue_range ?? loserCompany.revenue_range,
124
+ employee_count: winnerCompany.employee_count ?? loserCompany.employee_count,
125
+ founded_year: winnerCompany.founded_year ?? loserCompany.founded_year,
126
+
127
+ // Phase 1: Social & Enrichment (merge social profiles, prefer winner's logo_url)
128
+ social_profiles: mergedSocialProfiles,
129
+ logo_url: winnerCompany.logo_url ?? loserCompany.logo_url,
130
+
131
+ // Phase 2: Heartbeat (take the better/higher heartbeat score)
132
+ external_heartbeat_status: winnerCompany.external_heartbeat_status ?? loserCompany.external_heartbeat_status,
133
+ external_heartbeat_checked_at:
134
+ winnerCompany.external_heartbeat_checked_at ?? loserCompany.external_heartbeat_checked_at,
135
+
136
+ // For internal heartbeat, take the higher score (better engagement)
137
+ internal_heartbeat_score: Math.max(
138
+ winnerCompany.internal_heartbeat_score ?? 0,
139
+ loserCompany.internal_heartbeat_score ?? 0
140
+ ) || undefined,
141
+ internal_heartbeat_status:
142
+ (winnerCompany.internal_heartbeat_score ?? 0) >= (loserCompany.internal_heartbeat_score ?? 0)
143
+ ? winnerCompany.internal_heartbeat_status
144
+ : loserCompany.internal_heartbeat_status,
145
+ internal_heartbeat_updated_at:
146
+ winnerCompany.internal_heartbeat_updated_at ?? loserCompany.internal_heartbeat_updated_at,
102
147
  },
103
148
  previousData: winnerCompany,
104
149
  });
@@ -17,9 +17,22 @@ const sizes = [1, 10, 50, 250, 500];
17
17
 
18
18
  const regex = /\W+/;
19
19
 
20
+ // New field options
21
+ const lifecycleStages = ['prospect', 'customer', 'churned', 'lost', 'archived'];
22
+ const companyTypes = ['customer', 'prospect', 'partner', 'vendor', 'competitor'];
23
+ const qualificationStatuses = ['qualified', 'unqualified', 'duplicate', 'spam'];
24
+ const revenueRanges = ['0-1M', '1M-10M', '10M-50M', '50M-100M', '100M+', 'unknown'];
25
+ const industries = ['SaaS', 'E-commerce', 'Healthcare', 'Fintech', 'Manufacturing', 'Consulting', 'Real Estate', 'Education'];
26
+ const externalHeartbeats = ['healthy', 'risky', 'dead', 'unknown'];
27
+ const internalHeartbeats = ['engaged', 'quiet', 'at_risk', 'unresponsive'];
28
+
20
29
  export const generateCompanies = (db: Db, size = 55): Required<Company>[] => {
21
30
  return Array.from(Array(size).keys()).map((id) => {
22
31
  const name = company.companyName();
32
+ const createdAt = randomDate();
33
+ const hasLifecycle = datatype.boolean();
34
+ const hasHeartbeat = datatype.number(100) > 40; // 60% have heartbeat computed
35
+
23
36
  return {
24
37
  id,
25
38
  name: name,
@@ -40,14 +53,59 @@ export const generateCompanies = (db: Db, size = 55): Required<Company>[] => {
40
53
  stateAbbr: address.stateAbbr(),
41
54
  nb_contacts: 0,
42
55
  nb_deals: 0,
56
+ nb_notes: 0,
57
+ nb_tasks: 0,
43
58
  // at least 1/3rd of companies for Jane Doe
44
59
  sales_id: datatype.number(2) === 0 ? 0 : random.arrayElement(db.sales).id,
45
- created_at: randomDate().toISOString(),
60
+ created_at: createdAt.toISOString(),
46
61
  description: lorem.paragraph(),
47
62
  revenue: random.arrayElement(["$1M", "$10M", "$100M", "$1B"]),
48
63
  tax_identifier: random.alphaNumeric(10),
49
64
  country: random.arrayElement(["USA", "France", "UK"]),
50
65
  context_links: [],
66
+
67
+ // Phase 1: Lifecycle & Classification (70% populated)
68
+ updated_at: randomDate(createdAt).toISOString(),
69
+ lifecycle_stage: hasLifecycle ? random.arrayElement(lifecycleStages) : undefined,
70
+ company_type: datatype.number(100) > 30 ? random.arrayElement(companyTypes) : undefined,
71
+ qualification_status: datatype.number(100) > 60 ? random.arrayElement(qualificationStatuses) : undefined,
72
+
73
+ // Phase 1: External Integration (20% populated)
74
+ external_id: datatype.number(100) > 80 ? random.alphaNumeric(12) : undefined,
75
+ external_system: datatype.number(100) > 80 ? random.arrayElement(['salesforce', 'hubspot', 'clearbit']) : undefined,
76
+
77
+ // Phase 1: Contact Information (70% have email)
78
+ email: datatype.number(100) > 30 ? `info@${internet.domainName()}` : undefined,
79
+
80
+ // Phase 1: Firmographics (varies by field)
81
+ industry: datatype.number(100) > 40 ? random.arrayElement(industries) : undefined,
82
+ revenue_range: datatype.number(100) > 50 ? random.arrayElement(revenueRanges) : undefined,
83
+ employee_count: datatype.number(100) > 50 ? datatype.number({ min: 1, max: 10000 }) : undefined,
84
+ founded_year: datatype.number(100) > 60 ? datatype.number({ min: 1990, max: 2023 }) : undefined,
85
+
86
+ // Phase 1: Social & Enrichment (30% have social profiles)
87
+ social_profiles: datatype.number(100) > 70 ? {
88
+ linkedin: `https://www.linkedin.com/company/${name.toLowerCase().replace(regex, "_")}`,
89
+ ...(datatype.number(100) > 90 && { x: `https://x.com/${name.toLowerCase().replace(regex, "")}` }),
90
+ ...(datatype.number(100) > 95 && { facebook: `https://facebook.com/${name.toLowerCase().replace(regex, "")}` }),
91
+ } : {},
92
+ logo_url: datatype.number(100) > 60 ? `https://logo.clearbit.com/${internet.domainName()}` : undefined,
93
+
94
+ // Phase 2: External Heartbeat (50% computed)
95
+ external_heartbeat_status: datatype.number(100) > 50 ? random.arrayElement(externalHeartbeats) : undefined,
96
+ external_heartbeat_checked_at: datatype.number(100) > 50 ? randomDate(createdAt).toISOString() : undefined,
97
+
98
+ // Phase 2: Internal Heartbeat (60% computed)
99
+ internal_heartbeat_status: hasHeartbeat ? random.arrayElement(internalHeartbeats) : undefined,
100
+ internal_heartbeat_score: hasHeartbeat ? datatype.number({ min: 0, max: 100 }) : undefined,
101
+ internal_heartbeat_updated_at: hasHeartbeat ? randomDate(createdAt).toISOString() : undefined,
102
+
103
+ // View-computed fields (will be set by view in real DB, defaulted here)
104
+ last_note_date: undefined,
105
+ last_deal_activity: undefined,
106
+ last_task_activity: undefined,
107
+ days_since_last_activity: undefined,
108
+ total_deal_amount: 0,
51
109
  };
52
110
  });
53
111
  };
@@ -70,7 +70,47 @@ export type Company = {
70
70
  nb_contacts?: number;
71
71
  nb_deals?: number;
72
72
  nb_notes?: number;
73
+ nb_tasks?: number;
73
74
  last_seen?: string;
75
+
76
+ // Phase 1: Lifecycle & Classification
77
+ updated_at?: string;
78
+ lifecycle_stage?: string;
79
+ company_type?: string;
80
+ qualification_status?: string;
81
+
82
+ // Phase 1: External Integration
83
+ external_id?: string;
84
+ external_system?: string;
85
+
86
+ // Phase 1: Contact Information
87
+ email?: string;
88
+
89
+ // Phase 1: Firmographics
90
+ industry?: string;
91
+ revenue_range?: string;
92
+ employee_count?: number;
93
+ founded_year?: number;
94
+
95
+ // Phase 1: Social & Enrichment
96
+ social_profiles?: Record<string, string>;
97
+ logo_url?: string;
98
+
99
+ // Phase 2: External Heartbeat
100
+ external_heartbeat_status?: string;
101
+ external_heartbeat_checked_at?: string;
102
+
103
+ // Phase 2: Internal Heartbeat
104
+ internal_heartbeat_status?: string;
105
+ internal_heartbeat_score?: number;
106
+ internal_heartbeat_updated_at?: string;
107
+
108
+ // View-computed fields
109
+ last_note_date?: string;
110
+ last_deal_activity?: string;
111
+ last_task_activity?: string;
112
+ days_since_last_activity?: number;
113
+ total_deal_amount?: number;
74
114
  } & Pick<RaRecord, "id">;
75
115
 
76
116
  export type EmailAndType = {
@@ -0,0 +1,31 @@
1
+ import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
2
+
3
+ function Collapsible({
4
+ ...props
5
+ }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
6
+ return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
7
+ }
8
+
9
+ function CollapsibleTrigger({
10
+ ...props
11
+ }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
12
+ return (
13
+ <CollapsiblePrimitive.CollapsibleTrigger
14
+ data-slot="collapsible-trigger"
15
+ {...props}
16
+ />
17
+ )
18
+ }
19
+
20
+ function CollapsibleContent({
21
+ ...props
22
+ }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
23
+ return (
24
+ <CollapsiblePrimitive.CollapsibleContent
25
+ data-slot="collapsible-content"
26
+ {...props}
27
+ />
28
+ )
29
+ }
30
+
31
+ export { Collapsible, CollapsibleTrigger, CollapsibleContent }