realtimex-crm 0.13.1 → 0.13.5
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-DjGN09Te.js → DealList-CFcN25AN.js} +2 -2
- package/dist/assets/{DealList-DjGN09Te.js.map → DealList-CFcN25AN.js.map} +1 -1
- package/dist/assets/{index-Lbg40PSt.js → index-BxvvgMGy.js} +61 -61
- package/dist/assets/{index-Lbg40PSt.js.map → index-BxvvgMGy.js.map} +1 -1
- package/dist/assets/index-DIJpijUP.css +1 -0
- package/dist/index.html +1 -1
- package/dist/stats.html +1 -1
- package/package.json +1 -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/contacts/ContactAside.tsx +3 -0
- package/src/components/atomic-crm/contacts/ContactHealthCard.tsx +201 -0
- package/src/components/atomic-crm/contacts/ContactListFilter.tsx +47 -1
- package/src/components/atomic-crm/providers/fakerest/dataGenerator/contacts.ts +37 -0
- package/src/components/atomic-crm/types.ts +20 -0
- package/supabase/migrations/20251226100000_add_contact_heartbeats.sql +237 -0
- package/supabase/migrations/20251226101500_backfill_contact_heartbeats.sql +12 -0
- package/dist/assets/index-BFJ29Og3.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "realtimex-crm",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.5",
|
|
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",
|
|
@@ -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
|
+
};
|
|
@@ -21,6 +21,7 @@ import { SaleName } from "../sales/SaleName";
|
|
|
21
21
|
import type { Contact } from "../types";
|
|
22
22
|
import { ContactMergeButton } from "./ContactMergeButton";
|
|
23
23
|
import { ExportVCardButton } from "./ExportVCardButton";
|
|
24
|
+
import { ContactHealthCard } from "./ContactHealthCard";
|
|
24
25
|
|
|
25
26
|
export const ContactAside = ({ link = "edit" }: { link?: "edit" | "show" }) => {
|
|
26
27
|
const { contactGender } = useConfigurationContext();
|
|
@@ -37,6 +38,8 @@ export const ContactAside = ({ link = "edit" }: { link?: "edit" | "show" }) => {
|
|
|
37
38
|
)}
|
|
38
39
|
</div>
|
|
39
40
|
|
|
41
|
+
<ContactHealthCard />
|
|
42
|
+
|
|
40
43
|
<AsideSection title="Personal info">
|
|
41
44
|
<ArrayField source="email_jsonb">
|
|
42
45
|
<SingleFieldList className="flex-col">
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { formatDistance } from "date-fns";
|
|
2
|
+
import { Activity, HeartPulse, Mail, Linkedin } 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 { Contact } from "../types";
|
|
9
|
+
|
|
10
|
+
const InternalStatusBadge = ({ status }: { status: string }) => {
|
|
11
|
+
let variant: "default" | "secondary" | "destructive" | "outline" = "outline";
|
|
12
|
+
let className = "";
|
|
13
|
+
|
|
14
|
+
switch (status) {
|
|
15
|
+
case "strong":
|
|
16
|
+
variant = "default";
|
|
17
|
+
break;
|
|
18
|
+
case "active":
|
|
19
|
+
variant = "secondary";
|
|
20
|
+
className = "bg-green-100 text-green-800 hover:bg-green-100";
|
|
21
|
+
break;
|
|
22
|
+
case "cooling":
|
|
23
|
+
variant = "secondary";
|
|
24
|
+
className = "bg-yellow-100 text-yellow-800 hover:bg-yellow-100";
|
|
25
|
+
break;
|
|
26
|
+
case "cold":
|
|
27
|
+
variant = "secondary";
|
|
28
|
+
className = "bg-orange-100 text-orange-800 hover:bg-orange-100";
|
|
29
|
+
break;
|
|
30
|
+
case "dormant":
|
|
31
|
+
variant = "secondary";
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Badge variant={variant} className={className}>
|
|
37
|
+
{status.charAt(0).toUpperCase() + status.slice(1)}
|
|
38
|
+
</Badge>
|
|
39
|
+
);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const EmailStatusBadge = ({ status }: { status: string }) => {
|
|
43
|
+
let variant: "default" | "secondary" | "destructive" | "outline" = "outline";
|
|
44
|
+
let className = "";
|
|
45
|
+
|
|
46
|
+
switch (status) {
|
|
47
|
+
case "valid":
|
|
48
|
+
variant = "secondary";
|
|
49
|
+
className = "bg-green-100 text-green-800 hover:bg-green-100";
|
|
50
|
+
break;
|
|
51
|
+
case "risky":
|
|
52
|
+
variant = "secondary";
|
|
53
|
+
className = "bg-yellow-100 text-yellow-800 hover:bg-yellow-100";
|
|
54
|
+
break;
|
|
55
|
+
case "invalid":
|
|
56
|
+
variant = "destructive";
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Badge variant={variant} className={className}>
|
|
62
|
+
{status.charAt(0).toUpperCase() + status.slice(1)}
|
|
63
|
+
</Badge>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const LinkedInStatusBadge = ({ status }: { status: string }) => {
|
|
68
|
+
let variant: "default" | "secondary" | "destructive" | "outline" = "outline";
|
|
69
|
+
let className = "";
|
|
70
|
+
|
|
71
|
+
switch (status) {
|
|
72
|
+
case "active":
|
|
73
|
+
variant = "default";
|
|
74
|
+
className = "bg-[#0077b5] hover:bg-[#0077b5]"; // LinkedIn Blue
|
|
75
|
+
break;
|
|
76
|
+
case "inactive":
|
|
77
|
+
variant = "secondary";
|
|
78
|
+
break;
|
|
79
|
+
case "not_found":
|
|
80
|
+
variant = "destructive";
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<Badge variant={variant} className={className}>
|
|
86
|
+
{status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')}
|
|
87
|
+
</Badge>
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const ContactHealthCard = () => {
|
|
92
|
+
const record = useRecordContext<Contact>();
|
|
93
|
+
if (!record) return null;
|
|
94
|
+
|
|
95
|
+
const daysSince = record.days_since_last_activity ??
|
|
96
|
+
(record.last_seen ? Math.floor((new Date().getTime() - new Date(record.last_seen).getTime()) / (1000 * 60 * 60 * 24)) : undefined);
|
|
97
|
+
|
|
98
|
+
const hasInternalHealth =
|
|
99
|
+
record.internal_heartbeat_score != null ||
|
|
100
|
+
record.internal_heartbeat_status != null ||
|
|
101
|
+
daysSince != null;
|
|
102
|
+
|
|
103
|
+
const hasExternalHealth =
|
|
104
|
+
record.external_heartbeat_status != null ||
|
|
105
|
+
record.email_validation_status != null ||
|
|
106
|
+
record.linkedin_profile_status != null;
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<AsideSection title="Contact Health">
|
|
110
|
+
{!hasInternalHealth && !hasExternalHealth && (
|
|
111
|
+
<div className="text-xs text-muted-foreground italic">
|
|
112
|
+
No health data calculated yet.
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{/* Internal Heartbeat (Relationship Strength) */}
|
|
117
|
+
{hasInternalHealth && (
|
|
118
|
+
<div className="mb-4">
|
|
119
|
+
<div className="flex items-center gap-2 mb-2">
|
|
120
|
+
<Activity className="w-4 h-4 text-muted-foreground" />
|
|
121
|
+
<span className="font-medium text-sm">Relationship Strength</span>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{record.internal_heartbeat_score != null && (
|
|
125
|
+
<div className="mb-2">
|
|
126
|
+
<div className="flex items-center justify-between mb-1">
|
|
127
|
+
<span className="text-xs text-muted-foreground">
|
|
128
|
+
Engagement Score
|
|
129
|
+
</span>
|
|
130
|
+
<span className="text-xs font-medium">
|
|
131
|
+
{record.internal_heartbeat_score}/100
|
|
132
|
+
</span>
|
|
133
|
+
</div>
|
|
134
|
+
<Progress
|
|
135
|
+
value={record.internal_heartbeat_score}
|
|
136
|
+
className="h-2"
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
{record.internal_heartbeat_status && (
|
|
142
|
+
<div className="mb-2">
|
|
143
|
+
<InternalStatusBadge status={record.internal_heartbeat_status} />
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{daysSince != null && (
|
|
148
|
+
<div className="text-xs text-muted-foreground">
|
|
149
|
+
Last activity:{" "}
|
|
150
|
+
{daysSince === 0
|
|
151
|
+
? "Today"
|
|
152
|
+
: daysSince === 1
|
|
153
|
+
? "Yesterday"
|
|
154
|
+
: `${daysSince} days ago`}
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
|
|
160
|
+
{/* External Heartbeat (Contact Validation) */}
|
|
161
|
+
{hasExternalHealth && (
|
|
162
|
+
<div>
|
|
163
|
+
<div className="flex items-center gap-2 mb-2">
|
|
164
|
+
<HeartPulse className="w-4 h-4 text-muted-foreground" />
|
|
165
|
+
<span className="font-medium text-sm">Contact Validation</span>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
{record.email_validation_status && (
|
|
169
|
+
<div className="flex items-center gap-2 mb-2">
|
|
170
|
+
<Mail className="w-3 h-3 text-muted-foreground" />
|
|
171
|
+
<EmailStatusBadge status={record.email_validation_status} />
|
|
172
|
+
{record.email_last_bounced_at && (
|
|
173
|
+
<span className="text-xs text-destructive">
|
|
174
|
+
(bounced)
|
|
175
|
+
</span>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{record.linkedin_profile_status && (
|
|
181
|
+
<div className="flex items-center gap-2 mb-2">
|
|
182
|
+
<Linkedin className="w-3 h-3 text-muted-foreground" />
|
|
183
|
+
<LinkedInStatusBadge status={record.linkedin_profile_status} />
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{record.external_heartbeat_checked_at && (
|
|
188
|
+
<div className="text-xs text-muted-foreground">
|
|
189
|
+
Validated{" "}
|
|
190
|
+
{formatDistance(
|
|
191
|
+
new Date(record.external_heartbeat_checked_at),
|
|
192
|
+
new Date(),
|
|
193
|
+
{ addSuffix: true },
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
</AsideSection>
|
|
200
|
+
);
|
|
201
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { endOfYesterday, startOfMonth, startOfWeek, subMonths } from "date-fns";
|
|
2
|
-
import { CheckSquare, Clock, Tag, TrendingUp, Users } from "lucide-react";
|
|
2
|
+
import { Activity, CheckSquare, Clock, HeartPulse, Tag, TrendingUp, Users } from "lucide-react";
|
|
3
3
|
import { FilterLiveForm, useGetIdentity, useGetList } from "ra-core";
|
|
4
4
|
import { ToggleFilterButton } from "@/components/admin/toggle-filter-button";
|
|
5
5
|
import { SearchInput } from "@/components/admin/search-input";
|
|
@@ -69,6 +69,52 @@ export const ContactListFilter = () => {
|
|
|
69
69
|
/>
|
|
70
70
|
</FilterCategory>
|
|
71
71
|
|
|
72
|
+
<FilterCategory label="Engagement" icon={<Activity />}>
|
|
73
|
+
<ToggleFilterButton
|
|
74
|
+
className="w-full justify-between"
|
|
75
|
+
label="Strong"
|
|
76
|
+
value={{ internal_heartbeat_status: "strong" }}
|
|
77
|
+
/>
|
|
78
|
+
<ToggleFilterButton
|
|
79
|
+
className="w-full justify-between"
|
|
80
|
+
label="Active"
|
|
81
|
+
value={{ internal_heartbeat_status: "active" }}
|
|
82
|
+
/>
|
|
83
|
+
<ToggleFilterButton
|
|
84
|
+
className="w-full justify-between"
|
|
85
|
+
label="Cooling"
|
|
86
|
+
value={{ internal_heartbeat_status: "cooling" }}
|
|
87
|
+
/>
|
|
88
|
+
<ToggleFilterButton
|
|
89
|
+
className="w-full justify-between"
|
|
90
|
+
label="Cold"
|
|
91
|
+
value={{ internal_heartbeat_status: "cold" }}
|
|
92
|
+
/>
|
|
93
|
+
<ToggleFilterButton
|
|
94
|
+
className="w-full justify-between"
|
|
95
|
+
label="Dormant"
|
|
96
|
+
value={{ internal_heartbeat_status: "dormant" }}
|
|
97
|
+
/>
|
|
98
|
+
</FilterCategory>
|
|
99
|
+
|
|
100
|
+
<FilterCategory label="Validation" icon={<HeartPulse />}>
|
|
101
|
+
<ToggleFilterButton
|
|
102
|
+
className="w-full justify-between"
|
|
103
|
+
label="Valid Email"
|
|
104
|
+
value={{ email_validation_status: "valid" }}
|
|
105
|
+
/>
|
|
106
|
+
<ToggleFilterButton
|
|
107
|
+
className="w-full justify-between"
|
|
108
|
+
label="Risky Email"
|
|
109
|
+
value={{ email_validation_status: "risky" }}
|
|
110
|
+
/>
|
|
111
|
+
<ToggleFilterButton
|
|
112
|
+
className="w-full justify-between"
|
|
113
|
+
label="Invalid Email"
|
|
114
|
+
value={{ email_validation_status: "invalid" }}
|
|
115
|
+
/>
|
|
116
|
+
</FilterCategory>
|
|
117
|
+
|
|
72
118
|
<FilterCategory label="Status" icon={<TrendingUp />}>
|
|
73
119
|
{noteStatuses.map((status) => (
|
|
74
120
|
<ToggleFilterButton
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
name,
|
|
6
6
|
phone,
|
|
7
7
|
random,
|
|
8
|
+
date,
|
|
9
|
+
datatype,
|
|
8
10
|
} from "faker/locale/en_US";
|
|
9
11
|
|
|
10
12
|
import {
|
|
@@ -75,6 +77,38 @@ export const generateContacts = (db: Db, size = 500): Required<Contact>[] => {
|
|
|
75
77
|
const first_seen = randomDate(new Date(company.created_at)).toISOString();
|
|
76
78
|
const last_seen = first_seen;
|
|
77
79
|
|
|
80
|
+
// Heartbeat generation
|
|
81
|
+
const daysInactive = datatype.number({ min: 0, max: 365 });
|
|
82
|
+
|
|
83
|
+
const computeScore = (days: number): number => {
|
|
84
|
+
if (days <= 7) return datatype.number({ min: 80, max: 100 });
|
|
85
|
+
if (days <= 30) return datatype.number({ min: 60, max: 79 });
|
|
86
|
+
if (days <= 90) return datatype.number({ min: 40, max: 59 });
|
|
87
|
+
if (days <= 180) return datatype.number({ min: 20, max: 39 });
|
|
88
|
+
return datatype.number({ min: 0, max: 19 });
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const score = computeScore(daysInactive);
|
|
92
|
+
|
|
93
|
+
// Internal heartbeat (70% populated)
|
|
94
|
+
const internalHeartbeat = Math.random() > 0.3 ? {
|
|
95
|
+
internal_heartbeat_score: score,
|
|
96
|
+
internal_heartbeat_status:
|
|
97
|
+
score >= 80 ? 'strong' :
|
|
98
|
+
score >= 60 ? 'active' :
|
|
99
|
+
score >= 40 ? 'cooling' :
|
|
100
|
+
score >= 20 ? 'cold' : 'dormant',
|
|
101
|
+
internal_heartbeat_updated_at: date.recent(7).toISOString(),
|
|
102
|
+
} : {};
|
|
103
|
+
|
|
104
|
+
// External heartbeat (50% populated)
|
|
105
|
+
const externalHeartbeat = Math.random() > 0.5 ? {
|
|
106
|
+
external_heartbeat_status: random.arrayElement(['valid', 'warning', 'invalid', 'unknown']),
|
|
107
|
+
external_heartbeat_checked_at: date.recent(30).toISOString(),
|
|
108
|
+
email_validation_status: random.arrayElement(['valid', 'risky', 'invalid', 'unknown']),
|
|
109
|
+
linkedin_profile_status: random.arrayElement(['active', 'inactive', 'not_found', 'unknown']),
|
|
110
|
+
} : {};
|
|
111
|
+
|
|
78
112
|
return {
|
|
79
113
|
id,
|
|
80
114
|
first_name,
|
|
@@ -98,6 +132,9 @@ export const generateContacts = (db: Db, size = 500): Required<Contact>[] => {
|
|
|
98
132
|
sales_id: company.sales_id,
|
|
99
133
|
nb_tasks: 0,
|
|
100
134
|
linkedin_url: null,
|
|
135
|
+
...internalHeartbeat,
|
|
136
|
+
...externalHeartbeat,
|
|
137
|
+
days_since_last_activity: daysInactive,
|
|
101
138
|
};
|
|
102
139
|
});
|
|
103
140
|
};
|
|
@@ -142,6 +142,26 @@ export type Contact = {
|
|
|
142
142
|
phone_jsonb: PhoneNumberAndType[];
|
|
143
143
|
nb_tasks?: number;
|
|
144
144
|
company_name?: string;
|
|
145
|
+
|
|
146
|
+
// Internal Heartbeat
|
|
147
|
+
internal_heartbeat_score?: number;
|
|
148
|
+
internal_heartbeat_status?: string; // 'strong' | 'active' | 'cooling' | 'cold' | 'dormant'
|
|
149
|
+
internal_heartbeat_updated_at?: string;
|
|
150
|
+
|
|
151
|
+
// External Heartbeat
|
|
152
|
+
external_heartbeat_status?: string; // 'valid' | 'warning' | 'invalid' | 'unknown'
|
|
153
|
+
external_heartbeat_checked_at?: string;
|
|
154
|
+
email_validation_status?: string; // 'valid' | 'risky' | 'invalid' | 'unknown'
|
|
155
|
+
email_last_bounced_at?: string;
|
|
156
|
+
linkedin_profile_status?: string; // 'active' | 'inactive' | 'not_found' | 'unknown'
|
|
157
|
+
employment_verified_at?: string;
|
|
158
|
+
|
|
159
|
+
// View-computed fields
|
|
160
|
+
nb_completed_tasks?: number;
|
|
161
|
+
task_completion_rate?: number;
|
|
162
|
+
last_note_date?: string;
|
|
163
|
+
last_task_activity?: string;
|
|
164
|
+
days_since_last_activity?: number;
|
|
145
165
|
} & Pick<RaRecord, "id">;
|
|
146
166
|
|
|
147
167
|
export type ContactNote = {
|