realtimex-crm 0.9.1 → 0.9.2
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/bin/realtimex-crm.js +56 -32
- package/dist/assets/{DealList-DnGVfS15.js → DealList-DbwJCRGl.js} +2 -2
- package/dist/assets/{DealList-DnGVfS15.js.map → DealList-DbwJCRGl.js.map} +1 -1
- package/dist/assets/index-C__S90Gb.css +1 -0
- package/dist/assets/index-mE-upBfc.js +166 -0
- package/dist/assets/{index-DPrpo5Xq.js.map → index-mE-upBfc.js.map} +1 -1
- package/dist/index.html +1 -1
- package/dist/stats.html +1 -1
- package/package.json +2 -1
- package/src/components/atomic-crm/activities/ActivitiesPage.tsx +16 -0
- package/src/components/atomic-crm/activities/ActivityFeed.tsx +212 -0
- package/src/components/atomic-crm/activities/FileUpload.tsx +359 -0
- package/src/components/atomic-crm/contacts/ContactShow.tsx +28 -10
- package/src/components/atomic-crm/integrations/CreateChannelDialog.tsx +139 -0
- package/src/components/atomic-crm/integrations/IngestionChannelsTab.tsx +188 -0
- package/src/components/atomic-crm/integrations/IntegrationsPage.tsx +15 -3
- package/supabase/fix_webhook_hardcoded.sql +34 -0
- package/supabase/functions/_shared/ingestionGuard.ts +128 -0
- package/supabase/functions/_shared/utils.ts +1 -1
- package/supabase/functions/ingest-activity/.well-known/supabase/config.toml +4 -0
- package/supabase/functions/ingest-activity/index.ts +261 -0
- package/supabase/migrations/20251219120100_webhook_triggers.sql +10 -5
- package/supabase/migrations/20251220120000_realtime_ingestion.sql +154 -0
- package/supabase/migrations/20251221000000_contact_matching.sql +94 -0
- package/supabase/migrations/20251221000001_fix_ingestion_providers_rls.sql +23 -0
- package/supabase/migrations/20251221000002_fix_contact_matching_jsonb.sql +67 -0
- package/supabase/migrations/20251221000003_fix_email_matching.sql +70 -0
- package/supabase/migrations/20251221000004_time_based_work_stealing.sql +99 -0
- package/supabase/migrations/20251221000005_realtime_functions.sql +73 -0
- package/supabase/migrations/20251221000006_enable_pg_net.sql +3 -0
- package/supabase/migrations/20251222075019_enable_extensions_and_configure_cron.sql +86 -0
- package/supabase/migrations/20251222094036_large_payload_storage.sql +213 -0
- package/supabase/migrations/20251222094247_large_payload_cron.sql +28 -0
- package/supabase/migrations/20251222220000_fix_large_payload_types.sql +72 -0
- package/supabase/migrations/20251223000000_enable_realtime_all_crm_tables.sql +50 -0
- package/supabase/migrations/20251223185638_remove_large_payload_storage.sql +54 -0
- package/dist/assets/index-DPrpo5Xq.js +0 -159
- package/dist/assets/index-kM1Og1AS.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "realtimex-crm",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
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",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"registry:gen": "node ./scripts/generate-registry.mjs",
|
|
69
69
|
"ghpages:deploy": "node ./scripts/ghpages-deploy.mjs",
|
|
70
70
|
"supabase:remote:init": "node ./scripts/supabase-remote-init.mjs",
|
|
71
|
+
"supabase:configure:cron": "bash ./scripts/configure-webhook-cron.sh",
|
|
71
72
|
"prepare": "husky"
|
|
72
73
|
},
|
|
73
74
|
"dependencies": {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ActivityFeed } from "./ActivityFeed";
|
|
2
|
+
|
|
3
|
+
export const ActivitiesPage = () => {
|
|
4
|
+
return (
|
|
5
|
+
<div className="container mx-auto py-6">
|
|
6
|
+
<div className="mb-6">
|
|
7
|
+
<h1 className="text-3xl font-bold">Activity Timeline</h1>
|
|
8
|
+
<p className="text-muted-foreground mt-2">
|
|
9
|
+
All activities from ingestion channels and manual entries
|
|
10
|
+
</p>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<ActivityFeed />
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
16
|
+
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { useEffect, useMemo } from "react";
|
|
2
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { supabase } from "@/components/atomic-crm/providers/supabase/supabase";
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
5
|
+
import { Badge } from "@/components/ui/badge";
|
|
6
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
7
|
+
import { Mail, Phone, MessageSquare, StickyNote, Play, Calendar, User, Clock, CheckCircle2, AlertCircle } from "lucide-react";
|
|
8
|
+
import { formatDistanceToNow } from "date-fns";
|
|
9
|
+
import { Button } from "@/components/ui/button";
|
|
10
|
+
|
|
11
|
+
interface ActivityFeedProps {
|
|
12
|
+
contactId?: number;
|
|
13
|
+
salesId?: number; // Optional: filter by agent
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const ActivityFeed = ({ contactId, salesId, className }: ActivityFeedProps) => {
|
|
18
|
+
const queryClient = useQueryClient();
|
|
19
|
+
const queryKey = useMemo(() => ["activities", contactId, salesId], [contactId, salesId]);
|
|
20
|
+
|
|
21
|
+
// 1. Initial Fetch
|
|
22
|
+
const { data: activities, isLoading } = useQuery({
|
|
23
|
+
queryKey,
|
|
24
|
+
queryFn: async () => {
|
|
25
|
+
let query = supabase
|
|
26
|
+
.from("activities")
|
|
27
|
+
.select(`
|
|
28
|
+
*,
|
|
29
|
+
sales:sales_id (first_name, last_name)
|
|
30
|
+
`)
|
|
31
|
+
.order("created_at", { ascending: false })
|
|
32
|
+
.limit(50);
|
|
33
|
+
|
|
34
|
+
if (contactId) {
|
|
35
|
+
query = query.eq("contact_id", contactId);
|
|
36
|
+
}
|
|
37
|
+
if (salesId) {
|
|
38
|
+
query = query.eq("sales_id", salesId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { data, error } = await query;
|
|
42
|
+
if (error) throw error;
|
|
43
|
+
return data;
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// 2. Realtime Subscription
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const channel = supabase
|
|
50
|
+
.channel("activities-feed")
|
|
51
|
+
.on(
|
|
52
|
+
"postgres_changes",
|
|
53
|
+
{
|
|
54
|
+
event: "*",
|
|
55
|
+
schema: "public",
|
|
56
|
+
table: "activities",
|
|
57
|
+
filter: contactId ? `contact_id=eq.${contactId}` : undefined,
|
|
58
|
+
},
|
|
59
|
+
(payload) => {
|
|
60
|
+
console.log("Realtime update:", payload);
|
|
61
|
+
// Invalidate query to refetch (simplest way to keep sync)
|
|
62
|
+
queryClient.invalidateQueries({ queryKey });
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
.subscribe();
|
|
66
|
+
|
|
67
|
+
return () => {
|
|
68
|
+
supabase.removeChannel(channel);
|
|
69
|
+
};
|
|
70
|
+
}, [contactId, queryClient, queryKey]);
|
|
71
|
+
|
|
72
|
+
if (isLoading) {
|
|
73
|
+
return (
|
|
74
|
+
<div className={`space-y-4 ${className}`}>
|
|
75
|
+
<Skeleton className="h-24 w-full" />
|
|
76
|
+
<Skeleton className="h-24 w-full" />
|
|
77
|
+
<Skeleton className="h-24 w-full" />
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!activities || activities.length === 0) {
|
|
83
|
+
return (
|
|
84
|
+
<div className={`text-center py-8 text-muted-foreground bg-muted/20 rounded-lg border border-dashed ${className}`}>
|
|
85
|
+
No activities yet.
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div className={`space-y-4 ${className}`}>
|
|
92
|
+
{activities.map((activity) => (
|
|
93
|
+
<ActivityCard key={activity.id} activity={activity} />
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const ActivityCard = ({ activity }: { activity: any }) => {
|
|
100
|
+
const isPending = activity.processing_status === "raw" || activity.processing_status === "processing";
|
|
101
|
+
const isFailed = activity.processing_status === "failed";
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<Card className={`relative overflow-hidden transition-all ${isPending ? 'border-blue-200 bg-blue-50/30 dark:bg-blue-950/10' : ''}`}>
|
|
105
|
+
{isPending && (
|
|
106
|
+
<div className="absolute top-0 left-0 right-0 h-1 bg-blue-100">
|
|
107
|
+
<div className="h-full bg-blue-500 animate-progress origin-left w-full"></div>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
|
|
111
|
+
<CardHeader className="py-3 px-4 flex flex-row items-start justify-between space-y-0">
|
|
112
|
+
<div className="flex items-center gap-3">
|
|
113
|
+
<ActivityIcon type={activity.type} />
|
|
114
|
+
<div>
|
|
115
|
+
<CardTitle className="text-base font-medium flex items-center gap-2">
|
|
116
|
+
<span className="capitalize">{activity.type}</span>
|
|
117
|
+
{activity.direction === "inbound" && (
|
|
118
|
+
<Badge variant="outline" className="text-[10px] h-5">Inbound</Badge>
|
|
119
|
+
)}
|
|
120
|
+
{activity.direction === "outbound" && (
|
|
121
|
+
<Badge variant="secondary" className="text-[10px] h-5">Outbound</Badge>
|
|
122
|
+
)}
|
|
123
|
+
</CardTitle>
|
|
124
|
+
<div className="text-xs text-muted-foreground flex items-center gap-2 mt-0.5">
|
|
125
|
+
<Clock className="h-3 w-3" />
|
|
126
|
+
{formatDistanceToNow(new Date(activity.created_at), { addSuffix: true })}
|
|
127
|
+
{activity.sales && (
|
|
128
|
+
<>
|
|
129
|
+
<span>•</span>
|
|
130
|
+
<User className="h-3 w-3" />
|
|
131
|
+
{activity.sales.first_name} {activity.sales.last_name}
|
|
132
|
+
</>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<StatusBadge status={activity.processing_status} />
|
|
139
|
+
</CardHeader>
|
|
140
|
+
|
|
141
|
+
<CardContent className="pb-4 px-4 pt-0">
|
|
142
|
+
{/* Content Body */}
|
|
143
|
+
<div className="mt-2 text-sm">
|
|
144
|
+
{isPending ? (
|
|
145
|
+
<div className="flex items-center gap-2 text-muted-foreground italic">
|
|
146
|
+
<div className="h-2 w-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
|
|
147
|
+
<div className="h-2 w-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
|
|
148
|
+
<div className="h-2 w-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
|
|
149
|
+
<span>Processing content...</span>
|
|
150
|
+
</div>
|
|
151
|
+
) : isFailed ? (
|
|
152
|
+
<p className="text-destructive">Processing failed.</p>
|
|
153
|
+
) : (
|
|
154
|
+
<div className="space-y-2">
|
|
155
|
+
{/* Transcript / Text */}
|
|
156
|
+
{activity.processed_data?.transcript ? (
|
|
157
|
+
<div className="bg-muted/50 p-3 rounded-md border text-foreground/90 whitespace-pre-wrap">
|
|
158
|
+
{activity.processed_data.transcript}
|
|
159
|
+
</div>
|
|
160
|
+
) : activity.raw_data?.source_type === "text" ? (
|
|
161
|
+
<p className="whitespace-pre-wrap">{activity.raw_data.content}</p>
|
|
162
|
+
) : null}
|
|
163
|
+
|
|
164
|
+
{/* Audio Player */}
|
|
165
|
+
{activity.raw_data?.source_type === "url" && (
|
|
166
|
+
<div className="flex items-center gap-2 bg-secondary p-2 rounded-md w-fit">
|
|
167
|
+
<Button size="icon" variant="ghost" className="h-8 w-8 rounded-full">
|
|
168
|
+
<Play className="h-4 w-4" />
|
|
169
|
+
</Button>
|
|
170
|
+
<span className="text-xs font-mono">Audio Recording</span>
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{/* Atomic Facts / Summary */}
|
|
175
|
+
{activity.processed_data?.summary && (
|
|
176
|
+
<div className="bg-yellow-50 dark:bg-yellow-950/20 p-2 rounded border border-yellow-200 dark:border-yellow-800 text-xs">
|
|
177
|
+
<span className="font-semibold text-yellow-700 dark:text-yellow-500 block mb-1">Summary:</span>
|
|
178
|
+
{activity.processed_data.summary}
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
</CardContent>
|
|
185
|
+
</Card>
|
|
186
|
+
);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const ActivityIcon = ({ type }: { type: string }) => {
|
|
190
|
+
const className = "h-5 w-5 text-muted-foreground";
|
|
191
|
+
switch (type) {
|
|
192
|
+
case "email": return <Mail className={className} />;
|
|
193
|
+
case "call": return <Phone className={className} />;
|
|
194
|
+
case "sms": return <MessageSquare className={className} />;
|
|
195
|
+
case "meeting": return <Calendar className={className} />;
|
|
196
|
+
default: return <StickyNote className={className} />;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const StatusBadge = ({ status }: { status: string }) => {
|
|
201
|
+
switch (status) {
|
|
202
|
+
case "raw":
|
|
203
|
+
case "processing":
|
|
204
|
+
return <Badge variant="outline" className="border-blue-200 text-blue-600 bg-blue-50">Processing</Badge>;
|
|
205
|
+
case "completed":
|
|
206
|
+
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
|
207
|
+
case "failed":
|
|
208
|
+
return <AlertCircle className="h-4 w-4 text-destructive" />;
|
|
209
|
+
default:
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
2
|
+
import { useDropzone } from 'react-dropzone';
|
|
3
|
+
import { useNotify, useGetIdentity, useGetList } from 'ra-core';
|
|
4
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { Progress } from '@/components/ui/progress';
|
|
7
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
8
|
+
import { Label } from '@/components/ui/label';
|
|
9
|
+
import { Upload, FileIcon, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
|
10
|
+
import { getSupabaseConfig } from '@/lib/supabase-config';
|
|
11
|
+
|
|
12
|
+
interface UploadFile {
|
|
13
|
+
file: File;
|
|
14
|
+
status: 'pending' | 'uploading' | 'success' | 'error';
|
|
15
|
+
progress: number;
|
|
16
|
+
activityId?: string;
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface IngestionProvider {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
provider_code: string;
|
|
24
|
+
ingestion_key: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const FileUpload = () => {
|
|
28
|
+
const [files, setFiles] = useState<UploadFile[]>([]);
|
|
29
|
+
const [activityType, setActivityType] = useState<string>('note');
|
|
30
|
+
const [selectedProvider, setSelectedProvider] = useState<string>('');
|
|
31
|
+
|
|
32
|
+
const notify = useNotify();
|
|
33
|
+
const { data: identity } = useGetIdentity();
|
|
34
|
+
|
|
35
|
+
// Load ingestion providers using React-Admin hook (cleaner than manual useEffect)
|
|
36
|
+
const { data: providers = [] } = useGetList<IngestionProvider>('ingestion_providers', {
|
|
37
|
+
filter: { is_active: true },
|
|
38
|
+
pagination: { page: 1, perPage: 100 },
|
|
39
|
+
sort: { field: 'created_at', order: 'DESC' }
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Auto-select first provider when data loads
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (providers.length > 0 && !selectedProvider) {
|
|
45
|
+
setSelectedProvider(providers[0].id);
|
|
46
|
+
}
|
|
47
|
+
}, [providers, selectedProvider]);
|
|
48
|
+
|
|
49
|
+
const onDrop = useCallback((acceptedFiles: File[]) => {
|
|
50
|
+
const newFiles = acceptedFiles.map(file => ({
|
|
51
|
+
file,
|
|
52
|
+
status: 'pending' as const,
|
|
53
|
+
progress: 0
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
setFiles(prev => [...prev, ...newFiles]);
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
60
|
+
onDrop,
|
|
61
|
+
// Accept all file types (removed restrictive accept prop)
|
|
62
|
+
// Only block dangerous executable files for security
|
|
63
|
+
validator: (file) => {
|
|
64
|
+
// Guard against missing file name
|
|
65
|
+
if (!file.name) {
|
|
66
|
+
return null; // Allow files without names (edge case)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const dangerous = ['.exe', '.bat', '.cmd', '.com', '.scr', '.vbs', '.ps1', '.msi'];
|
|
70
|
+
const fileName = file.name.toLowerCase();
|
|
71
|
+
const dotIndex = fileName.lastIndexOf('.');
|
|
72
|
+
|
|
73
|
+
// If no extension, allow the file
|
|
74
|
+
if (dotIndex === -1) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const ext = fileName.slice(dotIndex);
|
|
79
|
+
|
|
80
|
+
if (dangerous.includes(ext)) {
|
|
81
|
+
return {
|
|
82
|
+
code: 'dangerous-file',
|
|
83
|
+
message: 'Executable files are not allowed for security reasons'
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const uploadFile = async (index: number) => {
|
|
91
|
+
const fileToUpload = files[index];
|
|
92
|
+
const provider = providers.find(p => p.id === selectedProvider);
|
|
93
|
+
|
|
94
|
+
if (!provider) {
|
|
95
|
+
notify('Please select an ingestion channel', { type: 'error' });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const config = getSupabaseConfig();
|
|
100
|
+
const webhookUrl = `${config.url}/functions/v1/ingest-activity`;
|
|
101
|
+
|
|
102
|
+
// Update status to uploading
|
|
103
|
+
setFiles(prev => prev.map((f, i) =>
|
|
104
|
+
i === index ? { ...f, status: 'uploading', progress: 0 } : f
|
|
105
|
+
));
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const formData = new FormData();
|
|
109
|
+
formData.append('file', fileToUpload.file);
|
|
110
|
+
formData.append('type', activityType);
|
|
111
|
+
formData.append('from', identity?.email || 'manual-upload');
|
|
112
|
+
formData.append('subject', `File Upload: ${fileToUpload.file.name}`);
|
|
113
|
+
|
|
114
|
+
const xhr = new XMLHttpRequest();
|
|
115
|
+
|
|
116
|
+
// Track upload progress
|
|
117
|
+
xhr.upload.addEventListener('progress', (e) => {
|
|
118
|
+
if (e.lengthComputable) {
|
|
119
|
+
const progress = Math.round((e.loaded / e.total) * 100);
|
|
120
|
+
setFiles(prev => prev.map((f, i) =>
|
|
121
|
+
i === index ? { ...f, progress } : f
|
|
122
|
+
));
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
xhr.addEventListener('load', () => {
|
|
127
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
128
|
+
const response = JSON.parse(xhr.responseText);
|
|
129
|
+
setFiles(prev => prev.map((f, i) =>
|
|
130
|
+
i === index ? { ...f, status: 'success', progress: 100, activityId: response.id } : f
|
|
131
|
+
));
|
|
132
|
+
notify(`File uploaded: ${fileToUpload.file.name}`, { type: 'success' });
|
|
133
|
+
} else {
|
|
134
|
+
throw new Error(`Upload failed with status ${xhr.status}`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
xhr.addEventListener('error', () => {
|
|
139
|
+
setFiles(prev => prev.map((f, i) =>
|
|
140
|
+
i === index ? { ...f, status: 'error', error: 'Network error' } : f
|
|
141
|
+
));
|
|
142
|
+
notify(`Upload failed: ${fileToUpload.file.name}`, { type: 'error' });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
xhr.open('POST', webhookUrl);
|
|
146
|
+
// Security: Move ingestion key from URL to header (prevents key leakage in logs)
|
|
147
|
+
xhr.setRequestHeader('x-ingestion-key', provider.ingestion_key);
|
|
148
|
+
xhr.send(formData);
|
|
149
|
+
|
|
150
|
+
} catch (error) {
|
|
151
|
+
setFiles(prev => prev.map((f, i) =>
|
|
152
|
+
i === index ? { ...f, status: 'error', error: String(error) } : f
|
|
153
|
+
));
|
|
154
|
+
notify(`Upload failed: ${fileToUpload.file.name}`, { type: 'error' });
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const uploadAll = async () => {
|
|
159
|
+
const pendingFiles = files
|
|
160
|
+
.map((f, index) => ({ file: f, index }))
|
|
161
|
+
.filter(({ file }) => file.status === 'pending');
|
|
162
|
+
|
|
163
|
+
// Upload all files in parallel for better performance
|
|
164
|
+
// Browsers manage connection limits automatically (usually 6 concurrent)
|
|
165
|
+
await Promise.all(
|
|
166
|
+
pendingFiles.map(({ index }) => uploadFile(index))
|
|
167
|
+
);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const clearCompleted = () => {
|
|
171
|
+
setFiles(prev => prev.filter(f => f.status !== 'success'));
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const formatFileSize = (bytes: number) => {
|
|
175
|
+
if (bytes === 0) return '0 Bytes';
|
|
176
|
+
const k = 1024;
|
|
177
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
178
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
179
|
+
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<div className="p-6 max-w-4xl mx-auto space-y-6">
|
|
184
|
+
<Card>
|
|
185
|
+
<CardHeader>
|
|
186
|
+
<CardTitle>Upload Files</CardTitle>
|
|
187
|
+
<CardDescription>
|
|
188
|
+
Upload documents, images, audio, or video files to create activities.
|
|
189
|
+
Files are automatically stored and linked to your account.
|
|
190
|
+
</CardDescription>
|
|
191
|
+
</CardHeader>
|
|
192
|
+
<CardContent className="space-y-4">
|
|
193
|
+
{/* Configuration */}
|
|
194
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
195
|
+
<div className="space-y-2">
|
|
196
|
+
<Label htmlFor="provider">Ingestion Channel</Label>
|
|
197
|
+
<Select value={selectedProvider} onValueChange={setSelectedProvider}>
|
|
198
|
+
<SelectTrigger id="provider">
|
|
199
|
+
<SelectValue placeholder="Select channel..." />
|
|
200
|
+
</SelectTrigger>
|
|
201
|
+
<SelectContent>
|
|
202
|
+
{providers.map(provider => (
|
|
203
|
+
<SelectItem key={provider.id} value={provider.id}>
|
|
204
|
+
{provider.name} ({provider.provider_code})
|
|
205
|
+
</SelectItem>
|
|
206
|
+
))}
|
|
207
|
+
</SelectContent>
|
|
208
|
+
</Select>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<div className="space-y-2">
|
|
212
|
+
<Label htmlFor="type">Activity Type</Label>
|
|
213
|
+
<Select value={activityType} onValueChange={setActivityType}>
|
|
214
|
+
<SelectTrigger id="type">
|
|
215
|
+
<SelectValue />
|
|
216
|
+
</SelectTrigger>
|
|
217
|
+
<SelectContent>
|
|
218
|
+
<SelectItem value="note">Note</SelectItem>
|
|
219
|
+
<SelectItem value="email">Email</SelectItem>
|
|
220
|
+
<SelectItem value="call">Call Recording</SelectItem>
|
|
221
|
+
<SelectItem value="meeting">Meeting Recording</SelectItem>
|
|
222
|
+
<SelectItem value="other">Other</SelectItem>
|
|
223
|
+
</SelectContent>
|
|
224
|
+
</Select>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
{/* Dropzone */}
|
|
229
|
+
<div
|
|
230
|
+
{...getRootProps()}
|
|
231
|
+
className={`
|
|
232
|
+
border-2 border-dashed rounded-lg p-12 text-center cursor-pointer
|
|
233
|
+
transition-all duration-200
|
|
234
|
+
${isDragActive
|
|
235
|
+
? 'border-green-500 bg-green-50 scale-[1.02] shadow-lg'
|
|
236
|
+
: 'border-gray-300 hover:border-primary/50 hover:bg-gray-50'
|
|
237
|
+
}
|
|
238
|
+
`}
|
|
239
|
+
>
|
|
240
|
+
<input {...getInputProps()} />
|
|
241
|
+
<Upload className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
|
242
|
+
{isDragActive ? (
|
|
243
|
+
<p className="text-lg font-medium">Drop files here...</p>
|
|
244
|
+
) : (
|
|
245
|
+
<>
|
|
246
|
+
<p className="text-lg font-medium mb-2">
|
|
247
|
+
Drag & drop files here, or click to select
|
|
248
|
+
</p>
|
|
249
|
+
<p className="text-sm text-gray-500">
|
|
250
|
+
Supports all file types (executables blocked for security)
|
|
251
|
+
</p>
|
|
252
|
+
</>
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
{/* File List */}
|
|
257
|
+
{files.length > 0 && (
|
|
258
|
+
<div className="space-y-2">
|
|
259
|
+
<div className="flex items-center justify-between">
|
|
260
|
+
<h3 className="font-medium">Files ({files.length})</h3>
|
|
261
|
+
<div className="space-x-2">
|
|
262
|
+
<Button
|
|
263
|
+
size="sm"
|
|
264
|
+
onClick={uploadAll}
|
|
265
|
+
disabled={!files.some(f => f.status === 'pending')}
|
|
266
|
+
>
|
|
267
|
+
Upload All
|
|
268
|
+
</Button>
|
|
269
|
+
<Button
|
|
270
|
+
size="sm"
|
|
271
|
+
variant="outline"
|
|
272
|
+
onClick={clearCompleted}
|
|
273
|
+
disabled={!files.some(f => f.status === 'success')}
|
|
274
|
+
>
|
|
275
|
+
Clear Completed
|
|
276
|
+
</Button>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
<div className="space-y-2">
|
|
281
|
+
{files.map((fileItem, index) => (
|
|
282
|
+
<div
|
|
283
|
+
key={index}
|
|
284
|
+
className="border rounded-lg p-4 space-y-2"
|
|
285
|
+
>
|
|
286
|
+
<div className="flex items-start justify-between gap-4">
|
|
287
|
+
<div className="flex items-start gap-3 flex-1 min-w-0">
|
|
288
|
+
<FileIcon className="w-5 h-5 mt-0.5 flex-shrink-0 text-gray-400" />
|
|
289
|
+
<div className="flex-1 min-w-0">
|
|
290
|
+
<p className="font-medium truncate">
|
|
291
|
+
{fileItem.file.name}
|
|
292
|
+
</p>
|
|
293
|
+
<p className="text-sm text-gray-500">
|
|
294
|
+
{formatFileSize(fileItem.file.size)}
|
|
295
|
+
{fileItem.activityId && (
|
|
296
|
+
<span className="ml-2 text-xs">
|
|
297
|
+
ID: {fileItem.activityId.substring(0, 8)}...
|
|
298
|
+
</span>
|
|
299
|
+
)}
|
|
300
|
+
</p>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
<div className="flex items-center gap-2 flex-shrink-0">
|
|
305
|
+
{fileItem.status === 'pending' && (
|
|
306
|
+
<Button
|
|
307
|
+
size="sm"
|
|
308
|
+
onClick={() => uploadFile(index)}
|
|
309
|
+
>
|
|
310
|
+
Upload
|
|
311
|
+
</Button>
|
|
312
|
+
)}
|
|
313
|
+
{fileItem.status === 'uploading' && (
|
|
314
|
+
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
|
315
|
+
)}
|
|
316
|
+
{fileItem.status === 'success' && (
|
|
317
|
+
<CheckCircle className="w-5 h-5 text-green-500" />
|
|
318
|
+
)}
|
|
319
|
+
{fileItem.status === 'error' && (
|
|
320
|
+
<XCircle className="w-5 h-5 text-red-500" />
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
|
|
325
|
+
{/* Progress bar */}
|
|
326
|
+
{fileItem.status === 'uploading' && (
|
|
327
|
+
<Progress value={fileItem.progress} className="h-1" />
|
|
328
|
+
)}
|
|
329
|
+
|
|
330
|
+
{/* Error message */}
|
|
331
|
+
{fileItem.status === 'error' && fileItem.error && (
|
|
332
|
+
<p className="text-sm text-red-500">
|
|
333
|
+
Error: {fileItem.error}
|
|
334
|
+
</p>
|
|
335
|
+
)}
|
|
336
|
+
</div>
|
|
337
|
+
))}
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
)}
|
|
341
|
+
</CardContent>
|
|
342
|
+
</Card>
|
|
343
|
+
|
|
344
|
+
{/* Info Card */}
|
|
345
|
+
<Card>
|
|
346
|
+
<CardHeader>
|
|
347
|
+
<CardTitle className="text-base">How it works</CardTitle>
|
|
348
|
+
</CardHeader>
|
|
349
|
+
<CardContent className="text-sm text-gray-600 space-y-2">
|
|
350
|
+
<p>• Files are uploaded directly to secure storage (no database bloat)</p>
|
|
351
|
+
<p>• Each file creates an activity record for tracking and search</p>
|
|
352
|
+
<p>• Large files are handled automatically (no size limits)</p>
|
|
353
|
+
<p>• Files are linked to your selected ingestion channel</p>
|
|
354
|
+
<p>• Activities appear in the Activity Feed immediately</p>
|
|
355
|
+
</CardContent>
|
|
356
|
+
</Card>
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
359
|
+
};
|
|
@@ -9,6 +9,7 @@ import { NoteCreate, NotesIterator } from "../notes";
|
|
|
9
9
|
import type { Contact } from "../types";
|
|
10
10
|
import { Avatar } from "./Avatar";
|
|
11
11
|
import { ContactAside } from "./ContactAside";
|
|
12
|
+
import { ActivityFeed } from "../activities/ActivityFeed";
|
|
12
13
|
|
|
13
14
|
export const ContactShow = () => (
|
|
14
15
|
<ShowBase>
|
|
@@ -57,16 +58,33 @@ const ContactShowContent = () => {
|
|
|
57
58
|
</ReferenceField>
|
|
58
59
|
</div>
|
|
59
60
|
</div>
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
61
|
+
|
|
62
|
+
{/* Activity Timeline - Shows processing status (temporary) */}
|
|
63
|
+
<div className="mt-6">
|
|
64
|
+
<h3 className="text-lg font-semibold mb-4">Activity Timeline</h3>
|
|
65
|
+
<p className="text-sm text-muted-foreground mb-3">
|
|
66
|
+
Real-time processing status of incoming activities
|
|
67
|
+
</p>
|
|
68
|
+
<ActivityFeed contactId={record.id as number} />
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
{/* Notes - Permanent record of outcomes */}
|
|
72
|
+
<div className="mt-8">
|
|
73
|
+
<h3 className="text-lg font-semibold mb-4">Notes</h3>
|
|
74
|
+
<p className="text-sm text-muted-foreground mb-3">
|
|
75
|
+
Permanent record of interactions and outcomes
|
|
76
|
+
</p>
|
|
77
|
+
<ReferenceManyField
|
|
78
|
+
target="contact_id"
|
|
79
|
+
reference="contactNotes"
|
|
80
|
+
sort={{ field: "date", order: "DESC" }}
|
|
81
|
+
empty={
|
|
82
|
+
<NoteCreate reference="contacts" showStatus className="mt-4" />
|
|
83
|
+
}
|
|
84
|
+
>
|
|
85
|
+
<NotesIterator reference="contacts" showStatus />
|
|
86
|
+
</ReferenceManyField>
|
|
87
|
+
</div>
|
|
70
88
|
</CardContent>
|
|
71
89
|
</Card>
|
|
72
90
|
</div>
|