@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.
- package/index.html +23 -0
- package/package.json +76 -0
- package/postcss.config.js +6 -0
- package/public/android-chrome-192x192.png +0 -0
- package/public/android-chrome-512x512.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +13 -0
- package/public/site.webmanifest +1 -0
- package/src/App.tsx +158 -0
- package/src/api/client.ts +82 -0
- package/src/api/hooks.ts +689 -0
- package/src/assets/crmy-logo.png +0 -0
- package/src/components/CustomFields.tsx +240 -0
- package/src/components/NavLink.tsx +28 -0
- package/src/components/crm/AIFab.tsx +37 -0
- package/src/components/crm/AccountDrawer.tsx +372 -0
- package/src/components/crm/ActivityTimeline.tsx +115 -0
- package/src/components/crm/AssignmentDrawer.tsx +396 -0
- package/src/components/crm/BriefingPanel.tsx +217 -0
- package/src/components/crm/CommandPalette.tsx +254 -0
- package/src/components/crm/ContactAvatar.tsx +49 -0
- package/src/components/crm/ContactDrawer.tsx +438 -0
- package/src/components/crm/ContextPanel.tsx +200 -0
- package/src/components/crm/CrmWidgets.tsx +417 -0
- package/src/components/crm/DrawerShell.tsx +77 -0
- package/src/components/crm/ListToolbar.tsx +252 -0
- package/src/components/crm/OpportunityDrawer.tsx +372 -0
- package/src/components/crm/PaginationBar.tsx +111 -0
- package/src/components/crm/QuickAddDrawer.tsx +652 -0
- package/src/components/crm/ShortcutsOverlay.tsx +65 -0
- package/src/components/crm/UseCaseDrawer.tsx +454 -0
- package/src/components/layout/MobileNav.tsx +49 -0
- package/src/components/layout/Sidebar.tsx +157 -0
- package/src/components/layout/TopBar.tsx +54 -0
- package/src/components/settings/ActorsSettings.tsx +1190 -0
- package/src/components/ui/accordion.tsx +52 -0
- package/src/components/ui/alert-dialog.tsx +104 -0
- package/src/components/ui/alert.tsx +43 -0
- package/src/components/ui/aspect-ratio.tsx +5 -0
- package/src/components/ui/avatar.tsx +38 -0
- package/src/components/ui/badge.tsx +29 -0
- package/src/components/ui/breadcrumb.tsx +90 -0
- package/src/components/ui/button.tsx +47 -0
- package/src/components/ui/calendar.tsx +54 -0
- package/src/components/ui/card.tsx +43 -0
- package/src/components/ui/carousel.tsx +224 -0
- package/src/components/ui/chart.tsx +303 -0
- package/src/components/ui/checkbox.tsx +26 -0
- package/src/components/ui/collapsible.tsx +9 -0
- package/src/components/ui/command.tsx +132 -0
- package/src/components/ui/context-menu.tsx +178 -0
- package/src/components/ui/date-picker.tsx +313 -0
- package/src/components/ui/dialog.tsx +95 -0
- package/src/components/ui/drawer.tsx +87 -0
- package/src/components/ui/dropdown-menu.tsx +179 -0
- package/src/components/ui/form.tsx +129 -0
- package/src/components/ui/hover-card.tsx +27 -0
- package/src/components/ui/input-otp.tsx +61 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/label.tsx +17 -0
- package/src/components/ui/menubar.tsx +207 -0
- package/src/components/ui/navigation-menu.tsx +120 -0
- package/src/components/ui/pagination.tsx +81 -0
- package/src/components/ui/popover.tsx +29 -0
- package/src/components/ui/progress.tsx +23 -0
- package/src/components/ui/radio-group.tsx +36 -0
- package/src/components/ui/resizable.tsx +37 -0
- package/src/components/ui/scroll-area.tsx +38 -0
- package/src/components/ui/select.tsx +143 -0
- package/src/components/ui/separator.tsx +20 -0
- package/src/components/ui/sheet.tsx +107 -0
- package/src/components/ui/sidebar.tsx +637 -0
- package/src/components/ui/skeleton.tsx +7 -0
- package/src/components/ui/slider.tsx +23 -0
- package/src/components/ui/sonner.tsx +24 -0
- package/src/components/ui/switch.tsx +27 -0
- package/src/components/ui/table.tsx +72 -0
- package/src/components/ui/tabs.tsx +53 -0
- package/src/components/ui/textarea.tsx +21 -0
- package/src/components/ui/toast.tsx +111 -0
- package/src/components/ui/toaster.tsx +24 -0
- package/src/components/ui/toggle-group.tsx +49 -0
- package/src/components/ui/toggle.tsx +37 -0
- package/src/components/ui/tooltip.tsx +28 -0
- package/src/components/ui/use-toast.ts +1 -0
- package/src/components/ui/utils.ts +9 -0
- package/src/contexts/AgentSettingsContext.tsx +24 -0
- package/src/hooks/use-mobile.tsx +19 -0
- package/src/hooks/use-toast.ts +186 -0
- package/src/hooks/useKeyboardShortcuts.ts +95 -0
- package/src/hooks/useTheme.ts +24 -0
- package/src/index.css +245 -0
- package/src/lib/entityColors.ts +18 -0
- package/src/lib/stageConfig.ts +32 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +25 -0
- package/src/pages/Accounts.tsx +205 -0
- package/src/pages/Activities.tsx +251 -0
- package/src/pages/Agent.tsx +237 -0
- package/src/pages/AgentSettings.tsx +544 -0
- package/src/pages/Assignments.tsx +750 -0
- package/src/pages/Contacts.tsx +200 -0
- package/src/pages/Dashboard.tsx +143 -0
- package/src/pages/Inbox.tsx +615 -0
- package/src/pages/NotFound.tsx +24 -0
- package/src/pages/Opportunities.tsx +386 -0
- package/src/pages/SearchResults.tsx +49 -0
- package/src/pages/Settings.tsx +1884 -0
- package/src/pages/UseCases.tsx +396 -0
- package/src/pages/auth/Login.tsx +261 -0
- package/src/pages/hitl/HITL.tsx +101 -0
- package/src/store/appStore.ts +103 -0
- package/src/vite-env.d.ts +14 -0
- package/tailwind.config.js +121 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +27 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
// Copyright 2026 CRMy Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { useState, useRef } from 'react';
|
|
5
|
+
import { useContact, useActivities, useUpdateContact, useDeleteContact, useUsers, useCustomFields, useNotes, useCreateNote } from '@/api/hooks';
|
|
6
|
+
import { ContactAvatar } from './ContactAvatar';
|
|
7
|
+
import { useNavigate } from 'react-router-dom';
|
|
8
|
+
import { useAppStore } from '@/store/appStore';
|
|
9
|
+
import { useAgentSettings } from '@/contexts/AgentSettingsContext';
|
|
10
|
+
import { StageBadge, LeadScoreBadge, CustomFieldsSection } from './CrmWidgets';
|
|
11
|
+
import { ActivityTimeline } from './ActivityTimeline';
|
|
12
|
+
import { Phone, Mail, StickyNote, Sparkles, Pencil, ChevronLeft, Send, Pin, Trash2, FileText } from 'lucide-react';
|
|
13
|
+
import { ContextPanel } from './ContextPanel';
|
|
14
|
+
import { BriefingPanel } from './BriefingPanel';
|
|
15
|
+
import { toast } from '@/components/ui/use-toast';
|
|
16
|
+
import { DatePicker } from '@/components/ui/date-picker';
|
|
17
|
+
|
|
18
|
+
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';
|
|
19
|
+
const labelClass = 'text-xs font-mono text-muted-foreground uppercase tracking-wider';
|
|
20
|
+
|
|
21
|
+
const LIFECYCLE_STAGES = ['lead', 'qualified', 'opportunity', 'customer', 'churned'];
|
|
22
|
+
|
|
23
|
+
function ContactEditForm({
|
|
24
|
+
contact,
|
|
25
|
+
onSave,
|
|
26
|
+
onCancel,
|
|
27
|
+
onDelete,
|
|
28
|
+
isSaving,
|
|
29
|
+
}: {
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
|
+
contact: any;
|
|
32
|
+
onSave: (data: Record<string, unknown>) => void;
|
|
33
|
+
onCancel: () => void;
|
|
34
|
+
onDelete: () => void;
|
|
35
|
+
isSaving: boolean;
|
|
36
|
+
}) {
|
|
37
|
+
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
38
|
+
const [fields, setFields] = useState<Record<string, string>>({
|
|
39
|
+
first_name: contact.first_name ?? '',
|
|
40
|
+
last_name: contact.last_name ?? '',
|
|
41
|
+
email: contact.email ?? '',
|
|
42
|
+
phone: contact.phone ?? '',
|
|
43
|
+
company_name: contact.company_name ?? '',
|
|
44
|
+
title: contact.title ?? '',
|
|
45
|
+
lifecycle_stage: contact.lifecycle_stage ?? 'lead',
|
|
46
|
+
source: contact.source ?? '',
|
|
47
|
+
owner_id: contact.owner_id ?? '',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const [customFieldValues, setCustomFieldValues] = useState<Record<string, string>>(() => {
|
|
51
|
+
const init: Record<string, string> = {};
|
|
52
|
+
if (contact.custom_fields) {
|
|
53
|
+
for (const [k, v] of Object.entries(contact.custom_fields as Record<string, unknown>)) {
|
|
54
|
+
init[k] = String(v ?? '');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return init;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
61
|
+
const { data: usersData } = useUsers() as any;
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
63
|
+
const users: any[] = usersData?.data ?? [];
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
65
|
+
const { data: customFieldDefs } = useCustomFields('contact') as any;
|
|
66
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
67
|
+
const fieldDefs: any[] = customFieldDefs?.fields ?? [];
|
|
68
|
+
|
|
69
|
+
const set = (key: string, val: string) => setFields(prev => ({ ...prev, [key]: val }));
|
|
70
|
+
const setCF = (key: string, val: string) => setCustomFieldValues(prev => ({ ...prev, [key]: val }));
|
|
71
|
+
|
|
72
|
+
const handleSave = () => {
|
|
73
|
+
const payload: Record<string, unknown> = {};
|
|
74
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
75
|
+
if (v !== '') payload[k] = v;
|
|
76
|
+
}
|
|
77
|
+
const cfPayload: Record<string, unknown> = {};
|
|
78
|
+
for (const def of fieldDefs) {
|
|
79
|
+
const val = customFieldValues[def.field_key] ?? '';
|
|
80
|
+
if (val === '') continue;
|
|
81
|
+
if (def.field_type === 'number') cfPayload[def.field_key] = Number(val);
|
|
82
|
+
else if (def.field_type === 'boolean') cfPayload[def.field_key] = val === 'true';
|
|
83
|
+
else cfPayload[def.field_key] = val;
|
|
84
|
+
}
|
|
85
|
+
if (Object.keys(cfPayload).length > 0) payload.custom_fields = cfPayload;
|
|
86
|
+
onSave(payload);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className="flex flex-col h-full">
|
|
91
|
+
<div className="flex items-center gap-2 px-5 py-3 border-b border-border">
|
|
92
|
+
<button onClick={onCancel} className="flex items-center gap-1 text-xs text-accent hover:underline">
|
|
93
|
+
<ChevronLeft className="w-3.5 h-3.5" /> Back
|
|
94
|
+
</button>
|
|
95
|
+
<span className="text-xs text-muted-foreground ml-auto">Editing contact</span>
|
|
96
|
+
</div>
|
|
97
|
+
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
|
98
|
+
<div className="grid grid-cols-2 gap-4">
|
|
99
|
+
<div className="space-y-1.5">
|
|
100
|
+
<label className={labelClass}>First Name<span className="text-destructive ml-0.5">*</span></label>
|
|
101
|
+
<input type="text" value={fields.first_name} onChange={e => set('first_name', e.target.value)} placeholder="First name" className={inputClass} />
|
|
102
|
+
</div>
|
|
103
|
+
<div className="space-y-1.5">
|
|
104
|
+
<label className={labelClass}>Last Name</label>
|
|
105
|
+
<input type="text" value={fields.last_name} onChange={e => set('last_name', e.target.value)} placeholder="Last name" className={inputClass} />
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
{[
|
|
109
|
+
{ key: 'email', label: 'Email', type: 'email', placeholder: 'email@example.com' },
|
|
110
|
+
{ key: 'phone', label: 'Phone', type: 'tel', placeholder: '(555) 123-4567' },
|
|
111
|
+
{ key: 'company_name', label: 'Company', type: 'text', placeholder: 'Company name' },
|
|
112
|
+
{ key: 'title', label: 'Title', type: 'text', placeholder: 'Job title' },
|
|
113
|
+
{ key: 'source', label: 'Source', type: 'text', placeholder: 'e.g. inbound, referral' },
|
|
114
|
+
].map(f => (
|
|
115
|
+
<div key={f.key} className="space-y-1.5">
|
|
116
|
+
<label className={labelClass}>{f.label}</label>
|
|
117
|
+
<input type={f.type} value={fields[f.key]} onChange={e => set(f.key, e.target.value)} placeholder={f.placeholder} className={inputClass} />
|
|
118
|
+
</div>
|
|
119
|
+
))}
|
|
120
|
+
<div className="space-y-1.5">
|
|
121
|
+
<label className={labelClass}>Stage</label>
|
|
122
|
+
<select value={fields.lifecycle_stage} onChange={e => set('lifecycle_stage', e.target.value)} className={`${inputClass} pr-3`}>
|
|
123
|
+
{LIFECYCLE_STAGES.map(s => <option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>)}
|
|
124
|
+
</select>
|
|
125
|
+
</div>
|
|
126
|
+
{users.length > 0 && (
|
|
127
|
+
<div className="space-y-1.5">
|
|
128
|
+
<label className={labelClass}>Owner</label>
|
|
129
|
+
<select value={fields.owner_id} onChange={e => set('owner_id', e.target.value)} className={`${inputClass} pr-3`}>
|
|
130
|
+
<option value="">Unassigned</option>
|
|
131
|
+
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
132
|
+
{users.map((u: any) => (
|
|
133
|
+
<option key={u.id} value={u.id}>{u.name || u.email}</option>
|
|
134
|
+
))}
|
|
135
|
+
</select>
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
{fieldDefs.length > 0 && (
|
|
139
|
+
<>
|
|
140
|
+
<div className="border-t border-border pt-2">
|
|
141
|
+
<p className={labelClass}>Custom Fields</p>
|
|
142
|
+
</div>
|
|
143
|
+
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
144
|
+
{fieldDefs.map((def: any) => (
|
|
145
|
+
<div key={def.field_key} className="space-y-1.5">
|
|
146
|
+
<label className={labelClass}>{def.label}{def.required && <span className="text-destructive ml-0.5">*</span>}</label>
|
|
147
|
+
{(def.field_type === 'text' || !def.field_type) && (
|
|
148
|
+
<input type="text" value={customFieldValues[def.field_key] ?? ''} onChange={e => setCF(def.field_key, e.target.value)} className={inputClass} />
|
|
149
|
+
)}
|
|
150
|
+
{def.field_type === 'number' && (
|
|
151
|
+
<input type="number" value={customFieldValues[def.field_key] ?? ''} onChange={e => setCF(def.field_key, e.target.value)} className={inputClass} />
|
|
152
|
+
)}
|
|
153
|
+
{def.field_type === 'date' && (
|
|
154
|
+
<DatePicker
|
|
155
|
+
value={customFieldValues[def.field_key] ?? ''}
|
|
156
|
+
onChange={val => setCF(def.field_key, val)}
|
|
157
|
+
required={def.required}
|
|
158
|
+
/>
|
|
159
|
+
)}
|
|
160
|
+
{def.field_type === 'boolean' && (
|
|
161
|
+
<div className="flex items-center gap-2 h-10">
|
|
162
|
+
<input type="checkbox" checked={customFieldValues[def.field_key] === 'true'} onChange={e => setCF(def.field_key, e.target.checked ? 'true' : 'false')} className="w-4 h-4 rounded border-border accent-primary" />
|
|
163
|
+
<span className="text-sm text-foreground">Yes</span>
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
{(def.field_type === 'select' || def.field_type === 'multi_select') && (
|
|
167
|
+
<select value={customFieldValues[def.field_key] ?? ''} onChange={e => setCF(def.field_key, e.target.value)} className={`${inputClass} pr-3`}>
|
|
168
|
+
<option value="">Select…</option>
|
|
169
|
+
{(def.options ?? []).map((opt: string) => <option key={opt} value={opt}>{opt}</option>)}
|
|
170
|
+
</select>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
))}
|
|
174
|
+
</>
|
|
175
|
+
)}
|
|
176
|
+
<button
|
|
177
|
+
onClick={handleSave}
|
|
178
|
+
disabled={!fields.first_name.trim() || isSaving}
|
|
179
|
+
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"
|
|
180
|
+
>
|
|
181
|
+
{isSaving ? 'Saving…' : 'Save Changes'}
|
|
182
|
+
</button>
|
|
183
|
+
{!confirmDelete ? (
|
|
184
|
+
<button
|
|
185
|
+
onClick={() => setConfirmDelete(true)}
|
|
186
|
+
className="w-full h-9 rounded-md border border-destructive/40 text-destructive text-sm font-medium hover:bg-destructive/10 transition-colors flex items-center justify-center gap-1.5"
|
|
187
|
+
>
|
|
188
|
+
<Trash2 className="w-3.5 h-3.5" /> Delete Contact
|
|
189
|
+
</button>
|
|
190
|
+
) : (
|
|
191
|
+
<div className="flex gap-2">
|
|
192
|
+
<button onClick={() => setConfirmDelete(false)} className="flex-1 h-9 rounded-md border border-border text-sm text-muted-foreground hover:bg-muted/50 transition-colors">
|
|
193
|
+
Cancel
|
|
194
|
+
</button>
|
|
195
|
+
<button onClick={onDelete} className="flex-1 h-9 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 transition-colors">
|
|
196
|
+
Confirm Delete
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function ContactDrawer() {
|
|
206
|
+
const { drawerEntityId, openAIWithContext, closeDrawer } = useAppStore();
|
|
207
|
+
const { enabled: agentEnabled } = useAgentSettings();
|
|
208
|
+
const navigate = useNavigate();
|
|
209
|
+
const [editing, setEditing] = useState(false);
|
|
210
|
+
const [briefing, setBriefing] = useState(false);
|
|
211
|
+
const [noting, setNoting] = useState(false);
|
|
212
|
+
const [noteBody, setNoteBody] = useState('');
|
|
213
|
+
const noteRef = useRef<HTMLTextAreaElement>(null);
|
|
214
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
215
|
+
const { data: contactData, isLoading } = useContact(drawerEntityId ?? '') as any;
|
|
216
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
217
|
+
const { data: activitiesData } = useActivities({ contact_id: drawerEntityId ?? undefined, limit: 20 }) as any;
|
|
218
|
+
const activities: any[] = activitiesData?.data ?? [];
|
|
219
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
220
|
+
const { data: notesData } = useNotes({ object_type: 'contact', object_id: drawerEntityId ?? '' }) as any;
|
|
221
|
+
const notes: any[] = notesData?.data ?? [];
|
|
222
|
+
const createNote = useCreateNote();
|
|
223
|
+
const updateContact = useUpdateContact(drawerEntityId ?? '');
|
|
224
|
+
const deleteContact = useDeleteContact(drawerEntityId ?? '');
|
|
225
|
+
|
|
226
|
+
if (isLoading) {
|
|
227
|
+
return (
|
|
228
|
+
<div className="flex flex-col gap-4 p-6 animate-pulse">
|
|
229
|
+
<div className="flex gap-4">
|
|
230
|
+
<div className="w-14 h-14 rounded-2xl bg-muted" />
|
|
231
|
+
<div className="flex-1 space-y-2">
|
|
232
|
+
<div className="h-4 bg-muted rounded w-3/4" />
|
|
233
|
+
<div className="h-3 bg-muted rounded w-1/2" />
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!contactData?.contact) {
|
|
241
|
+
return <div className="p-4 text-muted-foreground">Contact not found</div>;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const contact = contactData.contact;
|
|
245
|
+
const name: string = [contact.first_name, contact.last_name].filter(Boolean).join(' ') || contact.email || '';
|
|
246
|
+
const company: string = contact.company_name ?? '';
|
|
247
|
+
const stage: string = contact.lifecycle_stage ?? '';
|
|
248
|
+
const leadScore: number = contact.lead_score ?? 0;
|
|
249
|
+
|
|
250
|
+
if (briefing) {
|
|
251
|
+
return <BriefingPanel subjectType="contact" subjectId={drawerEntityId!} onClose={() => setBriefing(false)} />;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (editing) {
|
|
255
|
+
return (
|
|
256
|
+
<ContactEditForm
|
|
257
|
+
contact={contact}
|
|
258
|
+
onSave={async (data) => {
|
|
259
|
+
try {
|
|
260
|
+
await updateContact.mutateAsync(data);
|
|
261
|
+
setEditing(false);
|
|
262
|
+
toast({ title: 'Contact updated' });
|
|
263
|
+
} catch (err) {
|
|
264
|
+
toast({ title: 'Failed to update contact', description: err instanceof Error ? err.message : 'Please try again.', variant: 'destructive' });
|
|
265
|
+
}
|
|
266
|
+
}}
|
|
267
|
+
onCancel={() => setEditing(false)}
|
|
268
|
+
onDelete={async () => {
|
|
269
|
+
try {
|
|
270
|
+
await deleteContact.mutateAsync();
|
|
271
|
+
closeDrawer();
|
|
272
|
+
toast({ title: 'Contact deleted' });
|
|
273
|
+
} catch (err) {
|
|
274
|
+
toast({ title: 'Failed to delete contact', description: err instanceof Error ? err.message : 'Please try again.', variant: 'destructive' });
|
|
275
|
+
}
|
|
276
|
+
}}
|
|
277
|
+
isSaving={updateContact.isPending}
|
|
278
|
+
/>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<div className="flex flex-col">
|
|
284
|
+
{/* Header */}
|
|
285
|
+
<div className="p-6 border-b border-border">
|
|
286
|
+
<div className="flex items-start gap-4">
|
|
287
|
+
<ContactAvatar name={name} className="w-14 h-14 rounded-2xl text-lg" />
|
|
288
|
+
<div className="flex-1">
|
|
289
|
+
<h2 className="font-display font-extrabold text-xl text-foreground">{name}</h2>
|
|
290
|
+
{company && <p className="text-sm text-muted-foreground">{company}</p>}
|
|
291
|
+
<div className="flex items-center gap-2 mt-2">
|
|
292
|
+
{stage && <StageBadge stage={stage} />}
|
|
293
|
+
{leadScore > 0 && <LeadScoreBadge score={leadScore} />}
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
<div className="flex gap-2 mt-4">
|
|
298
|
+
{contact.phone && (
|
|
299
|
+
<a
|
|
300
|
+
href={`tel:${contact.phone}`}
|
|
301
|
+
className="flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-primary text-primary-foreground text-sm font-semibold hover:bg-primary/90 transition-all press-scale"
|
|
302
|
+
>
|
|
303
|
+
<Phone className="w-3.5 h-3.5" /> Call
|
|
304
|
+
</a>
|
|
305
|
+
)}
|
|
306
|
+
{contact.email && (
|
|
307
|
+
<a
|
|
308
|
+
href={`mailto:${contact.email}`}
|
|
309
|
+
className="flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-muted text-foreground text-sm font-medium hover:bg-muted/80 transition-all press-scale"
|
|
310
|
+
>
|
|
311
|
+
<Mail className="w-3.5 h-3.5" /> Email
|
|
312
|
+
</a>
|
|
313
|
+
)}
|
|
314
|
+
<button
|
|
315
|
+
onClick={() => { setNoting(v => !v); setTimeout(() => noteRef.current?.focus(), 50); }}
|
|
316
|
+
className={`flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-sm font-medium transition-all press-scale ${noting ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground hover:bg-muted/80'}`}
|
|
317
|
+
>
|
|
318
|
+
<StickyNote className="w-3.5 h-3.5" /> Note
|
|
319
|
+
</button>
|
|
320
|
+
<button
|
|
321
|
+
onClick={() => setEditing(true)}
|
|
322
|
+
className="flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-muted text-foreground text-sm font-medium hover:bg-muted/80 transition-all press-scale"
|
|
323
|
+
>
|
|
324
|
+
<Pencil className="w-3.5 h-3.5" /> Edit
|
|
325
|
+
</button>
|
|
326
|
+
<button
|
|
327
|
+
onClick={() => setBriefing(true)}
|
|
328
|
+
className="flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-muted text-foreground text-sm font-medium hover:bg-muted/80 transition-all press-scale"
|
|
329
|
+
>
|
|
330
|
+
<FileText className="w-3.5 h-3.5" /> Brief
|
|
331
|
+
</button>
|
|
332
|
+
{agentEnabled && (
|
|
333
|
+
<button
|
|
334
|
+
onClick={() => {
|
|
335
|
+
openAIWithContext({ type: 'contact', id: contact.id, name, detail: company });
|
|
336
|
+
closeDrawer();
|
|
337
|
+
navigate('/agent');
|
|
338
|
+
}}
|
|
339
|
+
className="flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-accent/30 bg-accent/5 text-accent text-sm font-semibold hover:bg-accent/10 transition-all ml-auto press-scale"
|
|
340
|
+
>
|
|
341
|
+
<Sparkles className="w-3.5 h-3.5" /> Chat
|
|
342
|
+
</button>
|
|
343
|
+
)}
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
|
|
347
|
+
{/* Note compose panel */}
|
|
348
|
+
{noting && (
|
|
349
|
+
<div className="mx-4 mt-4 rounded-xl border border-border bg-card p-3 space-y-2">
|
|
350
|
+
<textarea
|
|
351
|
+
ref={noteRef}
|
|
352
|
+
value={noteBody}
|
|
353
|
+
onChange={e => setNoteBody(e.target.value)}
|
|
354
|
+
placeholder="Write a note…"
|
|
355
|
+
rows={3}
|
|
356
|
+
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"
|
|
357
|
+
/>
|
|
358
|
+
<div className="flex items-center justify-end gap-2">
|
|
359
|
+
<button onClick={() => { setNoting(false); setNoteBody(''); }} className="text-xs text-muted-foreground hover:text-foreground">Cancel</button>
|
|
360
|
+
<button
|
|
361
|
+
disabled={!noteBody.trim() || createNote.isPending}
|
|
362
|
+
onClick={async () => {
|
|
363
|
+
await createNote.mutateAsync({ object_type: 'contact', object_id: drawerEntityId, body: noteBody.trim() });
|
|
364
|
+
setNoteBody('');
|
|
365
|
+
setNoting(false);
|
|
366
|
+
toast({ title: 'Note saved' });
|
|
367
|
+
}}
|
|
368
|
+
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold disabled:opacity-40 transition-colors"
|
|
369
|
+
>
|
|
370
|
+
<Send className="w-3 h-3" /> Save
|
|
371
|
+
</button>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
)}
|
|
375
|
+
|
|
376
|
+
{/* Notes list */}
|
|
377
|
+
{notes.length > 0 && (
|
|
378
|
+
<div className="p-4 mx-4 mt-4 space-y-3">
|
|
379
|
+
<h3 className="text-xs font-display font-bold text-muted-foreground uppercase tracking-wide">Notes</h3>
|
|
380
|
+
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
381
|
+
{notes.map((note: any) => (
|
|
382
|
+
<div key={note.id} className="rounded-xl bg-muted/50 p-3 space-y-1">
|
|
383
|
+
<div className="flex items-center gap-1.5">
|
|
384
|
+
{note.pinned && <Pin className="w-3 h-3 text-accent" />}
|
|
385
|
+
<span className="text-[10px] text-muted-foreground ml-auto">
|
|
386
|
+
{new Date(note.created_at).toLocaleDateString()}
|
|
387
|
+
</span>
|
|
388
|
+
</div>
|
|
389
|
+
<p className="text-sm text-foreground whitespace-pre-wrap">{note.body}</p>
|
|
390
|
+
{note.author_type && (
|
|
391
|
+
<p className="text-[10px] text-muted-foreground capitalize">{note.author_type === 'agent' ? 'AI Agent' : note.author_type}</p>
|
|
392
|
+
)}
|
|
393
|
+
</div>
|
|
394
|
+
))}
|
|
395
|
+
</div>
|
|
396
|
+
)}
|
|
397
|
+
|
|
398
|
+
{/* Details */}
|
|
399
|
+
<div className="p-4 mx-4 mt-4 space-y-3">
|
|
400
|
+
<h3 className="text-xs font-display font-bold text-muted-foreground uppercase tracking-wide">Details</h3>
|
|
401
|
+
{[
|
|
402
|
+
{ label: 'Email', value: contact.email },
|
|
403
|
+
{ label: 'Phone', value: contact.phone },
|
|
404
|
+
{ label: 'Company', value: company },
|
|
405
|
+
{ label: 'Source', value: contact.source },
|
|
406
|
+
{ label: 'Last Contacted', value: contact.last_contacted_at ? new Date(contact.last_contacted_at).toLocaleDateString() : undefined },
|
|
407
|
+
{ label: 'Created', value: contact.created_at ? new Date(contact.created_at).toLocaleDateString() : undefined },
|
|
408
|
+
]
|
|
409
|
+
.filter((f) => f.value)
|
|
410
|
+
.map((field) => (
|
|
411
|
+
<div key={field.label} className="flex items-center justify-between">
|
|
412
|
+
<span className="text-xs text-muted-foreground">{field.label}</span>
|
|
413
|
+
<span className="text-sm text-foreground">{field.value}</span>
|
|
414
|
+
</div>
|
|
415
|
+
))}
|
|
416
|
+
{contact.tags && contact.tags.length > 0 && (
|
|
417
|
+
<div className="flex items-center gap-1.5 flex-wrap mt-2">
|
|
418
|
+
{(contact.tags as string[]).map((tag: string) => (
|
|
419
|
+
<span key={tag} className="px-2.5 py-1 rounded-lg bg-muted text-xs text-muted-foreground font-medium">{tag}</span>
|
|
420
|
+
))}
|
|
421
|
+
</div>
|
|
422
|
+
)}
|
|
423
|
+
</div>
|
|
424
|
+
|
|
425
|
+
{/* Custom Fields */}
|
|
426
|
+
<CustomFieldsSection objectType="contact" values={(contact.custom_fields ?? {}) as Record<string, unknown>} />
|
|
427
|
+
|
|
428
|
+
{/* Context */}
|
|
429
|
+
<ContextPanel subjectType="contact" subjectId={drawerEntityId!} />
|
|
430
|
+
|
|
431
|
+
{/* Timeline */}
|
|
432
|
+
<div className="p-4 mx-4 mt-4 mb-6">
|
|
433
|
+
<h3 className="text-xs font-display font-bold text-muted-foreground uppercase tracking-wide mb-3">Timeline</h3>
|
|
434
|
+
<ActivityTimeline activities={activities} />
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
437
|
+
);
|
|
438
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Copyright 2026 CRMy Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { useContextEntries, useReviewContextEntry, useStaleContextEntries } from '@/api/hooks';
|
|
6
|
+
import { Brain, AlertTriangle, CheckCircle2, ChevronDown, ChevronUp, Tag } from 'lucide-react';
|
|
7
|
+
import { toast } from '@/components/ui/use-toast';
|
|
8
|
+
|
|
9
|
+
interface ContextPanelProps {
|
|
10
|
+
subjectType: string;
|
|
11
|
+
subjectId: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ContextEntry {
|
|
15
|
+
id: string;
|
|
16
|
+
context_type: string;
|
|
17
|
+
title?: string;
|
|
18
|
+
body: string;
|
|
19
|
+
confidence?: number;
|
|
20
|
+
source?: string;
|
|
21
|
+
tags?: string[];
|
|
22
|
+
is_current: boolean;
|
|
23
|
+
valid_until?: string;
|
|
24
|
+
reviewed_at?: string;
|
|
25
|
+
created_at: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const TYPE_COLORS: Record<string, string> = {
|
|
29
|
+
note: '#6366f1',
|
|
30
|
+
research: '#8b5cf6',
|
|
31
|
+
preference: '#ec4899',
|
|
32
|
+
objection: '#ef4444',
|
|
33
|
+
competitive_intel: '#f97316',
|
|
34
|
+
relationship_map: '#14b8a6',
|
|
35
|
+
meeting_notes: '#3b82f6',
|
|
36
|
+
summary: '#06b6d4',
|
|
37
|
+
transcript: '#64748b',
|
|
38
|
+
agent_reasoning: '#a855f7',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function ContextPanel({ subjectType, subjectId }: ContextPanelProps) {
|
|
42
|
+
const [expanded, setExpanded] = useState(true);
|
|
43
|
+
const [activeType, setActiveType] = useState<string | null>(null);
|
|
44
|
+
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
46
|
+
const { data: contextData, isLoading } = useContextEntries({ subject_type: subjectType, subject_id: subjectId, is_current: true, limit: 50 }) as any;
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
48
|
+
const { data: staleData } = useStaleContextEntries({ subject_type: subjectType, subject_id: subjectId, limit: 10 }) as any;
|
|
49
|
+
const reviewMutation = useReviewContextEntry();
|
|
50
|
+
|
|
51
|
+
const entries: ContextEntry[] = contextData?.data ?? [];
|
|
52
|
+
const staleEntries: ContextEntry[] = staleData?.data ?? [];
|
|
53
|
+
|
|
54
|
+
// Group entries by context_type
|
|
55
|
+
const grouped = entries.reduce<Record<string, ContextEntry[]>>((acc, entry) => {
|
|
56
|
+
const type = entry.context_type ?? 'note';
|
|
57
|
+
if (!acc[type]) acc[type] = [];
|
|
58
|
+
acc[type].push(entry);
|
|
59
|
+
return acc;
|
|
60
|
+
}, {});
|
|
61
|
+
|
|
62
|
+
const types = Object.keys(grouped).sort();
|
|
63
|
+
const displayed = activeType ? (grouped[activeType] ?? []) : entries;
|
|
64
|
+
|
|
65
|
+
const handleReview = async (id: string) => {
|
|
66
|
+
await reviewMutation.mutateAsync(id);
|
|
67
|
+
toast({ title: 'Context entry reviewed' });
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (isLoading) {
|
|
71
|
+
return (
|
|
72
|
+
<div className="space-y-2 animate-pulse">
|
|
73
|
+
<div className="h-4 bg-muted rounded w-1/3" />
|
|
74
|
+
<div className="h-16 bg-muted rounded" />
|
|
75
|
+
<div className="h-16 bg-muted rounded" />
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (entries.length === 0 && staleEntries.length === 0) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="px-4 mx-4 mt-4">
|
|
86
|
+
<button
|
|
87
|
+
onClick={() => setExpanded(!expanded)}
|
|
88
|
+
className="flex items-center gap-1.5 w-full text-left mb-2"
|
|
89
|
+
>
|
|
90
|
+
<Brain className="w-3.5 h-3.5 text-primary" />
|
|
91
|
+
<h3 className="text-xs font-display font-bold text-muted-foreground uppercase tracking-wide flex-1">
|
|
92
|
+
Context
|
|
93
|
+
<span className="ml-1.5 font-mono font-normal text-muted-foreground/50">({entries.length})</span>
|
|
94
|
+
</h3>
|
|
95
|
+
{staleEntries.length > 0 && (
|
|
96
|
+
<span className="flex items-center gap-1 text-[10px] text-warning font-medium">
|
|
97
|
+
<AlertTriangle className="w-3 h-3" /> {staleEntries.length} stale
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
{expanded ? <ChevronUp className="w-3.5 h-3.5 text-muted-foreground" /> : <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />}
|
|
101
|
+
</button>
|
|
102
|
+
|
|
103
|
+
{expanded && (
|
|
104
|
+
<div className="space-y-3">
|
|
105
|
+
{/* Type filter tabs */}
|
|
106
|
+
{types.length > 1 && (
|
|
107
|
+
<div className="flex gap-1 flex-wrap">
|
|
108
|
+
<button
|
|
109
|
+
onClick={() => setActiveType(null)}
|
|
110
|
+
className={`px-2 py-0.5 rounded-md text-[10px] font-semibold transition-all ${!activeType ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground hover:text-foreground'}`}
|
|
111
|
+
>
|
|
112
|
+
All
|
|
113
|
+
</button>
|
|
114
|
+
{types.map(type => (
|
|
115
|
+
<button
|
|
116
|
+
key={type}
|
|
117
|
+
onClick={() => setActiveType(activeType === type ? null : type)}
|
|
118
|
+
className={`px-2 py-0.5 rounded-md text-[10px] font-semibold transition-all ${activeType === type ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground hover:text-foreground'}`}
|
|
119
|
+
>
|
|
120
|
+
{type.replace(/_/g, ' ')}
|
|
121
|
+
<span className="ml-1 opacity-50">{grouped[type].length}</span>
|
|
122
|
+
</button>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
{/* Stale warnings */}
|
|
128
|
+
{staleEntries.length > 0 && !activeType && (
|
|
129
|
+
<div className="rounded-xl border border-warning/30 bg-warning/5 p-3 space-y-2">
|
|
130
|
+
<p className="text-[10px] font-semibold text-warning uppercase tracking-wide">Needs Review</p>
|
|
131
|
+
{staleEntries.map(entry => (
|
|
132
|
+
<div key={entry.id} className="flex items-start gap-2">
|
|
133
|
+
<div className="flex-1 min-w-0">
|
|
134
|
+
<p className="text-xs text-foreground truncate">{entry.title ?? entry.context_type}</p>
|
|
135
|
+
<p className="text-[10px] text-muted-foreground">
|
|
136
|
+
Expired {entry.valid_until ? new Date(entry.valid_until).toLocaleDateString() : ''}
|
|
137
|
+
</p>
|
|
138
|
+
</div>
|
|
139
|
+
<button
|
|
140
|
+
onClick={() => handleReview(entry.id)}
|
|
141
|
+
disabled={reviewMutation.isPending}
|
|
142
|
+
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-medium bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
|
143
|
+
>
|
|
144
|
+
<CheckCircle2 className="w-3 h-3" /> Review
|
|
145
|
+
</button>
|
|
146
|
+
</div>
|
|
147
|
+
))}
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
{/* Context entries */}
|
|
152
|
+
{displayed.map(entry => {
|
|
153
|
+
const color = TYPE_COLORS[entry.context_type] ?? '#94a3b8';
|
|
154
|
+
const isStale = entry.valid_until && new Date(entry.valid_until) < new Date();
|
|
155
|
+
return (
|
|
156
|
+
<div
|
|
157
|
+
key={entry.id}
|
|
158
|
+
className={`rounded-xl border p-3 space-y-1.5 ${isStale ? 'border-warning/30 bg-warning/5' : 'border-border bg-card'}`}
|
|
159
|
+
>
|
|
160
|
+
<div className="flex items-center gap-2">
|
|
161
|
+
<span
|
|
162
|
+
className="px-1.5 py-0.5 rounded text-[10px] font-semibold capitalize"
|
|
163
|
+
style={{ backgroundColor: color + '18', color }}
|
|
164
|
+
>
|
|
165
|
+
{entry.context_type.replace(/_/g, ' ')}
|
|
166
|
+
</span>
|
|
167
|
+
{entry.confidence != null && (
|
|
168
|
+
<span className="text-[10px] text-muted-foreground font-mono">
|
|
169
|
+
{Math.round(entry.confidence * 100)}%
|
|
170
|
+
</span>
|
|
171
|
+
)}
|
|
172
|
+
<span className="text-[10px] text-muted-foreground ml-auto">
|
|
173
|
+
{new Date(entry.created_at).toLocaleDateString()}
|
|
174
|
+
</span>
|
|
175
|
+
</div>
|
|
176
|
+
{entry.title && (
|
|
177
|
+
<p className="text-sm font-medium text-foreground">{entry.title}</p>
|
|
178
|
+
)}
|
|
179
|
+
<p className="text-xs text-muted-foreground line-clamp-3 whitespace-pre-wrap">
|
|
180
|
+
{entry.body}
|
|
181
|
+
</p>
|
|
182
|
+
{entry.tags && entry.tags.length > 0 && (
|
|
183
|
+
<div className="flex items-center gap-1 flex-wrap">
|
|
184
|
+
<Tag className="w-2.5 h-2.5 text-muted-foreground" />
|
|
185
|
+
{entry.tags.map(tag => (
|
|
186
|
+
<span key={tag} className="px-1.5 py-0.5 rounded bg-muted text-[10px] text-muted-foreground">{tag}</span>
|
|
187
|
+
))}
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
{entry.source && (
|
|
191
|
+
<p className="text-[10px] text-muted-foreground/60">Source: {entry.source}</p>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
})}
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|