realtimex-crm 0.9.8 → 0.11.0

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.9.8",
3
+ "version": "0.11.0",
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",
@@ -13,6 +13,7 @@ import { AsideSection } from "../misc/AsideSection";
13
13
  import { SaleName } from "../sales/SaleName";
14
14
  import type { Company } from "../types";
15
15
  import { sizes } from "./sizes";
16
+ import { CompanyMergeButton } from "./CompanyMergeButton";
16
17
 
17
18
  interface CompanyAsideProps {
18
19
  link?: string;
@@ -41,12 +42,17 @@ export const CompanyAside = ({ link = "edit" }: CompanyAsideProps) => {
41
42
  <AdditionalInfo record={record} />
42
43
 
43
44
  {link !== "edit" && (
44
- <div className="mt-6 pt-6 border-t hidden sm:flex flex-col gap-2 items-start">
45
- <DeleteButton
46
- className="h-6 cursor-pointer hover:bg-destructive/10! text-destructive! border-destructive! focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40"
47
- size="sm"
48
- />
49
- </div>
45
+ <>
46
+ <div className="mt-6 pt-6 border-t hidden sm:flex flex-col gap-2 items-start">
47
+ <CompanyMergeButton />
48
+ </div>
49
+ <div className="mt-6 pt-6 border-t hidden sm:flex flex-col gap-2 items-start">
50
+ <DeleteButton
51
+ className="h-6 cursor-pointer hover:bg-destructive/10! text-destructive! border-destructive! focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40"
52
+ size="sm"
53
+ />
54
+ </div>
55
+ </>
50
56
  )}
51
57
  </div>
52
58
  );
@@ -0,0 +1,237 @@
1
+ import { useState, useEffect } from "react";
2
+ import { Merge, CircleX, AlertTriangle, ArrowDown } from "lucide-react";
3
+ import {
4
+ useDataProvider,
5
+ useRecordContext,
6
+ useGetList,
7
+ useGetManyReference,
8
+ required,
9
+ Form,
10
+ useNotify,
11
+ useRedirect,
12
+ } from "ra-core";
13
+ import type { Identifier } from "ra-core";
14
+ import { useMutation } from "@tanstack/react-query";
15
+ import {
16
+ Dialog,
17
+ DialogContent,
18
+ DialogHeader,
19
+ DialogTitle,
20
+ DialogDescription,
21
+ DialogFooter,
22
+ } from "@/components/ui/dialog";
23
+ import { Button } from "@/components/ui/button";
24
+ import { ReferenceInput } from "@/components/admin/reference-input";
25
+ import { AutocompleteInput } from "@/components/admin/autocomplete-input";
26
+ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
27
+ import type { Company } from "../types";
28
+
29
+ export const CompanyMergeButton = () => {
30
+ const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
31
+ return (
32
+ <>
33
+ <Button
34
+ variant="outline"
35
+ className="h-6 cursor-pointer"
36
+ size="sm"
37
+ onClick={() => setMergeDialogOpen(true)}
38
+ >
39
+ <Merge className="w-4 h-4" />
40
+ Merge with another company
41
+ </Button>
42
+ <CompanyMergeDialog
43
+ open={mergeDialogOpen}
44
+ onClose={() => setMergeDialogOpen(false)}
45
+ />
46
+ </>
47
+ );
48
+ };
49
+
50
+ interface CompanyMergeDialogProps {
51
+ open: boolean;
52
+ onClose: () => void;
53
+ }
54
+
55
+ const CompanyMergeDialog = ({ open, onClose }: CompanyMergeDialogProps) => {
56
+ const loserCompany = useRecordContext<Company>();
57
+ const notify = useNotify();
58
+ const redirect = useRedirect();
59
+ const dataProvider = useDataProvider();
60
+ const [winnerId, setWinnerId] = useState<Identifier | null>(null);
61
+ const [suggestedWinnerId, setSuggestedWinnerId] = useState<Identifier | null>(
62
+ null,
63
+ );
64
+ const [isMerging, setIsMerging] = useState(false);
65
+ const { mutateAsync } = useMutation({
66
+ mutationKey: ["companies", "merge", { loserId: loserCompany?.id, winnerId }],
67
+ mutationFn: async () => {
68
+ return dataProvider.mergeCompanies(loserCompany?.id, winnerId);
69
+ },
70
+ });
71
+
72
+ // Find potential companies with matching name
73
+ const { data: matchingCompanies } = useGetList(
74
+ "companies",
75
+ {
76
+ filter: {
77
+ name: loserCompany?.name,
78
+ "id@neq": `${loserCompany?.id}`, // Exclude current company
79
+ },
80
+ pagination: { page: 1, perPage: 10 },
81
+ },
82
+ { enabled: open && !!loserCompany },
83
+ );
84
+
85
+ // Get counts of items to be merged
86
+ const canFetchCounts = open && !!loserCompany && !!winnerId;
87
+ const { total: contactsCount } = useGetManyReference(
88
+ "contacts",
89
+ {
90
+ target: "company_id",
91
+ id: loserCompany?.id,
92
+ pagination: { page: 1, perPage: 1 },
93
+ },
94
+ { enabled: canFetchCounts },
95
+ );
96
+
97
+ const { total: dealsCount } = useGetManyReference(
98
+ "deals",
99
+ {
100
+ target: "company_id",
101
+ id: loserCompany?.id,
102
+ pagination: { page: 1, perPage: 1 },
103
+ },
104
+ { enabled: canFetchCounts },
105
+ );
106
+
107
+ useEffect(() => {
108
+ if (matchingCompanies && matchingCompanies.length > 0) {
109
+ const suggestedWinnerId = matchingCompanies[0].id;
110
+ setSuggestedWinnerId(suggestedWinnerId);
111
+ setWinnerId(suggestedWinnerId);
112
+ }
113
+ }, [matchingCompanies]);
114
+
115
+ const handleMerge = async () => {
116
+ if (!winnerId || !loserCompany) {
117
+ notify("Please select a company to merge with", { type: "warning" });
118
+ return;
119
+ }
120
+
121
+ try {
122
+ setIsMerging(true);
123
+ await mutateAsync();
124
+ setIsMerging(false);
125
+ notify("Companies merged successfully", { type: "success" });
126
+ redirect(`/companies/${winnerId}/show`);
127
+ onClose();
128
+ } catch (error) {
129
+ setIsMerging(false);
130
+ notify("Failed to merge companies", { type: "error" });
131
+ console.error("Merge failed:", error);
132
+ }
133
+ };
134
+
135
+ if (!loserCompany) return null;
136
+
137
+ return (
138
+ <Dialog open={open} onOpenChange={onClose}>
139
+ <DialogContent className="md:min-w-lg max-w-2xl">
140
+ <DialogHeader>
141
+ <DialogTitle>Merge Company</DialogTitle>
142
+ <DialogDescription>
143
+ Merge this company with another one.
144
+ </DialogDescription>
145
+ </DialogHeader>
146
+
147
+ <div className="space-y-4">
148
+ <div className="p-4 bg-primary/5 rounded-lg border border-primary/20">
149
+ <p className="font-medium text-sm">
150
+ Current Company (will be deleted)
151
+ </p>
152
+ <div className="font-medium text-sm mt-4">{loserCompany.name}</div>
153
+
154
+ <div className="flex justify-center my-4">
155
+ <ArrowDown className="h-5 w-5 text-muted-foreground" />
156
+ </div>
157
+
158
+ <p className="font-medium text-sm mb-2">
159
+ Target Company (will be kept)
160
+ </p>
161
+ <Form>
162
+ <ReferenceInput
163
+ source="winner_id"
164
+ reference="companies"
165
+ filter={{ "id@neq": loserCompany.id }}
166
+ >
167
+ <AutocompleteInput
168
+ label=""
169
+ optionText="name"
170
+ validate={required()}
171
+ onChange={setWinnerId}
172
+ defaultValue={suggestedWinnerId}
173
+ helperText={false}
174
+ />
175
+ </ReferenceInput>
176
+ </Form>
177
+ </div>
178
+
179
+ {winnerId && (
180
+ <>
181
+ <div className="space-y-2">
182
+ <p className="font-medium text-sm">What will be merged:</p>
183
+ <ul className="text-sm text-muted-foreground space-y-1 ml-4">
184
+ {contactsCount != null && contactsCount > 0 && (
185
+ <li>
186
+ • {contactsCount} contact
187
+ {contactsCount !== 1 ? "s" : ""} will be reassigned
188
+ </li>
189
+ )}
190
+ {dealsCount != null && dealsCount > 0 && (
191
+ <li>
192
+ • {dealsCount} deal
193
+ {dealsCount !== 1 ? "s" : ""} will be reassigned
194
+ </li>
195
+ )}
196
+ {loserCompany.context_links?.length > 0 && (
197
+ <li>
198
+ • {loserCompany.context_links.length} context link
199
+ {loserCompany.context_links.length !== 1 ? "s" : ""} will
200
+ be added
201
+ </li>
202
+ )}
203
+ {!contactsCount &&
204
+ !dealsCount &&
205
+ !loserCompany.context_links?.length && (
206
+ <li className="text-muted-foreground/60">
207
+ No additional data to merge
208
+ </li>
209
+ )}
210
+ </ul>
211
+ </div>
212
+ <Alert variant="destructive">
213
+ <AlertTriangle className="h-4 w-4" />
214
+ <AlertTitle>Warning: Destructive Operation</AlertTitle>
215
+ <AlertDescription>
216
+ All data will be transferred to the second company. This
217
+ action cannot be undone.
218
+ </AlertDescription>
219
+ </Alert>
220
+ </>
221
+ )}
222
+ </div>
223
+
224
+ <DialogFooter>
225
+ <Button variant="ghost" onClick={onClose} disabled={isMerging}>
226
+ <CircleX />
227
+ Cancel
228
+ </Button>
229
+ <Button onClick={handleMerge} disabled={!winnerId || isMerging}>
230
+ <Merge />
231
+ {isMerging ? "Merging..." : "Merge Companies"}
232
+ </Button>
233
+ </DialogFooter>
234
+ </DialogContent>
235
+ </Dialog>
236
+ );
237
+ };
@@ -39,13 +39,13 @@ export const TagsListEdit = () => {
39
39
  const [update] = useUpdate<Contact>();
40
40
 
41
41
  const unselectedTags =
42
- allTags && record && allTags.filter((tag) => !record.tags.includes(tag.id));
42
+ allTags && record && allTags.filter((tag) => !(record.tags ?? []).includes(tag.id));
43
43
 
44
44
  const handleTagAdd = (id: Identifier) => {
45
45
  if (!record) {
46
46
  throw new Error("No contact record found");
47
47
  }
48
- const tags = [...record.tags, id];
48
+ const tags = [...(record.tags ?? []), id];
49
49
  update("contacts", {
50
50
  id: record.id,
51
51
  data: { tags },
@@ -57,7 +57,7 @@ export const TagsListEdit = () => {
57
57
  if (!record) {
58
58
  throw new Error("No contact record found");
59
59
  }
60
- const tags = record.tags.filter((tagId) => tagId !== id);
60
+ const tags = (record.tags ?? []).filter((tagId) => tagId !== id);
61
61
  await update("contacts", {
62
62
  id: record.id,
63
63
  data: { tags },
@@ -83,7 +83,7 @@ export const TagsListEdit = () => {
83
83
  "contacts",
84
84
  {
85
85
  id: record.id,
86
- data: { tags: [...record.tags, tag.id] },
86
+ data: { tags: [...(record.tags ?? []), tag.id] },
87
87
  previousData: record,
88
88
  },
89
89
  {
@@ -0,0 +1,119 @@
1
+ import type { Identifier, DataProvider } from "ra-core";
2
+
3
+ import type { Company, Contact, Deal } from "../../types";
4
+
5
+ /**
6
+ * Merge one company (loser) into another company (winner).
7
+ *
8
+ * This function copies properties from the loser to the winner company,
9
+ * transfers all associated data (contacts, deals) from the loser to the winner,
10
+ * and deletes the loser company.
11
+ */
12
+ export const mergeCompanies = async (
13
+ loserId: Identifier,
14
+ winnerId: Identifier,
15
+ dataProvider: DataProvider,
16
+ ) => {
17
+ // Fetch both companies using dataProvider to get fresh data
18
+ const { data: winnerCompany } = await dataProvider.getOne<Company>(
19
+ "companies",
20
+ { id: winnerId },
21
+ );
22
+ const { data: loserCompany } = await dataProvider.getOne<Company>(
23
+ "companies",
24
+ { id: loserId },
25
+ );
26
+
27
+ if (!winnerCompany || !loserCompany) {
28
+ throw new Error("Could not fetch companies");
29
+ }
30
+
31
+ // 1. Reassign all contacts from loser to winner
32
+ const { data: loserContacts } = await dataProvider.getManyReference<Contact>(
33
+ "contacts",
34
+ {
35
+ target: "company_id",
36
+ id: loserId,
37
+ pagination: { page: 1, perPage: 1000 },
38
+ sort: { field: "id", order: "ASC" },
39
+ filter: {},
40
+ },
41
+ );
42
+
43
+ const contactUpdates =
44
+ loserContacts?.map((contact) =>
45
+ dataProvider.update("contacts", {
46
+ id: contact.id,
47
+ data: { company_id: winnerId },
48
+ previousData: contact,
49
+ }),
50
+ ) || [];
51
+
52
+ // 2. Reassign all deals from loser to winner
53
+ const { data: loserDeals } = await dataProvider.getManyReference<Deal>(
54
+ "deals",
55
+ {
56
+ target: "company_id",
57
+ id: loserId,
58
+ pagination: { page: 1, perPage: 1000 },
59
+ sort: { field: "id", order: "ASC" },
60
+ filter: {},
61
+ },
62
+ );
63
+
64
+ const dealUpdates =
65
+ loserDeals?.map((deal) =>
66
+ dataProvider.update<Deal>("deals", {
67
+ id: deal.id,
68
+ data: { company_id: winnerId },
69
+ previousData: deal,
70
+ }),
71
+ ) || [];
72
+
73
+ // 3. Update winner company with loser data
74
+ const mergedContextLinks = mergeArraysUnique(
75
+ winnerCompany.context_links || [],
76
+ loserCompany.context_links || [],
77
+ );
78
+
79
+ const winnerUpdate = dataProvider.update<Company>("companies", {
80
+ id: winnerId,
81
+ data: {
82
+ logo:
83
+ winnerCompany.logo && winnerCompany.logo.src
84
+ ? winnerCompany.logo
85
+ : loserCompany.logo,
86
+ sector: winnerCompany.sector ?? loserCompany.sector,
87
+ size: winnerCompany.size ?? loserCompany.size,
88
+ linkedin_url: winnerCompany.linkedin_url || loserCompany.linkedin_url,
89
+ website: winnerCompany.website || loserCompany.website,
90
+ phone_number: winnerCompany.phone_number ?? loserCompany.phone_number,
91
+ address: winnerCompany.address ?? loserCompany.address,
92
+ zipcode: winnerCompany.zipcode ?? loserCompany.zipcode,
93
+ city: winnerCompany.city ?? loserCompany.city,
94
+ stateAbbr: winnerCompany.stateAbbr ?? loserCompany.stateAbbr,
95
+ country: winnerCompany.country ?? loserCompany.country,
96
+ description: winnerCompany.description ?? loserCompany.description,
97
+ revenue: winnerCompany.revenue ?? loserCompany.revenue,
98
+ tax_identifier:
99
+ winnerCompany.tax_identifier ?? loserCompany.tax_identifier,
100
+ sales_id: winnerCompany.sales_id ?? loserCompany.sales_id,
101
+ context_links: mergedContextLinks,
102
+ },
103
+ previousData: winnerCompany,
104
+ });
105
+
106
+ // Execute all updates
107
+ await Promise.all([...contactUpdates, ...dealUpdates, winnerUpdate]);
108
+
109
+ // 4. Delete the loser company
110
+ await dataProvider.delete<Company>("companies", {
111
+ id: loserId,
112
+ previousData: loserCompany,
113
+ });
114
+ };
115
+
116
+ // Helper function to merge arrays and remove duplicates
117
+ const mergeArraysUnique = <T>(arr1: T[], arr2: T[]): T[] => [
118
+ ...new Set([...arr1, ...arr2]),
119
+ ];
@@ -21,6 +21,7 @@ import { getActivityLog } from "../commons/activity";
21
21
  import { getCompanyAvatar } from "../commons/getCompanyAvatar";
22
22
  import { getContactAvatar } from "../commons/getContactAvatar";
23
23
  import { mergeContacts } from "../commons/mergeContacts";
24
+ import { mergeCompanies } from "../commons/mergeCompanies";
24
25
  import type { CrmDataProvider } from "../types";
25
26
  import { authProvider, USER_STORAGE_KEY } from "./authProvider";
26
27
  import generateData from "./dataGenerator";
@@ -226,6 +227,9 @@ const dataProviderWithCustomMethod: CrmDataProvider = {
226
227
  mergeContacts: async (sourceId: Identifier, targetId: Identifier) => {
227
228
  return mergeContacts(sourceId, targetId, baseDataProvider);
228
229
  },
230
+ mergeCompanies: async (sourceId: Identifier, targetId: Identifier) => {
231
+ return mergeCompanies(sourceId, targetId, baseDataProvider);
232
+ },
229
233
  };
230
234
 
231
235
  async function updateCompany(
@@ -265,6 +265,19 @@ const dataProviderWithCustomMethods = {
265
265
  throw new Error("Failed to merge contacts");
266
266
  }
267
267
 
268
+ return data;
269
+ },
270
+ async mergeCompanies(sourceId: Identifier, targetId: Identifier) {
271
+ const { data, error } = await supabase.functions.invoke("mergeCompanies", {
272
+ method: "POST",
273
+ body: { loserId: sourceId, winnerId: targetId },
274
+ });
275
+
276
+ if (error) {
277
+ console.error("mergeCompanies.error", error);
278
+ throw new Error("Failed to merge companies");
279
+ }
280
+
268
281
  return data;
269
282
  },
270
283
  } satisfies DataProvider;
@@ -74,8 +74,31 @@ interface DealsTable {
74
74
  index: number | null;
75
75
  }
76
76
 
77
+ export interface CompaniesTable {
78
+ id: Generated<number>;
79
+ created_at: Date;
80
+ name: string;
81
+ sector: string | null;
82
+ size: number | null;
83
+ linkedin_url: string | null;
84
+ website: string | null;
85
+ phone_number: string | null;
86
+ address: string | null;
87
+ zipcode: string | null;
88
+ city: string | null;
89
+ stateAbbr: string | null;
90
+ sales_id: number | null;
91
+ context_links: unknown | null; // JSON array
92
+ country: string | null;
93
+ description: string | null;
94
+ revenue: string | null;
95
+ tax_identifier: string | null;
96
+ logo: unknown | null; // JSONB
97
+ }
98
+
77
99
  interface Database {
78
100
  contacts: ContactsTable;
101
+ companies: CompaniesTable;
79
102
  tasks: TasksTable;
80
103
  contactNotes: ContactNotesTable;
81
104
  deals: DealsTable;
@@ -41,8 +41,12 @@ Deno.serve(async (req: Request) => {
41
41
 
42
42
  let response: Response;
43
43
 
44
- if (req.method === "GET" && companyId) {
45
- response = await getCompany(apiKey, companyId);
44
+ if (req.method === "GET") {
45
+ if (companyId) {
46
+ response = await getCompany(apiKey, companyId);
47
+ } else {
48
+ response = await listCompanies(apiKey, req);
49
+ }
46
50
  } else if (req.method === "POST") {
47
51
  response = await createCompany(apiKey, req);
48
52
  } else if (req.method === "PATCH" && companyId) {
@@ -79,6 +83,42 @@ Deno.serve(async (req: Request) => {
79
83
  }
80
84
  });
81
85
 
86
+ async function listCompanies(apiKey: any, req: Request) {
87
+ if (!hasScope(apiKey, "companies:read")) {
88
+ return createErrorResponse(403, "Insufficient permissions");
89
+ }
90
+
91
+ const url = new URL(req.url);
92
+ const name = url.searchParams.get("name");
93
+ const website = url.searchParams.get("website");
94
+ const domain = url.searchParams.get("domain"); // Alias for website
95
+
96
+ let query = supabaseAdmin.from("companies").select("*");
97
+
98
+ if (name) {
99
+ query = query.ilike("name", `%${name}%`);
100
+ }
101
+
102
+ if (website || domain) {
103
+ // Exact match for website/domain
104
+ query = query.eq("website", website || domain);
105
+ }
106
+
107
+ if (!name && !website && !domain) {
108
+ query = query.limit(50);
109
+ }
110
+
111
+ const { data, error } = await query;
112
+
113
+ if (error) {
114
+ return createErrorResponse(500, error.message);
115
+ }
116
+
117
+ return new Response(JSON.stringify({ data }), {
118
+ headers: { "Content-Type": "application/json", ...corsHeaders },
119
+ });
120
+ }
121
+
82
122
  async function getCompany(apiKey: any, companyId: string) {
83
123
  if (!hasScope(apiKey, "companies:read")) {
84
124
  return createErrorResponse(403, "Insufficient permissions");
@@ -45,8 +45,12 @@ Deno.serve(async (req: Request) => {
45
45
 
46
46
  let response: Response;
47
47
 
48
- if (req.method === "GET" && contactId) {
49
- response = await getContact(apiKey, contactId);
48
+ if (req.method === "GET") {
49
+ if (contactId) {
50
+ response = await getContact(apiKey, contactId);
51
+ } else {
52
+ response = await listContacts(apiKey, req);
53
+ }
50
54
  } else if (req.method === "POST") {
51
55
  response = await createContact(apiKey, req);
52
56
  } else if (req.method === "PATCH" && contactId) {
@@ -83,6 +87,38 @@ Deno.serve(async (req: Request) => {
83
87
  }
84
88
  });
85
89
 
90
+ async function listContacts(apiKey: any, req: Request) {
91
+ if (!hasScope(apiKey, "contacts:read")) {
92
+ return createErrorResponse(403, "Insufficient permissions");
93
+ }
94
+
95
+ const url = new URL(req.url);
96
+ const email = url.searchParams.get("email");
97
+
98
+ let query = supabaseAdmin.from("contacts").select("*");
99
+
100
+ if (email) {
101
+ // Search within the email_jsonb array
102
+ // email_jsonb structure: [{ "email": "test@example.com", "type": "Work" }]
103
+ // We look for any object in the array that matches the email
104
+ query = query.contains("email_jsonb", JSON.stringify([{ email }]));
105
+ } else {
106
+ // If no specific search is provided, we default to a limit to prevent dumping the whole DB
107
+ // The apiKey owner can see all contacts, but let's limit it to 50 for safety if not searching
108
+ query = query.limit(50);
109
+ }
110
+
111
+ const { data, error } = await query;
112
+
113
+ if (error) {
114
+ return createErrorResponse(500, error.message);
115
+ }
116
+
117
+ return new Response(JSON.stringify({ data }), {
118
+ headers: { "Content-Type": "application/json", ...corsHeaders },
119
+ });
120
+ }
121
+
86
122
  async function getContact(apiKey: any, contactId: string) {
87
123
  if (!hasScope(apiKey, "contacts:read")) {
88
124
  return createErrorResponse(403, "Insufficient permissions");