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.
Files changed (38) hide show
  1. package/bin/realtimex-crm.js +56 -32
  2. package/dist/assets/{DealList-DnGVfS15.js → DealList-DbwJCRGl.js} +2 -2
  3. package/dist/assets/{DealList-DnGVfS15.js.map → DealList-DbwJCRGl.js.map} +1 -1
  4. package/dist/assets/index-C__S90Gb.css +1 -0
  5. package/dist/assets/index-mE-upBfc.js +166 -0
  6. package/dist/assets/{index-DPrpo5Xq.js.map → index-mE-upBfc.js.map} +1 -1
  7. package/dist/index.html +1 -1
  8. package/dist/stats.html +1 -1
  9. package/package.json +2 -1
  10. package/src/components/atomic-crm/activities/ActivitiesPage.tsx +16 -0
  11. package/src/components/atomic-crm/activities/ActivityFeed.tsx +212 -0
  12. package/src/components/atomic-crm/activities/FileUpload.tsx +359 -0
  13. package/src/components/atomic-crm/contacts/ContactShow.tsx +28 -10
  14. package/src/components/atomic-crm/integrations/CreateChannelDialog.tsx +139 -0
  15. package/src/components/atomic-crm/integrations/IngestionChannelsTab.tsx +188 -0
  16. package/src/components/atomic-crm/integrations/IntegrationsPage.tsx +15 -3
  17. package/supabase/fix_webhook_hardcoded.sql +34 -0
  18. package/supabase/functions/_shared/ingestionGuard.ts +128 -0
  19. package/supabase/functions/_shared/utils.ts +1 -1
  20. package/supabase/functions/ingest-activity/.well-known/supabase/config.toml +4 -0
  21. package/supabase/functions/ingest-activity/index.ts +261 -0
  22. package/supabase/migrations/20251219120100_webhook_triggers.sql +10 -5
  23. package/supabase/migrations/20251220120000_realtime_ingestion.sql +154 -0
  24. package/supabase/migrations/20251221000000_contact_matching.sql +94 -0
  25. package/supabase/migrations/20251221000001_fix_ingestion_providers_rls.sql +23 -0
  26. package/supabase/migrations/20251221000002_fix_contact_matching_jsonb.sql +67 -0
  27. package/supabase/migrations/20251221000003_fix_email_matching.sql +70 -0
  28. package/supabase/migrations/20251221000004_time_based_work_stealing.sql +99 -0
  29. package/supabase/migrations/20251221000005_realtime_functions.sql +73 -0
  30. package/supabase/migrations/20251221000006_enable_pg_net.sql +3 -0
  31. package/supabase/migrations/20251222075019_enable_extensions_and_configure_cron.sql +86 -0
  32. package/supabase/migrations/20251222094036_large_payload_storage.sql +213 -0
  33. package/supabase/migrations/20251222094247_large_payload_cron.sql +28 -0
  34. package/supabase/migrations/20251222220000_fix_large_payload_types.sql +72 -0
  35. package/supabase/migrations/20251223000000_enable_realtime_all_crm_tables.sql +50 -0
  36. package/supabase/migrations/20251223185638_remove_large_payload_storage.sql +54 -0
  37. package/dist/assets/index-DPrpo5Xq.js +0 -159
  38. 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.1",
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
- <ReferenceManyField
61
- target="contact_id"
62
- reference="contactNotes"
63
- sort={{ field: "date", order: "DESC" }}
64
- empty={
65
- <NoteCreate reference="contacts" showStatus className="mt-4" />
66
- }
67
- >
68
- <NotesIterator reference="contacts" showStatus />
69
- </ReferenceManyField>
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>