realtimex-crm 0.12.3 → 0.13.3
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/dist/assets/{DealList-syAJzJpA.js → DealList-C_bZazrP.js} +2 -2
- package/dist/assets/{DealList-syAJzJpA.js.map → DealList-C_bZazrP.js.map} +1 -1
- package/dist/assets/index-6eOQryrt.js +166 -0
- package/dist/assets/{index-fNN1dYom.js.map → index-6eOQryrt.js.map} +1 -1
- package/dist/assets/index-BFJ29Og3.css +1 -0
- package/dist/index.html +1 -1
- package/dist/stats.html +1 -1
- package/package.json +2 -1
- package/src/App.tsx +72 -1
- package/src/components/atomic-crm/companies/CompanyAside.tsx +4 -1
- package/src/components/atomic-crm/companies/CompanyHealthCard.tsx +157 -0
- package/src/components/atomic-crm/companies/CompanyInputs.tsx +133 -6
- package/src/components/atomic-crm/providers/commons/mergeCompanies.ts +45 -0
- package/src/components/atomic-crm/providers/fakerest/dataGenerator/companies.ts +59 -1
- package/src/components/atomic-crm/types.ts +40 -0
- package/src/components/ui/collapsible.tsx +31 -0
- package/supabase/migrations/20251225085142_enhance_companies.sql +278 -0
- package/supabase/migrations/20251225100646_refresh_companies_view.sql +53 -0
- package/dist/assets/index-C__S90Gb.css +0 -1
- package/dist/assets/index-fNN1dYom.js +0 -166
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "realtimex-crm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.3",
|
|
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
|
|
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;
|
|
@@ -14,6 +14,7 @@ import { SaleName } from "../sales/SaleName";
|
|
|
14
14
|
import type { Company } from "../types";
|
|
15
15
|
import { sizes } from "./sizes";
|
|
16
16
|
import { CompanyMergeButton } from "./CompanyMergeButton";
|
|
17
|
+
import { CompanyHealthCard } from "./CompanyHealthCard";
|
|
17
18
|
|
|
18
19
|
interface CompanyAsideProps {
|
|
19
20
|
link?: string;
|
|
@@ -33,6 +34,8 @@ export const CompanyAside = ({ link = "edit" }: CompanyAsideProps) => {
|
|
|
33
34
|
)}
|
|
34
35
|
</div>
|
|
35
36
|
|
|
37
|
+
<CompanyHealthCard />
|
|
38
|
+
|
|
36
39
|
<CompanyInfo record={record} />
|
|
37
40
|
|
|
38
41
|
<AddressInfo record={record} />
|
|
@@ -168,7 +171,7 @@ const AdditionalInfo = ({ record }: { record: Company }) => {
|
|
|
168
171
|
{record.description && (
|
|
169
172
|
<p className="text-sm mb-1">{record.description}</p>
|
|
170
173
|
)}
|
|
171
|
-
{record.context_links && (
|
|
174
|
+
{record.context_links && Array.isArray(record.context_links) && (
|
|
172
175
|
<div className="flex flex-col">
|
|
173
176
|
{record.context_links.map((link, index) =>
|
|
174
177
|
link ? (
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { formatDistance } from "date-fns";
|
|
2
|
+
import { Activity, HeartPulse } from "lucide-react";
|
|
3
|
+
import { useRecordContext } from "ra-core";
|
|
4
|
+
import { Badge } from "@/components/ui/badge";
|
|
5
|
+
import { Progress } from "@/components/ui/progress";
|
|
6
|
+
|
|
7
|
+
import { AsideSection } from "../misc/AsideSection";
|
|
8
|
+
import type { Company } from "../types";
|
|
9
|
+
|
|
10
|
+
export const CompanyHealthCard = () => {
|
|
11
|
+
const record = useRecordContext<Company>();
|
|
12
|
+
if (!record) return null;
|
|
13
|
+
|
|
14
|
+
const hasInternalHealth =
|
|
15
|
+
record.internal_heartbeat_score !== undefined ||
|
|
16
|
+
record.internal_heartbeat_status ||
|
|
17
|
+
record.days_since_last_activity !== undefined;
|
|
18
|
+
|
|
19
|
+
const hasExternalHealth =
|
|
20
|
+
record.external_heartbeat_status || record.external_heartbeat_checked_at;
|
|
21
|
+
|
|
22
|
+
if (!hasInternalHealth && !hasExternalHealth) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<AsideSection title="Company Health">
|
|
28
|
+
{/* Internal Heartbeat (Engagement) */}
|
|
29
|
+
{hasInternalHealth && (
|
|
30
|
+
<div className="mb-4">
|
|
31
|
+
<div className="flex items-center gap-2 mb-2">
|
|
32
|
+
<Activity className="w-4 h-4 text-muted-foreground" />
|
|
33
|
+
<span className="font-medium text-sm">Internal Engagement</span>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
{record.internal_heartbeat_score !== undefined && (
|
|
37
|
+
<div className="mb-2">
|
|
38
|
+
<div className="flex items-center justify-between mb-1">
|
|
39
|
+
<span className="text-xs text-muted-foreground">
|
|
40
|
+
Engagement Score
|
|
41
|
+
</span>
|
|
42
|
+
<span className="text-xs font-medium">
|
|
43
|
+
{record.internal_heartbeat_score}/100
|
|
44
|
+
</span>
|
|
45
|
+
</div>
|
|
46
|
+
<Progress
|
|
47
|
+
value={record.internal_heartbeat_score}
|
|
48
|
+
className="h-2"
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
52
|
+
|
|
53
|
+
{record.internal_heartbeat_status && (
|
|
54
|
+
<div className="mb-2">
|
|
55
|
+
<InternalStatusBadge status={record.internal_heartbeat_status} />
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
|
|
59
|
+
{record.days_since_last_activity !== undefined && (
|
|
60
|
+
<div className="text-xs text-muted-foreground">
|
|
61
|
+
Last activity:{" "}
|
|
62
|
+
{record.days_since_last_activity === 0
|
|
63
|
+
? "Today"
|
|
64
|
+
: record.days_since_last_activity === 1
|
|
65
|
+
? "Yesterday"
|
|
66
|
+
: `${record.days_since_last_activity} days ago`}
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
|
|
70
|
+
{record.internal_heartbeat_updated_at && (
|
|
71
|
+
<div className="text-xs text-muted-foreground mt-1">
|
|
72
|
+
Updated{" "}
|
|
73
|
+
{formatDistance(
|
|
74
|
+
new Date(record.internal_heartbeat_updated_at),
|
|
75
|
+
new Date(),
|
|
76
|
+
{ addSuffix: true },
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
|
|
83
|
+
{/* External Heartbeat (Entity Health) */}
|
|
84
|
+
{hasExternalHealth && (
|
|
85
|
+
<div>
|
|
86
|
+
<div className="flex items-center gap-2 mb-2">
|
|
87
|
+
<HeartPulse className="w-4 h-4 text-muted-foreground" />
|
|
88
|
+
<span className="font-medium text-sm">External Health</span>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{record.external_heartbeat_status && (
|
|
92
|
+
<div className="mb-2">
|
|
93
|
+
<ExternalStatusBadge status={record.external_heartbeat_status} />
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
{record.external_heartbeat_checked_at && (
|
|
98
|
+
<div className="text-xs text-muted-foreground">
|
|
99
|
+
Last checked{" "}
|
|
100
|
+
{formatDistance(
|
|
101
|
+
new Date(record.external_heartbeat_checked_at),
|
|
102
|
+
new Date(),
|
|
103
|
+
{ addSuffix: true },
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
</AsideSection>
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const InternalStatusBadge = ({ status }: { status: string }) => {
|
|
114
|
+
const statusConfig: Record<
|
|
115
|
+
string,
|
|
116
|
+
{ variant: "default" | "secondary" | "outline" | "destructive"; label: string }
|
|
117
|
+
> = {
|
|
118
|
+
engaged: { variant: "default", label: "Engaged" },
|
|
119
|
+
quiet: { variant: "secondary", label: "Quiet" },
|
|
120
|
+
at_risk: { variant: "outline", label: "At Risk" },
|
|
121
|
+
unresponsive: { variant: "destructive", label: "Unresponsive" },
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const config = statusConfig[status] || {
|
|
125
|
+
variant: "outline" as const,
|
|
126
|
+
label: status,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<Badge variant={config.variant} className="text-xs">
|
|
131
|
+
{config.label}
|
|
132
|
+
</Badge>
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const ExternalStatusBadge = ({ status }: { status: string }) => {
|
|
137
|
+
const statusConfig: Record<
|
|
138
|
+
string,
|
|
139
|
+
{ variant: "default" | "secondary" | "outline" | "destructive"; label: string }
|
|
140
|
+
> = {
|
|
141
|
+
healthy: { variant: "default", label: "Healthy" },
|
|
142
|
+
risky: { variant: "outline", label: "Risky" },
|
|
143
|
+
dead: { variant: "destructive", label: "Dead" },
|
|
144
|
+
unknown: { variant: "secondary", label: "Unknown" },
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const config = statusConfig[status] || {
|
|
148
|
+
variant: "outline" as const,
|
|
149
|
+
label: status,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<Badge variant={config.variant} className="text-xs">
|
|
154
|
+
{config.label}
|
|
155
|
+
</Badge>
|
|
156
|
+
);
|
|
157
|
+
};
|
|
@@ -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 {
|
|
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:
|
|
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 = {
|