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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "realtimex-crm",
3
- "version": "0.13.1",
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 = {