@crmy/web 0.5.1

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 (119) hide show
  1. package/index.html +23 -0
  2. package/package.json +76 -0
  3. package/postcss.config.js +6 -0
  4. package/public/android-chrome-192x192.png +0 -0
  5. package/public/android-chrome-512x512.png +0 -0
  6. package/public/apple-touch-icon.png +0 -0
  7. package/public/favicon-16x16.png +0 -0
  8. package/public/favicon-32x32.png +0 -0
  9. package/public/favicon.ico +0 -0
  10. package/public/favicon.svg +13 -0
  11. package/public/site.webmanifest +1 -0
  12. package/src/App.tsx +158 -0
  13. package/src/api/client.ts +82 -0
  14. package/src/api/hooks.ts +689 -0
  15. package/src/assets/crmy-logo.png +0 -0
  16. package/src/components/CustomFields.tsx +240 -0
  17. package/src/components/NavLink.tsx +28 -0
  18. package/src/components/crm/AIFab.tsx +37 -0
  19. package/src/components/crm/AccountDrawer.tsx +372 -0
  20. package/src/components/crm/ActivityTimeline.tsx +115 -0
  21. package/src/components/crm/AssignmentDrawer.tsx +396 -0
  22. package/src/components/crm/BriefingPanel.tsx +217 -0
  23. package/src/components/crm/CommandPalette.tsx +254 -0
  24. package/src/components/crm/ContactAvatar.tsx +49 -0
  25. package/src/components/crm/ContactDrawer.tsx +438 -0
  26. package/src/components/crm/ContextPanel.tsx +200 -0
  27. package/src/components/crm/CrmWidgets.tsx +417 -0
  28. package/src/components/crm/DrawerShell.tsx +77 -0
  29. package/src/components/crm/ListToolbar.tsx +252 -0
  30. package/src/components/crm/OpportunityDrawer.tsx +372 -0
  31. package/src/components/crm/PaginationBar.tsx +111 -0
  32. package/src/components/crm/QuickAddDrawer.tsx +652 -0
  33. package/src/components/crm/ShortcutsOverlay.tsx +65 -0
  34. package/src/components/crm/UseCaseDrawer.tsx +454 -0
  35. package/src/components/layout/MobileNav.tsx +49 -0
  36. package/src/components/layout/Sidebar.tsx +157 -0
  37. package/src/components/layout/TopBar.tsx +54 -0
  38. package/src/components/settings/ActorsSettings.tsx +1190 -0
  39. package/src/components/ui/accordion.tsx +52 -0
  40. package/src/components/ui/alert-dialog.tsx +104 -0
  41. package/src/components/ui/alert.tsx +43 -0
  42. package/src/components/ui/aspect-ratio.tsx +5 -0
  43. package/src/components/ui/avatar.tsx +38 -0
  44. package/src/components/ui/badge.tsx +29 -0
  45. package/src/components/ui/breadcrumb.tsx +90 -0
  46. package/src/components/ui/button.tsx +47 -0
  47. package/src/components/ui/calendar.tsx +54 -0
  48. package/src/components/ui/card.tsx +43 -0
  49. package/src/components/ui/carousel.tsx +224 -0
  50. package/src/components/ui/chart.tsx +303 -0
  51. package/src/components/ui/checkbox.tsx +26 -0
  52. package/src/components/ui/collapsible.tsx +9 -0
  53. package/src/components/ui/command.tsx +132 -0
  54. package/src/components/ui/context-menu.tsx +178 -0
  55. package/src/components/ui/date-picker.tsx +313 -0
  56. package/src/components/ui/dialog.tsx +95 -0
  57. package/src/components/ui/drawer.tsx +87 -0
  58. package/src/components/ui/dropdown-menu.tsx +179 -0
  59. package/src/components/ui/form.tsx +129 -0
  60. package/src/components/ui/hover-card.tsx +27 -0
  61. package/src/components/ui/input-otp.tsx +61 -0
  62. package/src/components/ui/input.tsx +22 -0
  63. package/src/components/ui/label.tsx +17 -0
  64. package/src/components/ui/menubar.tsx +207 -0
  65. package/src/components/ui/navigation-menu.tsx +120 -0
  66. package/src/components/ui/pagination.tsx +81 -0
  67. package/src/components/ui/popover.tsx +29 -0
  68. package/src/components/ui/progress.tsx +23 -0
  69. package/src/components/ui/radio-group.tsx +36 -0
  70. package/src/components/ui/resizable.tsx +37 -0
  71. package/src/components/ui/scroll-area.tsx +38 -0
  72. package/src/components/ui/select.tsx +143 -0
  73. package/src/components/ui/separator.tsx +20 -0
  74. package/src/components/ui/sheet.tsx +107 -0
  75. package/src/components/ui/sidebar.tsx +637 -0
  76. package/src/components/ui/skeleton.tsx +7 -0
  77. package/src/components/ui/slider.tsx +23 -0
  78. package/src/components/ui/sonner.tsx +24 -0
  79. package/src/components/ui/switch.tsx +27 -0
  80. package/src/components/ui/table.tsx +72 -0
  81. package/src/components/ui/tabs.tsx +53 -0
  82. package/src/components/ui/textarea.tsx +21 -0
  83. package/src/components/ui/toast.tsx +111 -0
  84. package/src/components/ui/toaster.tsx +24 -0
  85. package/src/components/ui/toggle-group.tsx +49 -0
  86. package/src/components/ui/toggle.tsx +37 -0
  87. package/src/components/ui/tooltip.tsx +28 -0
  88. package/src/components/ui/use-toast.ts +1 -0
  89. package/src/components/ui/utils.ts +9 -0
  90. package/src/contexts/AgentSettingsContext.tsx +24 -0
  91. package/src/hooks/use-mobile.tsx +19 -0
  92. package/src/hooks/use-toast.ts +186 -0
  93. package/src/hooks/useKeyboardShortcuts.ts +95 -0
  94. package/src/hooks/useTheme.ts +24 -0
  95. package/src/index.css +245 -0
  96. package/src/lib/entityColors.ts +18 -0
  97. package/src/lib/stageConfig.ts +32 -0
  98. package/src/lib/utils.ts +6 -0
  99. package/src/main.tsx +25 -0
  100. package/src/pages/Accounts.tsx +205 -0
  101. package/src/pages/Activities.tsx +251 -0
  102. package/src/pages/Agent.tsx +237 -0
  103. package/src/pages/AgentSettings.tsx +544 -0
  104. package/src/pages/Assignments.tsx +750 -0
  105. package/src/pages/Contacts.tsx +200 -0
  106. package/src/pages/Dashboard.tsx +143 -0
  107. package/src/pages/Inbox.tsx +615 -0
  108. package/src/pages/NotFound.tsx +24 -0
  109. package/src/pages/Opportunities.tsx +386 -0
  110. package/src/pages/SearchResults.tsx +49 -0
  111. package/src/pages/Settings.tsx +1884 -0
  112. package/src/pages/UseCases.tsx +396 -0
  113. package/src/pages/auth/Login.tsx +261 -0
  114. package/src/pages/hitl/HITL.tsx +101 -0
  115. package/src/store/appStore.ts +103 -0
  116. package/src/vite-env.d.ts +14 -0
  117. package/tailwind.config.js +121 -0
  118. package/tsconfig.json +24 -0
  119. package/vite.config.ts +27 -0
@@ -0,0 +1,652 @@
1
+ // Copyright 2026 CRMy Contributors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { useState, useRef, useEffect } from 'react';
5
+ import { useAppStore } from '@/store/appStore';
6
+ import { useAgentSettings } from '@/contexts/AgentSettingsContext';
7
+ import { X, Send, Sparkles, Check, FileText, Pencil, ChevronLeft } from 'lucide-react';
8
+ import { useCreateContact, useCreateAccount, useCreateOpportunity, useCreateUseCase, useCreateActivity, useCreateAssignment, useAccounts, useContacts, useOpportunities, useUseCases, useActors } from '@/api/hooks';
9
+ import { toast } from '@/components/ui/use-toast';
10
+ import { DatePicker, DateTimePicker } from '@/components/ui/date-picker';
11
+
12
+ const typeLabels: Record<string, string> = {
13
+ contact: 'Contact',
14
+ opportunity: 'Opportunity',
15
+ 'use-case': 'Use Case',
16
+ activity: 'Activity',
17
+ account: 'Account',
18
+ assignment: 'Assignment',
19
+ };
20
+
21
+ const typeGreetings: Record<string, string> = {
22
+ contact: "Hi! Tell me about the new contact — name, email, company, and any other details.",
23
+ opportunity: "Let's create a new opportunity! What's the name, amount, and who's the contact?",
24
+ 'use-case': "Let's set up a new use case. What's the name and which client is it for?",
25
+ activity: "Log an activity — tell me the type (call, email, meeting, note, demo, proposal, etc.), what it's about, and any outcome or notes.",
26
+ account: "Let's add a new account. What's the company name and any other details?",
27
+ assignment: "Let's create a new assignment. What's the title, type (call, email, research, etc.), and who should it be assigned to?",
28
+ };
29
+
30
+ type Message = { role: 'user' | 'assistant'; content: string };
31
+
32
+ function parseFieldsFromText(text: string, type: string): Record<string, unknown> {
33
+ const lower = text.toLowerCase();
34
+ const fields: Record<string, unknown> = {};
35
+
36
+ // Extract name (first sentence or "name is X" pattern)
37
+ const nameMatch = text.match(/(?:name(?:\s+is)?|called|named)\s+([A-Z][^\.,\n]+)/i) || text.match(/^([A-Z][a-z]+ [A-Z][a-z]+)/);
38
+ if (nameMatch) fields.name = nameMatch[1].trim();
39
+
40
+ // Extract email
41
+ const emailMatch = text.match(/[\w.-]+@[\w.-]+\.\w+/);
42
+ if (emailMatch) fields.email = emailMatch[0];
43
+
44
+ // Extract phone
45
+ const phoneMatch = text.match(/\+?[\d\s\-().]{10,}/);
46
+ if (phoneMatch) fields.phone = phoneMatch[0].trim();
47
+
48
+ // Extract company
49
+ const companyMatch = text.match(/(?:at|from|company|works at|with)\s+([A-Z][^\.,\n]+)/i);
50
+ if (companyMatch) fields.company = companyMatch[1].trim();
51
+
52
+ if (type === 'opportunity') {
53
+ const amountMatch = text.match(/\$?([\d,]+(?:\.\d+)?)\s*[kKmM]?/);
54
+ if (amountMatch) {
55
+ let amount = parseFloat(amountMatch[1].replace(',', ''));
56
+ if (lower.includes('k') || lower.includes('thousand')) amount *= 1000;
57
+ if (lower.includes('m') || lower.includes('million')) amount *= 1000000;
58
+ fields.amount = Math.round(amount);
59
+ }
60
+ fields.stage = 'prospecting';
61
+ }
62
+
63
+ if (type === 'activity') {
64
+ const types = ['call', 'email', 'meeting', 'note', 'task', 'demo', 'proposal', 'research', 'handoff', 'status_update'];
65
+ const foundType = types.find(t => lower.includes(t.replace('_', ' ')) || lower.includes(t));
66
+ fields.type = foundType ?? 'note';
67
+ fields.description = text;
68
+ // Extract outcome keywords
69
+ const outcomes = ['connected', 'voicemail', 'positive', 'negative', 'neutral', 'no show', 'no_show', 'follow up needed', 'follow_up_needed'];
70
+ const foundOutcome = outcomes.find(o => lower.includes(o));
71
+ if (foundOutcome) fields.outcome = foundOutcome.replace(/ /g, '_');
72
+ }
73
+
74
+ if (type === 'use-case') {
75
+ fields.stage = 'discovery';
76
+ }
77
+
78
+ if (type === 'assignment') {
79
+ // Extract assignment type
80
+ const assignmentTypes = ['call', 'draft', 'email', 'follow_up', 'follow up', 'research', 'review', 'send'];
81
+ const foundType = assignmentTypes.find(t => lower.includes(t));
82
+ if (foundType) fields.assignment_type = foundType.replace(' ', '_');
83
+ // Use first sentence as title if no explicit name
84
+ if (!fields.name) {
85
+ const firstSentence = text.split(/[.!?]/)[0].trim();
86
+ if (firstSentence) fields.title = firstSentence;
87
+ } else {
88
+ fields.title = fields.name;
89
+ delete fields.name;
90
+ }
91
+ // Extract priority
92
+ if (lower.includes('urgent')) fields.priority = 'urgent';
93
+ else if (lower.includes('high priority') || lower.includes('high-priority')) fields.priority = 'high';
94
+ else if (lower.includes('low priority') || lower.includes('low-priority')) fields.priority = 'low';
95
+ else fields.priority = 'normal';
96
+ }
97
+
98
+ return fields;
99
+ }
100
+
101
+ function ChatAddPanel({ type, onClose }: { type: string; onClose: () => void }) {
102
+ const [messages, setMessages] = useState<Message[]>([
103
+ { role: 'assistant', content: typeGreetings[type] ?? typeGreetings.contact },
104
+ ]);
105
+ const [input, setInput] = useState('');
106
+ const [isSubmitting, setIsSubmitting] = useState(false);
107
+ const [extractedFields, setExtractedFields] = useState<Record<string, unknown> | null>(null);
108
+ const [confirmed, setConfirmed] = useState(false);
109
+ const [showForm, setShowForm] = useState(false);
110
+ const scrollRef = useRef<HTMLDivElement>(null);
111
+ const inputRef = useRef<HTMLTextAreaElement>(null);
112
+
113
+ const createContact = useCreateContact();
114
+ const createAccount = useCreateAccount();
115
+ const createOpportunity = useCreateOpportunity();
116
+ const createUseCase = useCreateUseCase();
117
+ const createActivity = useCreateActivity();
118
+ const createAssignment = useCreateAssignment();
119
+
120
+ useEffect(() => {
121
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
122
+ }, [messages]);
123
+
124
+ useEffect(() => {
125
+ inputRef.current?.focus();
126
+ }, []);
127
+
128
+ const handleSend = () => {
129
+ if (!input.trim() || isSubmitting) return;
130
+ const userText = input.trim();
131
+ setInput('');
132
+
133
+ setMessages(prev => [...prev, { role: 'user', content: userText }]);
134
+
135
+ // Parse fields from message
136
+ const fields = parseFieldsFromText(userText, type);
137
+ setExtractedFields(prev => ({ ...(prev ?? {}), ...fields }));
138
+
139
+ // Generate assistant confirmation
140
+ const fieldsList = Object.entries({ ...(extractedFields ?? {}), ...fields })
141
+ .filter(([, v]) => v !== undefined && v !== '')
142
+ .map(([k, v]) => `• **${k}**: ${v}`)
143
+ .join('\n');
144
+
145
+ setMessages(prev => [
146
+ ...prev,
147
+ {
148
+ role: 'assistant',
149
+ content: fieldsList
150
+ ? `Got it! Here's what I'll create:\n\n${fieldsList}\n\nDoes this look right? Reply "yes" to confirm or add more details.`
151
+ : "Thanks! Can you share more details like name, email, or any other relevant information?",
152
+ },
153
+ ]);
154
+ };
155
+
156
+ const handleConfirm = async () => {
157
+ if (!extractedFields || isSubmitting) return;
158
+ setIsSubmitting(true);
159
+
160
+ try {
161
+ if (type === 'contact') await createContact.mutateAsync(extractedFields);
162
+ else if (type === 'account') await createAccount.mutateAsync(extractedFields);
163
+ else if (type === 'opportunity') await createOpportunity.mutateAsync(extractedFields);
164
+ else if (type === 'use-case') await createUseCase.mutateAsync(extractedFields);
165
+ else if (type === 'activity') await createActivity.mutateAsync(extractedFields);
166
+ else if (type === 'assignment') await createAssignment.mutateAsync(extractedFields);
167
+
168
+ setConfirmed(true);
169
+ toast({ title: `${typeLabels[type]} created!`, description: 'Successfully added to your CRM.' });
170
+ setTimeout(onClose, 1200);
171
+ } catch (err) {
172
+ toast({ title: `Failed to create ${typeLabels[type]}`, description: err instanceof Error ? err.message : 'Please try again.', variant: 'destructive' });
173
+ } finally {
174
+ setIsSubmitting(false);
175
+ }
176
+ };
177
+
178
+ const lastMsg = messages[messages.length - 1];
179
+ const showConfirmButton =
180
+ lastMsg?.role === 'assistant' &&
181
+ lastMsg.content.includes("Does this look right") &&
182
+ extractedFields !== null &&
183
+ !confirmed;
184
+
185
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
186
+ if (e.key === 'Enter' && !e.shiftKey) {
187
+ e.preventDefault();
188
+ if (input.toLowerCase().trim() === 'yes' || input.toLowerCase().trim() === 'confirm') {
189
+ handleConfirm();
190
+ } else {
191
+ handleSend();
192
+ }
193
+ }
194
+ };
195
+
196
+ return (
197
+ <div className="flex flex-col h-full">
198
+ {/* Header */}
199
+ <div className="flex items-center justify-between px-4 py-3 border-b border-border">
200
+ <div className="flex items-center gap-2">
201
+ <Sparkles className="w-4 h-4 text-accent" />
202
+ <span className="font-display font-bold text-foreground">New {typeLabels[type]}</span>
203
+ </div>
204
+ <div className="flex items-center gap-2">
205
+ <button
206
+ onClick={() => setShowForm(!showForm)}
207
+ className={`flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-md border transition-colors ${
208
+ showForm
209
+ ? 'border-border bg-muted text-foreground'
210
+ : 'border-border/60 text-muted-foreground hover:text-foreground hover:bg-muted/50'
211
+ }`}
212
+ >
213
+ <FileText className="w-3 h-3" />
214
+ <span>Form</span>
215
+ </button>
216
+ <button onClick={onClose} className="p-1.5 rounded-lg hover:bg-muted transition-colors">
217
+ <X className="w-4 h-4 text-muted-foreground" />
218
+ </button>
219
+ </div>
220
+ </div>
221
+
222
+ {showForm ? (
223
+ <ManualForm type={type} onClose={onClose} onBack={() => setShowForm(false)} backLabel="Back to AI chat" />
224
+ ) : (
225
+ <>
226
+ {/* Messages */}
227
+ <div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-3">
228
+ {messages.map((msg, i) => (
229
+ <div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
230
+ <div
231
+ className={`max-w-[85%] rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed whitespace-pre-wrap ${
232
+ msg.role === 'user'
233
+ ? 'bg-primary text-primary-foreground'
234
+ : 'bg-muted text-foreground'
235
+ }`}
236
+ >
237
+ {msg.content}
238
+ </div>
239
+ </div>
240
+ ))}
241
+ {confirmed && (
242
+ <div className="flex justify-center">
243
+ <div className="flex items-center gap-2 px-4 py-2 rounded-full bg-success/15 text-success text-sm font-semibold">
244
+ <Check className="w-4 h-4" /> Created successfully!
245
+ </div>
246
+ </div>
247
+ )}
248
+ </div>
249
+
250
+ {/* Confirm button */}
251
+ {showConfirmButton && (
252
+ <div className="px-4 pb-2">
253
+ <button
254
+ onClick={handleConfirm}
255
+ disabled={isSubmitting}
256
+ className="w-full py-2.5 rounded-xl bg-success text-success-foreground text-sm font-semibold hover:bg-success/90 transition-colors disabled:opacity-50"
257
+ >
258
+ {isSubmitting ? 'Creating...' : `Confirm & Create ${typeLabels[type]}`}
259
+ </button>
260
+ </div>
261
+ )}
262
+
263
+ {/* Input */}
264
+ <div className="flex items-end gap-2 p-3 border-t border-border">
265
+ <textarea
266
+ ref={inputRef}
267
+ value={input}
268
+ onChange={(e) => setInput(e.target.value)}
269
+ onKeyDown={handleKeyDown}
270
+ placeholder="Type details or 'yes' to confirm..."
271
+ rows={1}
272
+ className="flex-1 resize-none bg-muted rounded-xl px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-primary/30 min-h-[38px] max-h-28"
273
+ style={{ height: 'auto' }}
274
+ />
275
+ <button
276
+ onClick={() => {
277
+ if (input.toLowerCase().trim() === 'yes' || input.toLowerCase().trim() === 'confirm') {
278
+ handleConfirm();
279
+ } else {
280
+ handleSend();
281
+ }
282
+ }}
283
+ disabled={!input.trim() || isSubmitting}
284
+ className="p-2 rounded-xl bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-40"
285
+ >
286
+ <Send className="w-4 h-4" />
287
+ </button>
288
+ </div>
289
+ </>
290
+ )}
291
+ </div>
292
+ );
293
+ }
294
+
295
+ type FieldConfig = {
296
+ key: string;
297
+ label: string;
298
+ placeholder?: string;
299
+ inputType?: 'text' | 'email' | 'tel' | 'number' | 'date' | 'url' | 'datetime-local';
300
+ fieldType?: 'textarea' | 'select' | 'account-select' | 'subject-type-select' | 'entity-select' | 'datalist';
301
+ options?: string[];
302
+ datalistId?: string;
303
+ suggestions?: string[];
304
+ required?: boolean;
305
+ dependsOn?: { key: string; values?: string[] };
306
+ };
307
+
308
+ const FIELD_CONFIGS: Record<string, FieldConfig[]> = {
309
+ contact: [
310
+ { key: 'first_name', label: 'First Name', placeholder: 'First name', required: true },
311
+ { key: 'last_name', label: 'Last Name', placeholder: 'Last name' },
312
+ { key: 'email', label: 'Email', placeholder: 'email@example.com', inputType: 'email' },
313
+ { key: 'phone', label: 'Phone', placeholder: '(555) 123-4567', inputType: 'tel' },
314
+ { key: 'company_name', label: 'Company', placeholder: 'Company name' },
315
+ ],
316
+ opportunity: [
317
+ { key: 'name', label: 'Opportunity Name', placeholder: 'e.g. Acme Enterprise', required: true },
318
+ { key: 'amount', label: 'Amount ($)', placeholder: '850000', inputType: 'number' },
319
+ { key: 'close_date', label: 'Close Date', inputType: 'date' },
320
+ { key: 'description', label: 'Description', placeholder: 'Optional notes', fieldType: 'textarea' },
321
+ ],
322
+ 'use-case': [
323
+ { key: 'name', label: 'Name', placeholder: 'e.g. Corporate Relocation', required: true },
324
+ { key: 'account_id', label: 'Account', fieldType: 'account-select', required: true },
325
+ { key: 'stage', label: 'Stage', fieldType: 'select', options: ['discovery', 'poc', 'production', 'scaling', 'sunset'] },
326
+ { key: 'attributed_arr', label: 'Attributed ARR ($)', placeholder: '120000', inputType: 'number' },
327
+ { key: 'target_prod_date', label: 'Target Prod Date', inputType: 'date' },
328
+ { key: 'description', label: 'Description', placeholder: 'Any additional details', fieldType: 'textarea' },
329
+ ],
330
+ activity: [
331
+ { key: 'type', label: 'Type', fieldType: 'select', options: ['call', 'email', 'meeting', 'note', 'task', 'demo', 'proposal', 'research', 'handoff'], required: true },
332
+ { key: 'subject', label: 'Subject', placeholder: 'What was this activity about?', required: true },
333
+ { key: 'subject_type', label: 'Linked To', fieldType: 'subject-type-select', placeholder: 'Link to a CRM record (optional)' },
334
+ { key: 'subject_id', label: 'Record', fieldType: 'entity-select', dependsOn: { key: 'subject_type' } },
335
+ { key: 'occurred_at', label: 'When', inputType: 'datetime-local', placeholder: 'When did this happen?' },
336
+ { key: 'outcome', label: 'Outcome', fieldType: 'datalist', datalistId: 'outcome-suggestions', suggestions: ['connected', 'voicemail', 'positive', 'negative', 'neutral', 'no_show', 'follow_up_needed'], placeholder: 'e.g. connected, positive, voicemail' },
337
+ { key: 'body', label: 'Notes', placeholder: 'Additional details...', fieldType: 'textarea' },
338
+ ],
339
+ account: [
340
+ { key: 'name', label: 'Company Name', placeholder: 'e.g. Acme Corp', required: true },
341
+ { key: 'industry', label: 'Industry', placeholder: 'e.g. Real Estate, Technology' },
342
+ { key: 'website', label: 'Website', placeholder: 'https://acme.com', inputType: 'url' },
343
+ { key: 'domain', label: 'Domain', placeholder: 'acme.com' },
344
+ ],
345
+ assignment: [
346
+ { key: 'title', label: 'Title', placeholder: 'e.g. Follow up with Acme about contract', required: true },
347
+ { key: 'assignment_type', label: 'Type', fieldType: 'select', options: ['call', 'draft', 'email', 'follow_up', 'research', 'review', 'send'], required: true },
348
+ { key: 'assigned_to', label: 'Assign To', fieldType: 'actor-select', required: true },
349
+ { key: 'subject_type', label: 'Linked To', fieldType: 'subject-type-select' },
350
+ { key: 'subject_id', label: 'Record', fieldType: 'entity-select', dependsOn: { key: 'subject_type' } },
351
+ { key: 'priority', label: 'Priority', fieldType: 'select', options: ['low', 'normal', 'high', 'urgent'] },
352
+ { key: 'due_at', label: 'Due Date', inputType: 'date' },
353
+ { key: 'context', label: 'Context', placeholder: 'Brief context for the assignee', fieldType: 'textarea' },
354
+ { key: 'description', label: 'Description', placeholder: 'Additional details', fieldType: 'textarea' },
355
+ ],
356
+ };
357
+
358
+ const SUBJECT_TYPE_OPTIONS = [
359
+ { value: '', label: 'None (no link)' },
360
+ { value: 'contact', label: 'Contact' },
361
+ { value: 'account', label: 'Account' },
362
+ { value: 'opportunity', label: 'Opportunity' },
363
+ { value: 'use_case', label: 'Use Case' },
364
+ ];
365
+
366
+ function EntitySelect({ subjectType, value, onChange }: { subjectType: string; value: string; onChange: (v: string) => void }) {
367
+ const { data: contactsData } = useContacts(subjectType === 'contact' ? { limit: 100 } : undefined);
368
+ const { data: accountsData } = useAccounts(subjectType === 'account' ? { limit: 100 } : undefined);
369
+ const { data: oppsData } = useOpportunities(subjectType === 'opportunity' ? { limit: 100 } : undefined);
370
+ const { data: ucsData } = useUseCases(subjectType === 'use_case' ? { limit: 100 } : undefined);
371
+
372
+ const inputClass = 'w-full h-10 px-3 rounded-md border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring';
373
+
374
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
375
+ let entities: Array<{ id: string; label: string }> = [];
376
+ if (subjectType === 'contact') {
377
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
378
+ entities = ((contactsData?.data ?? []) as any[]).map(c => ({ id: c.id, label: `${c.first_name ?? ''} ${c.last_name ?? ''}`.trim() || c.email || c.id }));
379
+ } else if (subjectType === 'account') {
380
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
381
+ entities = ((accountsData?.data ?? []) as any[]).map(a => ({ id: a.id, label: a.name ?? a.id }));
382
+ } else if (subjectType === 'opportunity') {
383
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
384
+ entities = ((oppsData?.data ?? []) as any[]).map(o => ({ id: o.id, label: o.name ?? o.id }));
385
+ } else if (subjectType === 'use_case') {
386
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
387
+ entities = ((ucsData?.data ?? []) as any[]).map(u => ({ id: u.id, label: u.name ?? u.id }));
388
+ }
389
+
390
+ if (!subjectType) return null;
391
+
392
+ return (
393
+ <select value={value} onChange={e => onChange(e.target.value)} className={`${inputClass} pr-3`}>
394
+ <option value="">Select {subjectType.replace('_', ' ')}…</option>
395
+ {entities.map(e => (
396
+ <option key={e.id} value={e.id}>{e.label}</option>
397
+ ))}
398
+ </select>
399
+ );
400
+ }
401
+
402
+ function ActorSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
403
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
404
+ const { data: actorsData } = useActors({ limit: 100, is_active: true }) as any;
405
+ const actors: Array<{ id: string; display_name: string; actor_type: string }> = actorsData?.data ?? actorsData?.actors ?? [];
406
+ const inputClass = 'w-full h-10 px-3 rounded-md border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring';
407
+ return (
408
+ <select value={value} onChange={e => onChange(e.target.value)} className={`${inputClass} pr-3`}>
409
+ <option value="">Select actor…</option>
410
+ {actors.map(a => (
411
+ <option key={a.id} value={a.id}>
412
+ {a.display_name} ({a.actor_type})
413
+ </option>
414
+ ))}
415
+ </select>
416
+ );
417
+ }
418
+
419
+ function ManualForm({ type, onClose, onBack, backLabel }: { type: string; onClose: () => void; onBack: () => void; backLabel?: string }) {
420
+ const [fields, setFields] = useState<Record<string, string>>({});
421
+ const [isSubmitting, setIsSubmitting] = useState(false);
422
+ const { data: accountsData } = useAccounts({ limit: 200 });
423
+ const accounts = (accountsData?.data ?? []) as Array<{ id: string; name: string }>;
424
+
425
+ const createContact = useCreateContact();
426
+ const createAccount = useCreateAccount();
427
+ const createOpportunity = useCreateOpportunity();
428
+ const createUseCase = useCreateUseCase();
429
+ const createActivity = useCreateActivity();
430
+ const createAssignment = useCreateAssignment();
431
+
432
+ const config = FIELD_CONFIGS[type] ?? FIELD_CONFIGS.contact;
433
+
434
+ const isValid = () => {
435
+ if (type === 'contact') return !!fields.first_name?.trim();
436
+ if (type === 'activity') return !!fields.type && !!fields.subject?.trim();
437
+ if (type === 'use-case') return !!fields.name?.trim() && !!fields.account_id;
438
+ if (type === 'assignment') return !!fields.title?.trim() && !!fields.assignment_type && !!fields.assigned_to;
439
+ return !!fields.name?.trim();
440
+ };
441
+
442
+ const set = (key: string, val: string) => setFields(prev => ({ ...prev, [key]: val }));
443
+
444
+ const handleSubmit = async () => {
445
+ if (!isValid() || isSubmitting) return;
446
+ setIsSubmitting(true);
447
+ try {
448
+ const payload: Record<string, unknown> = { ...fields };
449
+
450
+ if (type === 'contact') {
451
+ delete payload.name; // server uses first_name/last_name
452
+ }
453
+ if (type === 'opportunity') {
454
+ if (fields.amount) payload.amount = parseFloat(fields.amount) || 0;
455
+ payload.stage = 'prospecting';
456
+ }
457
+ if (type === 'use-case') {
458
+ if (!payload.stage) payload.stage = 'discovery';
459
+ if (fields.attributed_arr) payload.attributed_arr = parseFloat(fields.attributed_arr) || 0;
460
+ }
461
+ if (type === 'account' && fields.website) {
462
+ payload.website = fields.website.startsWith('http') ? fields.website : `https://${fields.website}`;
463
+ }
464
+ if (type === 'activity') {
465
+ // Convert occurred_at from datetime-local to ISO string
466
+ if (fields.occurred_at) {
467
+ payload.occurred_at = new Date(fields.occurred_at).toISOString();
468
+ }
469
+ // Clean up empty optional fields
470
+ if (!fields.subject_type) { delete payload.subject_type; delete payload.subject_id; }
471
+ if (!fields.subject_id) delete payload.subject_id;
472
+ if (!fields.outcome) delete payload.outcome;
473
+ if (!fields.occurred_at) delete payload.occurred_at;
474
+ }
475
+
476
+ if (type === 'assignment') {
477
+ // Convert date string to ISO datetime
478
+ if (fields.due_at) payload.due_at = new Date(fields.due_at + 'T00:00:00').toISOString();
479
+ // Strip empty optional fields
480
+ if (!fields.subject_type) { delete payload.subject_type; delete payload.subject_id; }
481
+ if (!fields.subject_id) delete payload.subject_id;
482
+ if (!fields.context) delete payload.context;
483
+ if (!fields.description) delete payload.description;
484
+ if (!fields.due_at) delete payload.due_at;
485
+ if (!fields.priority) payload.priority = 'normal';
486
+ }
487
+
488
+ if (type === 'contact') await createContact.mutateAsync(payload);
489
+ else if (type === 'account') await createAccount.mutateAsync(payload);
490
+ else if (type === 'opportunity') await createOpportunity.mutateAsync(payload);
491
+ else if (type === 'use-case') await createUseCase.mutateAsync(payload);
492
+ else if (type === 'activity') await createActivity.mutateAsync(payload);
493
+ else if (type === 'assignment') await createAssignment.mutateAsync(payload);
494
+
495
+ const label = fields.first_name ?? fields.title ?? fields.name ?? fields.subject ?? typeLabels[type];
496
+ toast({ title: `${typeLabels[type]} created`, description: `${label} has been added.` });
497
+ onClose();
498
+ } catch (err) {
499
+ toast({ title: `Failed to create ${typeLabels[type]}`, description: err instanceof Error ? err.message : 'Please try again.', variant: 'destructive' });
500
+ } finally {
501
+ setIsSubmitting(false);
502
+ }
503
+ };
504
+
505
+ const inputClass = 'w-full h-10 px-3 rounded-md border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring';
506
+
507
+ // Check if a field should be visible based on its dependsOn condition
508
+ const isFieldVisible = (f: FieldConfig) => {
509
+ if (!f.dependsOn) return true;
510
+ const depVal = fields[f.dependsOn.key];
511
+ if (!depVal) return false;
512
+ if (f.dependsOn.values) return f.dependsOn.values.includes(depVal);
513
+ return true;
514
+ };
515
+
516
+ return (
517
+ <div className="flex-1 overflow-y-auto p-4">
518
+ <button onClick={onBack} className="flex items-center gap-1.5 text-xs text-accent hover:underline mb-5">
519
+ {backLabel ? <><Sparkles className="w-3 h-3" /> {backLabel}</> : <><ChevronLeft className="w-3.5 h-3.5" /> Back</>}
520
+ </button>
521
+ <div className="space-y-4">
522
+ {config.filter(isFieldVisible).map(f => (
523
+ <div key={f.key} className="space-y-1.5">
524
+ <label className="text-xs font-mono text-muted-foreground uppercase tracking-wider">
525
+ {f.label}{f.required && <span className="text-destructive ml-0.5">*</span>}
526
+ </label>
527
+
528
+ {f.fieldType === 'select' ? (
529
+ <select
530
+ value={fields[f.key] || ''}
531
+ onChange={(e) => set(f.key, e.target.value)}
532
+ className={`${inputClass} pr-3`}
533
+ >
534
+ <option value="">Select {f.label.toLowerCase()}…</option>
535
+ {f.options?.map(o => (
536
+ <option key={o} value={o}>{o.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}</option>
537
+ ))}
538
+ </select>
539
+ ) : f.fieldType === 'account-select' ? (
540
+ <select
541
+ value={fields[f.key] || ''}
542
+ onChange={(e) => set(f.key, e.target.value)}
543
+ className={`${inputClass} pr-3`}
544
+ >
545
+ <option value="">Select account…</option>
546
+ {accounts.map(a => (
547
+ <option key={a.id} value={a.id}>{a.name}</option>
548
+ ))}
549
+ </select>
550
+ ) : f.fieldType === 'subject-type-select' ? (
551
+ <select
552
+ value={fields[f.key] || ''}
553
+ onChange={(e) => { set(f.key, e.target.value); set('subject_id', ''); }}
554
+ className={`${inputClass} pr-3`}
555
+ >
556
+ {SUBJECT_TYPE_OPTIONS.map(o => (
557
+ <option key={o.value} value={o.value}>{o.label}</option>
558
+ ))}
559
+ </select>
560
+ ) : f.fieldType === 'entity-select' ? (
561
+ <EntitySelect
562
+ subjectType={fields.subject_type || ''}
563
+ value={fields[f.key] || ''}
564
+ onChange={(v) => set(f.key, v)}
565
+ />
566
+ ) : f.fieldType === 'actor-select' ? (
567
+ <ActorSelect
568
+ value={fields[f.key] || ''}
569
+ onChange={(v) => set(f.key, v)}
570
+ />
571
+ ) : f.fieldType === 'datalist' ? (
572
+ <>
573
+ <input
574
+ type="text"
575
+ value={fields[f.key] || ''}
576
+ onChange={(e) => set(f.key, e.target.value)}
577
+ placeholder={f.placeholder}
578
+ list={f.datalistId}
579
+ className={`${inputClass} pr-3`}
580
+ />
581
+ {f.datalistId && f.suggestions && (
582
+ <datalist id={f.datalistId}>
583
+ {f.suggestions.map(s => (
584
+ <option key={s} value={s} />
585
+ ))}
586
+ </datalist>
587
+ )}
588
+ </>
589
+ ) : f.fieldType === 'textarea' ? (
590
+ <textarea
591
+ value={fields[f.key] || ''}
592
+ onChange={(e) => set(f.key, e.target.value)}
593
+ placeholder={f.placeholder}
594
+ rows={3}
595
+ className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring resize-none"
596
+ />
597
+ ) : f.inputType === 'date' ? (
598
+ <DatePicker
599
+ value={fields[f.key] || ''}
600
+ onChange={(v) => set(f.key, v)}
601
+ required={f.required}
602
+ />
603
+ ) : f.inputType === 'datetime-local' ? (
604
+ <DateTimePicker
605
+ value={fields[f.key] || ''}
606
+ onChange={(v) => set(f.key, v)}
607
+ required={f.required}
608
+ />
609
+ ) : (
610
+ <div className="relative">
611
+ <input
612
+ type={f.inputType ?? 'text'}
613
+ value={fields[f.key] || ''}
614
+ onChange={(e) => set(f.key, e.target.value)}
615
+ placeholder={f.placeholder}
616
+ className={`${inputClass} pr-8`}
617
+ />
618
+ <Pencil className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground/40 pointer-events-none" />
619
+ </div>
620
+ )}
621
+ </div>
622
+ ))}
623
+ <button
624
+ onClick={handleSubmit}
625
+ disabled={!isValid() || isSubmitting}
626
+ className="w-full h-10 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-40 transition-colors mt-2"
627
+ >
628
+ {isSubmitting ? 'Creating...' : `Create ${typeLabels[type]}`}
629
+ </button>
630
+ </div>
631
+ </div>
632
+ );
633
+ }
634
+
635
+ export function QuickAddDrawer() {
636
+ const { quickAddType, closeQuickAdd } = useAppStore();
637
+ const { enabled: agentEnabled } = useAgentSettings();
638
+
639
+ if (!quickAddType) return null;
640
+
641
+ return (
642
+ <>
643
+ <div className="fixed inset-0 bg-background/60 backdrop-blur-sm z-[80]" onClick={closeQuickAdd} />
644
+ <div className="fixed right-0 top-0 h-full w-full max-w-md bg-card border-l border-border z-[90] shadow-2xl flex flex-col animate-slide-in-right">
645
+ {agentEnabled
646
+ ? <ChatAddPanel type={quickAddType} onClose={closeQuickAdd} />
647
+ : <ManualForm type={quickAddType} onClose={closeQuickAdd} onBack={closeQuickAdd} />
648
+ }
649
+ </div>
650
+ </>
651
+ );
652
+ }