@ebowwa/crm 0.1.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/README.md +174 -0
- package/dist/cli/commands/activities.d.ts +11 -0
- package/dist/cli/commands/activities.d.ts.map +1 -0
- package/dist/cli/commands/activities.js +427 -0
- package/dist/cli/commands/activities.js.map +1 -0
- package/dist/cli/commands/contacts.d.ts +11 -0
- package/dist/cli/commands/contacts.d.ts.map +1 -0
- package/dist/cli/commands/contacts.js +458 -0
- package/dist/cli/commands/contacts.js.map +1 -0
- package/dist/cli/commands/deals.d.ts +11 -0
- package/dist/cli/commands/deals.d.ts.map +1 -0
- package/dist/cli/commands/deals.js +498 -0
- package/dist/cli/commands/deals.js.map +1 -0
- package/dist/cli/commands/media.d.ts +11 -0
- package/dist/cli/commands/media.d.ts.map +1 -0
- package/dist/cli/commands/media.js +417 -0
- package/dist/cli/commands/media.js.map +1 -0
- package/dist/cli/commands/search.d.ts +11 -0
- package/dist/cli/commands/search.d.ts.map +1 -0
- package/dist/cli/commands/search.js +346 -0
- package/dist/cli/commands/search.js.map +1 -0
- package/dist/cli/index.d.ts +13 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +173 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/repl.d.ts +15 -0
- package/dist/cli/repl.d.ts.map +1 -0
- package/dist/cli/repl.js +318 -0
- package/dist/cli/repl.js.map +1 -0
- package/dist/cli/utils/config.d.ts +91 -0
- package/dist/cli/utils/config.d.ts.map +1 -0
- package/dist/cli/utils/config.js +212 -0
- package/dist/cli/utils/config.js.map +1 -0
- package/dist/cli/utils/output.d.ts +136 -0
- package/dist/cli/utils/output.d.ts.map +1 -0
- package/dist/cli/utils/output.js +323 -0
- package/dist/cli/utils/output.js.map +1 -0
- package/dist/cli/utils/prompt.d.ts +81 -0
- package/dist/cli/utils/prompt.d.ts.map +1 -0
- package/dist/cli/utils/prompt.js +341 -0
- package/dist/cli/utils/prompt.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +8 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/index.d.ts +6 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +32 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/schemas.d.ts +3050 -0
- package/dist/core/schemas.d.ts.map +1 -0
- package/dist/core/schemas.js +667 -0
- package/dist/core/schemas.js.map +1 -0
- package/dist/core/types.d.ts +597 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +8 -0
- package/dist/core/types.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/index.d.ts +14 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +11 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/server.d.ts +13 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +18 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/storage/client.d.ts +109 -0
- package/dist/mcp/storage/client.d.ts.map +1 -0
- package/dist/mcp/storage/client.js +355 -0
- package/dist/mcp/storage/client.js.map +1 -0
- package/dist/mcp/storage/index.d.ts +7 -0
- package/dist/mcp/storage/index.d.ts.map +1 -0
- package/dist/mcp/storage/index.js +6 -0
- package/dist/mcp/storage/index.js.map +1 -0
- package/dist/mcp/storage/types.d.ts +44 -0
- package/dist/mcp/storage/types.d.ts.map +1 -0
- package/dist/mcp/storage/types.js +35 -0
- package/dist/mcp/storage/types.js.map +1 -0
- package/dist/mcp/tools/definitions.d.ts +16 -0
- package/dist/mcp/tools/definitions.d.ts.map +1 -0
- package/dist/mcp/tools/definitions.js +914 -0
- package/dist/mcp/tools/definitions.js.map +1 -0
- package/dist/mcp/tools/handlers.d.ts +50 -0
- package/dist/mcp/tools/handlers.d.ts.map +1 -0
- package/dist/mcp/tools/handlers.js +760 -0
- package/dist/mcp/tools/handlers.js.map +1 -0
- package/dist/mcp/tools/index.d.ts +7 -0
- package/dist/mcp/tools/index.d.ts.map +1 -0
- package/dist/mcp/tools/index.js +6 -0
- package/dist/mcp/tools/index.js.map +1 -0
- package/dist/mcp/tools/types.d.ts +314 -0
- package/dist/mcp/tools/types.d.ts.map +1 -0
- package/dist/mcp/tools/types.js +5 -0
- package/dist/mcp/tools/types.js.map +1 -0
- package/dist/mcp/transports/stdio.d.ts +27 -0
- package/dist/mcp/transports/stdio.d.ts.map +1 -0
- package/dist/mcp/transports/stdio.js +237 -0
- package/dist/mcp/transports/stdio.js.map +1 -0
- package/dist/telemetry/index.d.ts +58 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/index.js +109 -0
- package/dist/telemetry/index.js.map +1 -0
- package/dist/telemetry/logger.d.ts +116 -0
- package/dist/telemetry/logger.d.ts.map +1 -0
- package/dist/telemetry/logger.js +256 -0
- package/dist/telemetry/logger.js.map +1 -0
- package/dist/telemetry/metrics.d.ts +115 -0
- package/dist/telemetry/metrics.d.ts.map +1 -0
- package/dist/telemetry/metrics.js +292 -0
- package/dist/telemetry/metrics.js.map +1 -0
- package/dist/telemetry/tracer.d.ts +227 -0
- package/dist/telemetry/tracer.d.ts.map +1 -0
- package/dist/telemetry/tracer.js +355 -0
- package/dist/telemetry/tracer.js.map +1 -0
- package/dist/web/app.d.ts +2 -0
- package/dist/web/app.d.ts.map +1 -0
- package/dist/web/app.js +115 -0
- package/dist/web/app.js.map +1 -0
- package/dist/web/components/ContactList.d.ts +3 -0
- package/dist/web/components/ContactList.d.ts.map +1 -0
- package/dist/web/components/ContactList.js +262 -0
- package/dist/web/components/ContactList.js.map +1 -0
- package/dist/web/components/Dashboard.d.ts +3 -0
- package/dist/web/components/Dashboard.d.ts.map +1 -0
- package/dist/web/components/Dashboard.js +158 -0
- package/dist/web/components/Dashboard.js.map +1 -0
- package/dist/web/components/DealPipeline.d.ts +3 -0
- package/dist/web/components/DealPipeline.d.ts.map +1 -0
- package/dist/web/components/DealPipeline.js +306 -0
- package/dist/web/components/DealPipeline.js.map +1 -0
- package/dist/web/index.d.ts +2 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +269 -0
- package/dist/web/index.js.map +1 -0
- package/dist/web/types.d.ts +75 -0
- package/dist/web/types.d.ts.map +1 -0
- package/dist/web/types.js +3 -0
- package/dist/web/types.js.map +1 -0
- package/native/index.d.ts +571 -0
- package/native/index.js +687 -0
- package/package.json +105 -0
- package/src/cli/commands/activities.ts +543 -0
- package/src/cli/commands/contacts.ts +563 -0
- package/src/cli/commands/deals.ts +637 -0
- package/src/cli/commands/media.ts +521 -0
- package/src/cli/commands/search.ts +426 -0
- package/src/cli/index.ts +203 -0
- package/src/cli/repl.ts +379 -0
- package/src/cli/utils/config.ts +299 -0
- package/src/cli/utils/output.ts +386 -0
- package/src/cli/utils/prompt.ts +444 -0
- package/src/cli.ts +11 -0
- package/src/core/index.ts +184 -0
- package/src/core/schemas.ts +770 -0
- package/src/core/types.ts +969 -0
- package/src/index.ts +8 -0
- package/src/mcp/index.ts +17 -0
- package/src/mcp/server.ts +26 -0
- package/src/mcp/storage/client.ts +408 -0
- package/src/mcp/storage/index.ts +7 -0
- package/src/mcp/storage/types.ts +72 -0
- package/src/mcp/tools/definitions.ts +961 -0
- package/src/mcp/tools/handlers.ts +805 -0
- package/src/mcp/tools/index.ts +7 -0
- package/src/mcp/tools/types.ts +390 -0
- package/src/mcp/transports/stdio.ts +225 -0
- package/src/telemetry/index.ts +131 -0
- package/src/telemetry/logger.ts +318 -0
- package/src/telemetry/metrics.ts +393 -0
- package/src/telemetry/tracer.ts +487 -0
- package/src/web/api/activities.ts +41 -0
- package/src/web/api/contacts.ts +114 -0
- package/src/web/api/deals.ts +108 -0
- package/src/web/api/media.ts +98 -0
- package/src/web/app.tsx +143 -0
- package/src/web/components/ActivityFeed.tsx +195 -0
- package/src/web/components/ContactList.tsx +340 -0
- package/src/web/components/Dashboard.tsx +214 -0
- package/src/web/components/DealPipeline.tsx +405 -0
- package/src/web/components/MediaGallery.tsx +334 -0
- package/src/web/index.html +14 -0
- package/src/web/index.ts +326 -0
- package/src/web/styles/main.css +180 -0
- package/src/web/types.ts +311 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import React, { useState, useRef } from 'react';
|
|
2
|
+
import type { Media, APIResponse } from '../types';
|
|
3
|
+
|
|
4
|
+
const mediaTypeIcons: Record<string, string> = {
|
|
5
|
+
'image': '🖼️',
|
|
6
|
+
'video': '🎬',
|
|
7
|
+
'audio': '🎵',
|
|
8
|
+
'application': '📄',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const getMediaType = (mimetype: string): string => {
|
|
12
|
+
return mimetype.split('/')[0];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const formatFileSize = (bytes: number): string => {
|
|
16
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
17
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
18
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
19
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default function MediaGallery() {
|
|
23
|
+
const [mediaFiles, setMediaFiles] = useState<Media[]>([]);
|
|
24
|
+
const [loading, setLoading] = useState(false);
|
|
25
|
+
const [uploading, setUploading] = useState(false);
|
|
26
|
+
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
|
27
|
+
const [filter, setFilter] = useState<string>('all');
|
|
28
|
+
const [dragOver, setDragOver] = useState(false);
|
|
29
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
30
|
+
|
|
31
|
+
const fetchMedia = async () => {
|
|
32
|
+
setLoading(true);
|
|
33
|
+
try {
|
|
34
|
+
// In a real implementation, this would fetch from /api/media
|
|
35
|
+
// For now, we'll use mock data
|
|
36
|
+
setMediaFiles([]);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error('Failed to fetch media:', error);
|
|
39
|
+
} finally {
|
|
40
|
+
setLoading(false);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const handleFileSelect = async (files: FileList | null) => {
|
|
45
|
+
if (!files || files.length === 0) return;
|
|
46
|
+
|
|
47
|
+
setUploading(true);
|
|
48
|
+
try {
|
|
49
|
+
const formData = new FormData();
|
|
50
|
+
formData.append('file', files[0]);
|
|
51
|
+
|
|
52
|
+
const response = await fetch('/api/media', {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
body: formData,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const data: APIResponse<Media> = await response.json();
|
|
58
|
+
|
|
59
|
+
if (data.success && data.data) {
|
|
60
|
+
setMediaFiles([data.data, ...mediaFiles]);
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error('Failed to upload media:', error);
|
|
64
|
+
} finally {
|
|
65
|
+
setUploading(false);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const handleDrop = (e: React.DragEvent) => {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
setDragOver(false);
|
|
72
|
+
handleFileSelect(e.dataTransfer.files);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const handleDragOver = (e: React.DragEvent) => {
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
setDragOver(true);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const handleDragLeave = () => {
|
|
81
|
+
setDragOver(false);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const filteredMedia = filter === 'all'
|
|
85
|
+
? mediaFiles
|
|
86
|
+
: mediaFiles.filter(m => getMediaType(m.mimetype) === filter);
|
|
87
|
+
|
|
88
|
+
const renderMediaPreview = (media: Media) => {
|
|
89
|
+
const type = getMediaType(media.mimetype);
|
|
90
|
+
|
|
91
|
+
if (type === 'image') {
|
|
92
|
+
return (
|
|
93
|
+
<img
|
|
94
|
+
src={media.url}
|
|
95
|
+
alt={media.filename}
|
|
96
|
+
className="w-full h-full object-cover"
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (type === 'video') {
|
|
102
|
+
return (
|
|
103
|
+
<div className="w-full h-full flex items-center justify-center bg-gray-900">
|
|
104
|
+
<video
|
|
105
|
+
src={media.url}
|
|
106
|
+
className="max-w-full max-h-full"
|
|
107
|
+
controls={false}
|
|
108
|
+
/>
|
|
109
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
110
|
+
<span className="text-4xl">{mediaTypeIcons.video}</span>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (type === 'audio') {
|
|
117
|
+
return (
|
|
118
|
+
<div className="w-full h-full flex items-center justify-center bg-gray-900">
|
|
119
|
+
<span className="text-4xl">{mediaTypeIcons.audio}</span>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className="w-full h-full flex items-center justify-center bg-gray-900">
|
|
126
|
+
<span className="text-4xl">{mediaTypeIcons.application}</span>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div className="space-y-6">
|
|
133
|
+
{/* Header */}
|
|
134
|
+
<div className="flex items-center justify-between">
|
|
135
|
+
<div>
|
|
136
|
+
<h2 className="text-2xl font-bold">Media Gallery</h2>
|
|
137
|
+
<p className="text-gray-400">{mediaFiles.length} files</p>
|
|
138
|
+
</div>
|
|
139
|
+
<button
|
|
140
|
+
onClick={() => fileInputRef.current?.click()}
|
|
141
|
+
className="crm-btn crm-btn-primary"
|
|
142
|
+
>
|
|
143
|
+
+ Upload
|
|
144
|
+
</button>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{/* Upload Area */}
|
|
148
|
+
<div
|
|
149
|
+
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
|
|
150
|
+
dragOver
|
|
151
|
+
? 'border-indigo-500 bg-indigo-500/10'
|
|
152
|
+
: 'border-gray-700 bg-gray-800/50'
|
|
153
|
+
}`}
|
|
154
|
+
onDrop={handleDrop}
|
|
155
|
+
onDragOver={handleDragOver}
|
|
156
|
+
onDragLeave={handleDragLeave}
|
|
157
|
+
onClick={() => fileInputRef.current?.click()}
|
|
158
|
+
>
|
|
159
|
+
<input
|
|
160
|
+
ref={fileInputRef}
|
|
161
|
+
type="file"
|
|
162
|
+
className="hidden"
|
|
163
|
+
onChange={(e) => handleFileSelect(e.target.files)}
|
|
164
|
+
accept="image/*,video/*,audio/*,.pdf,.doc,.docx"
|
|
165
|
+
/>
|
|
166
|
+
|
|
167
|
+
{uploading ? (
|
|
168
|
+
<div className="flex items-center justify-center gap-3">
|
|
169
|
+
<div className="crm-spinner" />
|
|
170
|
+
<span>Uploading...</span>
|
|
171
|
+
</div>
|
|
172
|
+
) : (
|
|
173
|
+
<>
|
|
174
|
+
<div className="text-4xl mb-4">📁</div>
|
|
175
|
+
<p className="text-lg font-medium mb-2">Drop files here or click to upload</p>
|
|
176
|
+
<p className="text-sm text-gray-400">
|
|
177
|
+
Supports images, videos, audio, and documents
|
|
178
|
+
</p>
|
|
179
|
+
</>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
{/* Filters */}
|
|
184
|
+
<div className="flex gap-2">
|
|
185
|
+
{['all', 'image', 'video', 'audio', 'application'].map(type => (
|
|
186
|
+
<button
|
|
187
|
+
key={type}
|
|
188
|
+
onClick={() => setFilter(type)}
|
|
189
|
+
className={`px-4 py-2 rounded-lg text-sm transition-colors ${
|
|
190
|
+
filter === type
|
|
191
|
+
? 'bg-indigo-600 text-white'
|
|
192
|
+
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
|
193
|
+
}`}
|
|
194
|
+
>
|
|
195
|
+
{type === 'all' ? 'All' : (
|
|
196
|
+
<span className="flex items-center gap-2">
|
|
197
|
+
<span>{mediaTypeIcons[type] || '📄'}</span>
|
|
198
|
+
<span className="capitalize">{type}</span>
|
|
199
|
+
</span>
|
|
200
|
+
)}
|
|
201
|
+
</button>
|
|
202
|
+
))}
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{/* Media Grid */}
|
|
206
|
+
{loading ? (
|
|
207
|
+
<div className="flex items-center justify-center h-64">
|
|
208
|
+
<div className="crm-spinner" />
|
|
209
|
+
</div>
|
|
210
|
+
) : filteredMedia.length === 0 ? (
|
|
211
|
+
<div className="text-center py-12 text-gray-400">
|
|
212
|
+
<div className="text-6xl mb-4">📭</div>
|
|
213
|
+
<p className="text-lg">No media files</p>
|
|
214
|
+
<p className="text-sm mt-2">Upload files to see them here</p>
|
|
215
|
+
</div>
|
|
216
|
+
) : (
|
|
217
|
+
<div className="media-grid">
|
|
218
|
+
{filteredMedia.map(media => (
|
|
219
|
+
<div
|
|
220
|
+
key={media.id}
|
|
221
|
+
onClick={() => setSelectedMedia(media)}
|
|
222
|
+
className="media-item aspect-square overflow-hidden relative group"
|
|
223
|
+
>
|
|
224
|
+
{renderMediaPreview(media)}
|
|
225
|
+
|
|
226
|
+
{/* Overlay */}
|
|
227
|
+
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
|
|
228
|
+
<div className="absolute bottom-0 left-0 right-0 p-3">
|
|
229
|
+
<p className="text-sm font-medium truncate">{media.filename}</p>
|
|
230
|
+
<p className="text-xs text-gray-400">{formatFileSize(media.size)}</p>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
))}
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
{/* Preview Modal */}
|
|
239
|
+
{selectedMedia && (
|
|
240
|
+
<div
|
|
241
|
+
className="crm-modal-backdrop"
|
|
242
|
+
onClick={() => setSelectedMedia(null)}
|
|
243
|
+
>
|
|
244
|
+
<div
|
|
245
|
+
className="bg-gray-800 rounded-xl border border-gray-700 max-w-4xl w-full mx-4 overflow-hidden"
|
|
246
|
+
onClick={(e) => e.stopPropagation()}
|
|
247
|
+
>
|
|
248
|
+
{/* Modal Header */}
|
|
249
|
+
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
|
250
|
+
<div>
|
|
251
|
+
<h3 className="font-medium">{selectedMedia.filename}</h3>
|
|
252
|
+
<p className="text-sm text-gray-400">
|
|
253
|
+
{formatFileSize(selectedMedia.size)} • {selectedMedia.mimetype}
|
|
254
|
+
</p>
|
|
255
|
+
</div>
|
|
256
|
+
<button
|
|
257
|
+
onClick={() => setSelectedMedia(null)}
|
|
258
|
+
className="text-gray-400 hover:text-white transition-colors"
|
|
259
|
+
>
|
|
260
|
+
✕
|
|
261
|
+
</button>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
{/* Modal Content */}
|
|
265
|
+
<div className="p-4">
|
|
266
|
+
{getMediaType(selectedMedia.mimetype) === 'image' && (
|
|
267
|
+
<img
|
|
268
|
+
src={selectedMedia.url}
|
|
269
|
+
alt={selectedMedia.filename}
|
|
270
|
+
className="max-w-full max-h-[60vh] mx-auto rounded-lg"
|
|
271
|
+
/>
|
|
272
|
+
)}
|
|
273
|
+
|
|
274
|
+
{getMediaType(selectedMedia.mimetype) === 'video' && (
|
|
275
|
+
<video
|
|
276
|
+
src={selectedMedia.url}
|
|
277
|
+
controls
|
|
278
|
+
className="max-w-full max-h-[60vh] mx-auto rounded-lg"
|
|
279
|
+
/>
|
|
280
|
+
)}
|
|
281
|
+
|
|
282
|
+
{getMediaType(selectedMedia.mimetype) === 'audio' && (
|
|
283
|
+
<audio
|
|
284
|
+
src={selectedMedia.url}
|
|
285
|
+
controls
|
|
286
|
+
className="w-full"
|
|
287
|
+
/>
|
|
288
|
+
)}
|
|
289
|
+
|
|
290
|
+
{getMediaType(selectedMedia.mimetype) === 'application' && (
|
|
291
|
+
<div className="text-center py-12">
|
|
292
|
+
<div className="text-6xl mb-4">{mediaTypeIcons.application}</div>
|
|
293
|
+
<p className="text-lg">{selectedMedia.filename}</p>
|
|
294
|
+
<a
|
|
295
|
+
href={selectedMedia.url}
|
|
296
|
+
download
|
|
297
|
+
className="crm-btn crm-btn-primary inline-block mt-4"
|
|
298
|
+
>
|
|
299
|
+
Download
|
|
300
|
+
</a>
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
{/* Modal Footer */}
|
|
306
|
+
<div className="flex items-center justify-between p-4 border-t border-gray-700 bg-gray-800/50">
|
|
307
|
+
<div className="text-sm text-gray-400">
|
|
308
|
+
Uploaded: {new Date(selectedMedia.uploadedAt).toLocaleString()}
|
|
309
|
+
</div>
|
|
310
|
+
<div className="flex gap-2">
|
|
311
|
+
<a
|
|
312
|
+
href={selectedMedia.url}
|
|
313
|
+
download
|
|
314
|
+
className="crm-btn crm-btn-secondary"
|
|
315
|
+
>
|
|
316
|
+
Download
|
|
317
|
+
</a>
|
|
318
|
+
<button
|
|
319
|
+
onClick={() => {
|
|
320
|
+
// Delete logic would go here
|
|
321
|
+
setSelectedMedia(null);
|
|
322
|
+
}}
|
|
323
|
+
className="crm-btn crm-btn-danger"
|
|
324
|
+
>
|
|
325
|
+
Delete
|
|
326
|
+
</button>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
)}
|
|
332
|
+
</div>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>CRM Dashboard</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
<link rel="stylesheet" href="./styles/main.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body class="bg-gray-900 text-gray-100 min-h-screen">
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
<script type="module" src="./app.tsx"></script>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
package/src/web/index.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import index from "./index.html";
|
|
2
|
+
import type { ServerWebSocket } from "bun";
|
|
3
|
+
import type { Contact, Deal, Activity, Media, WSMessage } from "./types";
|
|
4
|
+
import { CRMStorageClient } from "../mcp/storage/client.js";
|
|
5
|
+
|
|
6
|
+
// Persistent storage client
|
|
7
|
+
let storage: CRMStorageClient | null = null;
|
|
8
|
+
|
|
9
|
+
// Connected WebSocket clients
|
|
10
|
+
const connectedClients: Set<ServerWebSocket> = new Set();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Initialize storage and return when ready
|
|
14
|
+
*/
|
|
15
|
+
async function getStorage(): Promise<CRMStorageClient> {
|
|
16
|
+
if (!storage) {
|
|
17
|
+
storage = new CRMStorageClient({
|
|
18
|
+
path: process.env.CRM_DB_PATH || "./data/crm-web",
|
|
19
|
+
mapSize: 512 * 1024 * 1024, // 512MB
|
|
20
|
+
maxDbs: 20,
|
|
21
|
+
});
|
|
22
|
+
await storage.initialize();
|
|
23
|
+
console.log("CRM storage initialized at", process.env.CRM_DB_PATH || "./data/crm-web");
|
|
24
|
+
}
|
|
25
|
+
return storage;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// WebSocket message broadcasting
|
|
29
|
+
function broadcast(message: WSMessage) {
|
|
30
|
+
const payload = JSON.stringify({ ...message, timestamp: new Date().toISOString() });
|
|
31
|
+
for (const client of connectedClients) {
|
|
32
|
+
client.send(payload);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Helper to generate IDs
|
|
37
|
+
function generateId(): string {
|
|
38
|
+
return crypto.randomUUID();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// API Handlers
|
|
42
|
+
async function listContacts(request: Request): Promise<Response> {
|
|
43
|
+
const url = new URL(request.url);
|
|
44
|
+
const search = url.searchParams.get('search') || '';
|
|
45
|
+
const status = url.searchParams.get('status') || '';
|
|
46
|
+
|
|
47
|
+
const store = await getStorage();
|
|
48
|
+
let results = store.list('contacts');
|
|
49
|
+
|
|
50
|
+
if (search) {
|
|
51
|
+
const searchLower = search.toLowerCase();
|
|
52
|
+
results = results.filter((c: { name?: string; email?: string; company?: string }) =>
|
|
53
|
+
(c.name?.toLowerCase() ?? '').includes(searchLower) ||
|
|
54
|
+
(c.email?.toLowerCase() ?? '').includes(searchLower) ||
|
|
55
|
+
(c.company?.toLowerCase() ?? '').includes(searchLower)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (status) {
|
|
60
|
+
results = results.filter((c: { status?: string }) => c.status === status);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return Response.json({ success: true, data: results });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function createContact(request: Request): Promise<Response> {
|
|
67
|
+
const body = await request.json();
|
|
68
|
+
const store = await getStorage();
|
|
69
|
+
|
|
70
|
+
const contact = await store.insert('contacts', {
|
|
71
|
+
name: body.name,
|
|
72
|
+
email: body.email,
|
|
73
|
+
phone: body.phone,
|
|
74
|
+
company: body.company,
|
|
75
|
+
title: body.title,
|
|
76
|
+
status: body.status || 'lead',
|
|
77
|
+
tags: body.tags || [],
|
|
78
|
+
notes: body.notes,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Create activity
|
|
82
|
+
await store.insert('activities', {
|
|
83
|
+
type: 'contact',
|
|
84
|
+
description: `New contact created: ${contact.name}`,
|
|
85
|
+
contactId: contact.id,
|
|
86
|
+
userId: 'system',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
broadcast({ type: 'contact_update', payload: contact });
|
|
90
|
+
|
|
91
|
+
return Response.json({ success: true, data: contact });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function getContact(request: Request, params: Record<string, string>): Promise<Response> {
|
|
95
|
+
const store = await getStorage();
|
|
96
|
+
const contact = store.get('contacts', params.id);
|
|
97
|
+
if (!contact) {
|
|
98
|
+
return Response.json({ success: false, error: 'Contact not found' }, { status: 404 });
|
|
99
|
+
}
|
|
100
|
+
return Response.json({ success: true, data: contact });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function updateContact(request: Request, params: Record<string, string>): Promise<Response> {
|
|
104
|
+
const store = await getStorage();
|
|
105
|
+
const existing = store.get('contacts', params.id);
|
|
106
|
+
if (!existing) {
|
|
107
|
+
return Response.json({ success: false, error: 'Contact not found' }, { status: 404 });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const body = await request.json();
|
|
111
|
+
const updated = await store.update('contacts', params.id, body);
|
|
112
|
+
|
|
113
|
+
// Create activity
|
|
114
|
+
await store.insert('activities', {
|
|
115
|
+
type: 'contact',
|
|
116
|
+
description: `Contact updated: ${updated.name}`,
|
|
117
|
+
contactId: updated.id,
|
|
118
|
+
userId: 'system',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
broadcast({ type: 'contact_update', payload: updated });
|
|
122
|
+
|
|
123
|
+
return Response.json({ success: true, data: updated });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function deleteContact(request: Request, params: Record<string, string>): Promise<Response> {
|
|
127
|
+
const store = await getStorage();
|
|
128
|
+
const existing = store.get('contacts', params.id);
|
|
129
|
+
if (!existing) {
|
|
130
|
+
return Response.json({ success: false, error: 'Contact not found' }, { status: 404 });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
await store.delete('contacts', params.id);
|
|
134
|
+
|
|
135
|
+
broadcast({ type: 'contact_update', payload: { id: params.id, deleted: true } });
|
|
136
|
+
|
|
137
|
+
return Response.json({ success: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function listDeals(request: Request): Promise<Response> {
|
|
141
|
+
const url = new URL(request.url);
|
|
142
|
+
const stage = url.searchParams.get('stage') || '';
|
|
143
|
+
|
|
144
|
+
const store = await getStorage();
|
|
145
|
+
let results = store.list('deals');
|
|
146
|
+
|
|
147
|
+
if (stage) {
|
|
148
|
+
results = results.filter((d: { stage?: string }) => d.stage === stage);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return Response.json({ success: true, data: results });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function createDeal(request: Request): Promise<Response> {
|
|
155
|
+
const body = await request.json();
|
|
156
|
+
const store = await getStorage();
|
|
157
|
+
|
|
158
|
+
const deal = await store.insert('deals', {
|
|
159
|
+
contactId: body.contactId,
|
|
160
|
+
title: body.title,
|
|
161
|
+
value: body.value,
|
|
162
|
+
currency: body.currency || 'USD',
|
|
163
|
+
stage: body.stage || 'discovery',
|
|
164
|
+
probability: body.probability || 10,
|
|
165
|
+
expectedCloseDate: body.expectedCloseDate,
|
|
166
|
+
description: body.description,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Create activity
|
|
170
|
+
await store.insert('activities', {
|
|
171
|
+
type: 'deal',
|
|
172
|
+
description: `New deal created: ${deal.title}`,
|
|
173
|
+
dealId: deal.id,
|
|
174
|
+
userId: 'system',
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
broadcast({ type: 'deal_update', payload: deal });
|
|
178
|
+
|
|
179
|
+
return Response.json({ success: true, data: deal });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function listActivities(request: Request): Promise<Response> {
|
|
183
|
+
const url = new URL(request.url);
|
|
184
|
+
const limit = parseInt(url.searchParams.get('limit') || '50');
|
|
185
|
+
|
|
186
|
+
const store = await getStorage();
|
|
187
|
+
const activities = store.list('activities', { limit });
|
|
188
|
+
|
|
189
|
+
return Response.json({
|
|
190
|
+
success: true,
|
|
191
|
+
data: activities
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function uploadMedia(request: Request): Promise<Response> {
|
|
196
|
+
const formData = await request.formData();
|
|
197
|
+
const file = formData.get('file') as File;
|
|
198
|
+
|
|
199
|
+
if (!file) {
|
|
200
|
+
return Response.json({ success: false, error: 'No file provided' }, { status: 400 });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const store = await getStorage();
|
|
204
|
+
|
|
205
|
+
// Store file (in production, this would go to S3, etc.)
|
|
206
|
+
const buffer = await file.arrayBuffer();
|
|
207
|
+
const uploadsDir = './uploads';
|
|
208
|
+
await Bun.write(`${uploadsDir}/${crypto.randomUUID()}-${file.name}`, buffer);
|
|
209
|
+
|
|
210
|
+
const media = await store.insert('media', {
|
|
211
|
+
filename: file.name,
|
|
212
|
+
mimetype: file.type,
|
|
213
|
+
size: file.size,
|
|
214
|
+
url: `/media/${crypto.randomUUID()}`,
|
|
215
|
+
contactId: formData.get('contactId') as string || undefined,
|
|
216
|
+
dealId: formData.get('dealId') as string || undefined,
|
|
217
|
+
uploadedBy: 'system',
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
broadcast({ type: 'media_upload', payload: media });
|
|
221
|
+
|
|
222
|
+
return Response.json({ success: true, data: media });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function getDashboardStats(): Promise<Response> {
|
|
226
|
+
const store = await getStorage();
|
|
227
|
+
const stats = store.getStats();
|
|
228
|
+
const deals = store.list('deals');
|
|
229
|
+
const activities = store.list('activities', { limit: 1000 });
|
|
230
|
+
|
|
231
|
+
const activeDeals = deals.filter((d: { stage?: string }) =>
|
|
232
|
+
!d.stage?.startsWith('closed_')
|
|
233
|
+
).length;
|
|
234
|
+
|
|
235
|
+
const pipelineValue = deals
|
|
236
|
+
.filter((d: { stage?: string }) => !d.stage?.startsWith('closed_'))
|
|
237
|
+
.reduce((sum: number, d: { value?: number }) => sum + (d.value || 0), 0);
|
|
238
|
+
|
|
239
|
+
const wonThisMonth = deals
|
|
240
|
+
.filter((d: { stage?: string }) => d.stage === 'closed_won')
|
|
241
|
+
.reduce((sum: number, d: { value?: number }) => sum + (d.value || 0), 0);
|
|
242
|
+
|
|
243
|
+
const activitiesToday = activities.filter((a: { timestamp?: string }) =>
|
|
244
|
+
a.timestamp && new Date(a.timestamp).toDateString() === new Date().toDateString()
|
|
245
|
+
).length;
|
|
246
|
+
|
|
247
|
+
return Response.json({
|
|
248
|
+
success: true,
|
|
249
|
+
data: {
|
|
250
|
+
totalContacts: stats.contacts,
|
|
251
|
+
activeDeals,
|
|
252
|
+
pipelineValue,
|
|
253
|
+
wonThisMonth,
|
|
254
|
+
activitiesToday,
|
|
255
|
+
conversionRate: stats.contacts > 0 ? (activeDeals / stats.contacts) * 100 : 0,
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// WebSocket handler
|
|
261
|
+
const websocketHandler = {
|
|
262
|
+
open(ws: ServerWebSocket) {
|
|
263
|
+
connectedClients.add(ws);
|
|
264
|
+
ws.send(JSON.stringify({ type: 'connected', timestamp: new Date().toISOString() }));
|
|
265
|
+
console.log('WebSocket client connected');
|
|
266
|
+
},
|
|
267
|
+
message(ws: ServerWebSocket, message: string | Buffer) {
|
|
268
|
+
// Handle incoming WebSocket messages
|
|
269
|
+
try {
|
|
270
|
+
const data = JSON.parse(message.toString());
|
|
271
|
+
console.log('WebSocket message:', data);
|
|
272
|
+
// Echo back or broadcast as needed
|
|
273
|
+
} catch (e) {
|
|
274
|
+
console.error('Invalid WebSocket message:', e);
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
close(ws: ServerWebSocket) {
|
|
278
|
+
connectedClients.delete(ws);
|
|
279
|
+
console.log('WebSocket client disconnected');
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// Server
|
|
284
|
+
const server = Bun.serve({
|
|
285
|
+
port: 3001,
|
|
286
|
+
routes: {
|
|
287
|
+
"/": index,
|
|
288
|
+
"/api/contacts": {
|
|
289
|
+
GET: listContacts,
|
|
290
|
+
POST: createContact,
|
|
291
|
+
},
|
|
292
|
+
"/api/contacts/:id": {
|
|
293
|
+
GET: getContact,
|
|
294
|
+
PUT: updateContact,
|
|
295
|
+
DELETE: deleteContact,
|
|
296
|
+
},
|
|
297
|
+
"/api/deals": {
|
|
298
|
+
GET: listDeals,
|
|
299
|
+
POST: createDeal,
|
|
300
|
+
},
|
|
301
|
+
"/api/activities": {
|
|
302
|
+
GET: listActivities,
|
|
303
|
+
},
|
|
304
|
+
"/api/media": {
|
|
305
|
+
POST: uploadMedia,
|
|
306
|
+
},
|
|
307
|
+
"/api/dashboard/stats": {
|
|
308
|
+
GET: getDashboardStats,
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
websocket: websocketHandler,
|
|
312
|
+
development: {
|
|
313
|
+
hmr: true,
|
|
314
|
+
console: true,
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// Ensure uploads directory exists
|
|
319
|
+
Bun.file('./uploads').exists().then(async (exists) => {
|
|
320
|
+
if (!exists) {
|
|
321
|
+
await Bun.write('./uploads/.gitkeep', '');
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
console.log(`CRM Web Interface running at http://localhost:${server.port}`);
|
|
326
|
+
console.log('WebSocket endpoint: ws://localhost:3001/ws');
|