@crmy/web 0.5.5 → 0.5.9
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/dist/assets/index-CskfWp8E.js +560 -0
- package/dist/assets/index-D763l57m.css +1 -0
- package/{index.html → dist/index.html} +2 -1
- package/package.json +4 -1
- package/postcss.config.js +0 -6
- package/src/App.tsx +0 -158
- package/src/api/client.ts +0 -82
- package/src/api/hooks.ts +0 -689
- package/src/components/CustomFields.tsx +0 -240
- package/src/components/NavLink.tsx +0 -28
- package/src/components/crm/AIFab.tsx +0 -37
- package/src/components/crm/AccountDrawer.tsx +0 -372
- package/src/components/crm/ActivityTimeline.tsx +0 -115
- package/src/components/crm/AssignmentDrawer.tsx +0 -396
- package/src/components/crm/BriefingPanel.tsx +0 -217
- package/src/components/crm/CommandPalette.tsx +0 -254
- package/src/components/crm/ContactAvatar.tsx +0 -49
- package/src/components/crm/ContactDrawer.tsx +0 -438
- package/src/components/crm/ContextPanel.tsx +0 -200
- package/src/components/crm/CrmWidgets.tsx +0 -417
- package/src/components/crm/DrawerShell.tsx +0 -77
- package/src/components/crm/ListToolbar.tsx +0 -252
- package/src/components/crm/OpportunityDrawer.tsx +0 -372
- package/src/components/crm/PaginationBar.tsx +0 -111
- package/src/components/crm/QuickAddDrawer.tsx +0 -652
- package/src/components/crm/ShortcutsOverlay.tsx +0 -65
- package/src/components/crm/UseCaseDrawer.tsx +0 -454
- package/src/components/layout/MobileNav.tsx +0 -49
- package/src/components/layout/Sidebar.tsx +0 -157
- package/src/components/layout/TopBar.tsx +0 -54
- package/src/components/settings/ActorsSettings.tsx +0 -1190
- package/src/components/ui/accordion.tsx +0 -52
- package/src/components/ui/alert-dialog.tsx +0 -104
- package/src/components/ui/alert.tsx +0 -43
- package/src/components/ui/aspect-ratio.tsx +0 -5
- package/src/components/ui/avatar.tsx +0 -38
- package/src/components/ui/badge.tsx +0 -29
- package/src/components/ui/breadcrumb.tsx +0 -90
- package/src/components/ui/button.tsx +0 -47
- package/src/components/ui/calendar.tsx +0 -54
- package/src/components/ui/card.tsx +0 -43
- package/src/components/ui/carousel.tsx +0 -224
- package/src/components/ui/chart.tsx +0 -303
- package/src/components/ui/checkbox.tsx +0 -26
- package/src/components/ui/collapsible.tsx +0 -9
- package/src/components/ui/command.tsx +0 -132
- package/src/components/ui/context-menu.tsx +0 -178
- package/src/components/ui/date-picker.tsx +0 -313
- package/src/components/ui/dialog.tsx +0 -95
- package/src/components/ui/drawer.tsx +0 -87
- package/src/components/ui/dropdown-menu.tsx +0 -179
- package/src/components/ui/form.tsx +0 -129
- package/src/components/ui/hover-card.tsx +0 -27
- package/src/components/ui/input-otp.tsx +0 -61
- package/src/components/ui/input.tsx +0 -22
- package/src/components/ui/label.tsx +0 -17
- package/src/components/ui/menubar.tsx +0 -207
- package/src/components/ui/navigation-menu.tsx +0 -120
- package/src/components/ui/pagination.tsx +0 -81
- package/src/components/ui/popover.tsx +0 -29
- package/src/components/ui/progress.tsx +0 -23
- package/src/components/ui/radio-group.tsx +0 -36
- package/src/components/ui/resizable.tsx +0 -37
- package/src/components/ui/scroll-area.tsx +0 -38
- package/src/components/ui/select.tsx +0 -143
- package/src/components/ui/separator.tsx +0 -20
- package/src/components/ui/sheet.tsx +0 -107
- package/src/components/ui/sidebar.tsx +0 -637
- package/src/components/ui/skeleton.tsx +0 -7
- package/src/components/ui/slider.tsx +0 -23
- package/src/components/ui/sonner.tsx +0 -24
- package/src/components/ui/switch.tsx +0 -27
- package/src/components/ui/table.tsx +0 -72
- package/src/components/ui/tabs.tsx +0 -53
- package/src/components/ui/textarea.tsx +0 -21
- package/src/components/ui/toast.tsx +0 -111
- package/src/components/ui/toaster.tsx +0 -24
- package/src/components/ui/toggle-group.tsx +0 -49
- package/src/components/ui/toggle.tsx +0 -37
- package/src/components/ui/tooltip.tsx +0 -28
- package/src/components/ui/use-toast.ts +0 -1
- package/src/components/ui/utils.ts +0 -9
- package/src/contexts/AgentSettingsContext.tsx +0 -24
- package/src/hooks/use-mobile.tsx +0 -19
- package/src/hooks/use-toast.ts +0 -186
- package/src/hooks/useKeyboardShortcuts.ts +0 -95
- package/src/hooks/useTheme.ts +0 -24
- package/src/index.css +0 -245
- package/src/lib/entityColors.ts +0 -18
- package/src/lib/stageConfig.ts +0 -32
- package/src/lib/utils.ts +0 -6
- package/src/main.tsx +0 -25
- package/src/pages/Accounts.tsx +0 -205
- package/src/pages/Activities.tsx +0 -251
- package/src/pages/Agent.tsx +0 -237
- package/src/pages/AgentSettings.tsx +0 -544
- package/src/pages/Assignments.tsx +0 -750
- package/src/pages/Contacts.tsx +0 -200
- package/src/pages/Dashboard.tsx +0 -143
- package/src/pages/Inbox.tsx +0 -615
- package/src/pages/NotFound.tsx +0 -24
- package/src/pages/Opportunities.tsx +0 -386
- package/src/pages/SearchResults.tsx +0 -49
- package/src/pages/Settings.tsx +0 -1884
- package/src/pages/UseCases.tsx +0 -396
- package/src/pages/auth/Login.tsx +0 -261
- package/src/pages/hitl/HITL.tsx +0 -101
- package/src/store/appStore.ts +0 -103
- package/src/vite-env.d.ts +0 -14
- package/tailwind.config.js +0 -121
- package/tsconfig.json +0 -24
- package/vite.config.ts +0 -27
- /package/{public → dist}/android-chrome-192x192.png +0 -0
- /package/{public → dist}/android-chrome-512x512.png +0 -0
- /package/{public → dist}/apple-touch-icon.png +0 -0
- /package/{src/assets/crmy-logo.png → dist/assets/crmy-logo-DWN0xBPW.png} +0 -0
- /package/{public → dist}/favicon-16x16.png +0 -0
- /package/{public → dist}/favicon-32x32.png +0 -0
- /package/{public → dist}/favicon.ico +0 -0
- /package/{public → dist}/favicon.svg +0 -0
- /package/{public → dist}/site.webmanifest +0 -0
|
@@ -1,240 +0,0 @@
|
|
|
1
|
-
// Copyright 2026 CRMy Contributors
|
|
2
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
-
|
|
4
|
-
import { Input } from './ui/input';
|
|
5
|
-
import { Select } from './ui/select';
|
|
6
|
-
import { Badge } from './ui/badge';
|
|
7
|
-
import { useCustomFields } from '../api/hooks';
|
|
8
|
-
import { DatePicker } from './ui/date-picker';
|
|
9
|
-
|
|
10
|
-
interface CustomFieldDef {
|
|
11
|
-
id: string;
|
|
12
|
-
field_key: string;
|
|
13
|
-
field_name?: string;
|
|
14
|
-
label: string;
|
|
15
|
-
field_type: string;
|
|
16
|
-
options?: string[] | string;
|
|
17
|
-
is_required?: boolean;
|
|
18
|
-
required?: boolean;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function parseOptions(options: unknown): string[] {
|
|
22
|
-
if (!options) return [];
|
|
23
|
-
if (Array.isArray(options)) return options.map(String);
|
|
24
|
-
if (typeof options === 'string') {
|
|
25
|
-
try { return JSON.parse(options); } catch { return []; }
|
|
26
|
-
}
|
|
27
|
-
return [];
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Renders dynamic form inputs for custom fields defined on an object type.
|
|
32
|
-
* Integrates into create/edit forms.
|
|
33
|
-
*/
|
|
34
|
-
export function CustomFieldsForm({
|
|
35
|
-
objectType,
|
|
36
|
-
values,
|
|
37
|
-
onChange,
|
|
38
|
-
}: {
|
|
39
|
-
objectType: string;
|
|
40
|
-
values: Record<string, unknown>;
|
|
41
|
-
onChange: (values: Record<string, unknown>) => void;
|
|
42
|
-
}) {
|
|
43
|
-
const { data, isLoading } = useCustomFields(objectType);
|
|
44
|
-
const fields: CustomFieldDef[] = (data as any)?.data ?? (data as any)?.fields ?? [];
|
|
45
|
-
|
|
46
|
-
if (isLoading || fields.length === 0) return null;
|
|
47
|
-
|
|
48
|
-
const setValue = (key: string, value: unknown) => {
|
|
49
|
-
onChange({ ...values, [key]: value });
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
return (
|
|
53
|
-
<div className="space-y-4">
|
|
54
|
-
<h3 className="text-sm font-medium text-muted-foreground">Custom Fields</h3>
|
|
55
|
-
{fields.map((field) => {
|
|
56
|
-
const key = field.field_key ?? field.field_name ?? field.id;
|
|
57
|
-
const isRequired = field.is_required ?? field.required ?? false;
|
|
58
|
-
const opts = parseOptions(field.options);
|
|
59
|
-
|
|
60
|
-
return (
|
|
61
|
-
<div key={field.id}>
|
|
62
|
-
<label className="mb-1 block text-sm font-medium">
|
|
63
|
-
{field.label}{isRequired ? ' *' : ''}
|
|
64
|
-
</label>
|
|
65
|
-
{renderFieldInput(field.field_type, key, values[key], opts, isRequired, setValue)}
|
|
66
|
-
</div>
|
|
67
|
-
);
|
|
68
|
-
})}
|
|
69
|
-
</div>
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function renderFieldInput(
|
|
74
|
-
fieldType: string,
|
|
75
|
-
key: string,
|
|
76
|
-
value: unknown,
|
|
77
|
-
options: string[],
|
|
78
|
-
required: boolean,
|
|
79
|
-
setValue: (key: string, value: unknown) => void,
|
|
80
|
-
) {
|
|
81
|
-
switch (fieldType) {
|
|
82
|
-
case 'text':
|
|
83
|
-
return (
|
|
84
|
-
<Input
|
|
85
|
-
value={(value as string) ?? ''}
|
|
86
|
-
onChange={(e) => setValue(key, e.target.value)}
|
|
87
|
-
required={required}
|
|
88
|
-
/>
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
case 'number':
|
|
92
|
-
return (
|
|
93
|
-
<Input
|
|
94
|
-
type="number"
|
|
95
|
-
value={value != null ? String(value) : ''}
|
|
96
|
-
onChange={(e) => setValue(key, e.target.value ? Number(e.target.value) : null)}
|
|
97
|
-
required={required}
|
|
98
|
-
/>
|
|
99
|
-
);
|
|
100
|
-
|
|
101
|
-
case 'boolean':
|
|
102
|
-
return (
|
|
103
|
-
<label className="flex items-center gap-2">
|
|
104
|
-
<input
|
|
105
|
-
type="checkbox"
|
|
106
|
-
checked={Boolean(value)}
|
|
107
|
-
onChange={(e) => setValue(key, e.target.checked)}
|
|
108
|
-
className="h-4 w-4 rounded border-input"
|
|
109
|
-
/>
|
|
110
|
-
<span className="text-sm">Yes</span>
|
|
111
|
-
</label>
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
case 'date':
|
|
115
|
-
return (
|
|
116
|
-
<DatePicker
|
|
117
|
-
value={(value as string) ?? ''}
|
|
118
|
-
onChange={(v) => setValue(key, v || null)}
|
|
119
|
-
required={required}
|
|
120
|
-
/>
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
case 'select':
|
|
124
|
-
return (
|
|
125
|
-
<select
|
|
126
|
-
value={(value as string) ?? ''}
|
|
127
|
-
onChange={(e) => setValue(key, e.target.value || null)}
|
|
128
|
-
required={required}
|
|
129
|
-
className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
|
130
|
-
>
|
|
131
|
-
<option value="">Select...</option>
|
|
132
|
-
{options.map((opt) => (
|
|
133
|
-
<option key={opt} value={opt}>{opt}</option>
|
|
134
|
-
))}
|
|
135
|
-
</select>
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
case 'multi_select':
|
|
139
|
-
return (
|
|
140
|
-
<MultiSelectInput
|
|
141
|
-
options={options}
|
|
142
|
-
value={Array.isArray(value) ? value as string[] : []}
|
|
143
|
-
onChange={(v) => setValue(key, v)}
|
|
144
|
-
/>
|
|
145
|
-
);
|
|
146
|
-
|
|
147
|
-
default:
|
|
148
|
-
return (
|
|
149
|
-
<Input
|
|
150
|
-
value={(value as string) ?? ''}
|
|
151
|
-
onChange={(e) => setValue(key, e.target.value)}
|
|
152
|
-
/>
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function MultiSelectInput({
|
|
158
|
-
options,
|
|
159
|
-
value,
|
|
160
|
-
onChange,
|
|
161
|
-
}: {
|
|
162
|
-
options: string[];
|
|
163
|
-
value: string[];
|
|
164
|
-
onChange: (value: string[]) => void;
|
|
165
|
-
}) {
|
|
166
|
-
const toggle = (opt: string) => {
|
|
167
|
-
if (value.includes(opt)) {
|
|
168
|
-
onChange(value.filter((v) => v !== opt));
|
|
169
|
-
} else {
|
|
170
|
-
onChange([...value, opt]);
|
|
171
|
-
}
|
|
172
|
-
};
|
|
173
|
-
|
|
174
|
-
return (
|
|
175
|
-
<div className="flex flex-wrap gap-2">
|
|
176
|
-
{options.map((opt) => (
|
|
177
|
-
<button
|
|
178
|
-
key={opt}
|
|
179
|
-
type="button"
|
|
180
|
-
onClick={() => toggle(opt)}
|
|
181
|
-
className="cursor-pointer"
|
|
182
|
-
>
|
|
183
|
-
<Badge variant={value.includes(opt) ? 'default' : 'outline'}>
|
|
184
|
-
{opt}
|
|
185
|
-
</Badge>
|
|
186
|
-
</button>
|
|
187
|
-
))}
|
|
188
|
-
</div>
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Displays custom field values on a detail page.
|
|
194
|
-
*/
|
|
195
|
-
export function CustomFieldsDisplay({
|
|
196
|
-
objectType,
|
|
197
|
-
customFields,
|
|
198
|
-
}: {
|
|
199
|
-
objectType: string;
|
|
200
|
-
customFields?: Record<string, unknown>;
|
|
201
|
-
}) {
|
|
202
|
-
const { data } = useCustomFields(objectType);
|
|
203
|
-
const defs: CustomFieldDef[] = (data as any)?.data ?? (data as any)?.fields ?? [];
|
|
204
|
-
|
|
205
|
-
if (!customFields || Object.keys(customFields).length === 0 || defs.length === 0) return null;
|
|
206
|
-
|
|
207
|
-
return (
|
|
208
|
-
<div className="space-y-2">
|
|
209
|
-
{defs.map((def) => {
|
|
210
|
-
const key = def.field_key ?? def.field_name ?? def.id;
|
|
211
|
-
const value = customFields[key];
|
|
212
|
-
if (value === undefined || value === null) return null;
|
|
213
|
-
|
|
214
|
-
return (
|
|
215
|
-
<div key={def.id} className="flex justify-between">
|
|
216
|
-
<span className="text-muted-foreground">{def.label}</span>
|
|
217
|
-
<span>{formatFieldValue(def.field_type, value)}</span>
|
|
218
|
-
</div>
|
|
219
|
-
);
|
|
220
|
-
})}
|
|
221
|
-
</div>
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function formatFieldValue(fieldType: string, value: unknown): string {
|
|
226
|
-
if (value === null || value === undefined) return '—';
|
|
227
|
-
|
|
228
|
-
switch (fieldType) {
|
|
229
|
-
case 'boolean':
|
|
230
|
-
return value ? 'Yes' : 'No';
|
|
231
|
-
case 'date':
|
|
232
|
-
return typeof value === 'string' ? new Date(value).toLocaleDateString() : String(value);
|
|
233
|
-
case 'multi_select':
|
|
234
|
-
return Array.isArray(value) ? value.join(', ') : String(value);
|
|
235
|
-
case 'number':
|
|
236
|
-
return typeof value === 'number' ? value.toLocaleString() : String(value);
|
|
237
|
-
default:
|
|
238
|
-
return String(value);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom";
|
|
2
|
-
import { forwardRef } from "react";
|
|
3
|
-
import { cn } from "@/lib/utils";
|
|
4
|
-
|
|
5
|
-
interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
|
|
6
|
-
className?: string;
|
|
7
|
-
activeClassName?: string;
|
|
8
|
-
pendingClassName?: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
|
|
12
|
-
({ className, activeClassName, pendingClassName, to, ...props }, ref) => {
|
|
13
|
-
return (
|
|
14
|
-
<RouterNavLink
|
|
15
|
-
ref={ref}
|
|
16
|
-
to={to}
|
|
17
|
-
className={({ isActive, isPending }) =>
|
|
18
|
-
cn(className, isActive && activeClassName, isPending && pendingClassName)
|
|
19
|
-
}
|
|
20
|
-
{...props}
|
|
21
|
-
/>
|
|
22
|
-
);
|
|
23
|
-
},
|
|
24
|
-
);
|
|
25
|
-
|
|
26
|
-
NavLink.displayName = "NavLink";
|
|
27
|
-
|
|
28
|
-
export { NavLink };
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { useLocation, useNavigate } from 'react-router-dom';
|
|
2
|
-
import { AnimatePresence, motion } from 'framer-motion';
|
|
3
|
-
import { Sparkles, Command } from 'lucide-react';
|
|
4
|
-
import { useAgentSettings } from '@/contexts/AgentSettingsContext';
|
|
5
|
-
|
|
6
|
-
export function AIFab() {
|
|
7
|
-
const location = useLocation();
|
|
8
|
-
const navigate = useNavigate();
|
|
9
|
-
const { enabled } = useAgentSettings();
|
|
10
|
-
|
|
11
|
-
if (location.pathname === '/agent') return null;
|
|
12
|
-
|
|
13
|
-
return (
|
|
14
|
-
<AnimatePresence>
|
|
15
|
-
{enabled && (
|
|
16
|
-
<motion.div
|
|
17
|
-
initial={{ opacity: 0, scale: 0.8 }}
|
|
18
|
-
animate={{ opacity: 1, scale: 1 }}
|
|
19
|
-
exit={{ opacity: 0, scale: 0.8 }}
|
|
20
|
-
className="fixed z-[60] bottom-20 md:bottom-6 right-4 md:right-6 flex flex-col items-center gap-1.5"
|
|
21
|
-
>
|
|
22
|
-
<motion.button
|
|
23
|
-
onClick={() => navigate('/agent')}
|
|
24
|
-
whileHover={{ scale: 1.05 }}
|
|
25
|
-
whileTap={{ scale: 0.95 }}
|
|
26
|
-
className="w-14 h-14 rounded-2xl flex items-center justify-center shadow-lg bg-gradient-to-br from-primary to-accent text-primary-foreground animate-fab-glow"
|
|
27
|
-
>
|
|
28
|
-
<Sparkles className="w-5 h-5" />
|
|
29
|
-
</motion.button>
|
|
30
|
-
<span className="hidden md:inline-flex items-center gap-0.5 text-[10px] font-mono text-muted-foreground bg-card/80 backdrop-blur-sm border border-border px-1.5 py-0.5 rounded-md shadow-sm">
|
|
31
|
-
<Command className="w-2.5 h-2.5" />J
|
|
32
|
-
</span>
|
|
33
|
-
</motion.div>
|
|
34
|
-
)}
|
|
35
|
-
</AnimatePresence>
|
|
36
|
-
);
|
|
37
|
-
}
|
|
@@ -1,372 +0,0 @@
|
|
|
1
|
-
// Copyright 2026 CRMy Contributors
|
|
2
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
-
|
|
4
|
-
import { useState } from 'react';
|
|
5
|
-
import { useAccount, useUpdateAccount, useDeleteAccount, useUsers, useCustomFields } 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 { Sparkles, Globe, Users, DollarSign, Heart, Pencil, ChevronLeft, Trash2, FileText } from 'lucide-react';
|
|
11
|
-
import { ContextPanel } from './ContextPanel';
|
|
12
|
-
import { BriefingPanel } from './BriefingPanel';
|
|
13
|
-
import { CustomFieldsSection } from './CrmWidgets';
|
|
14
|
-
import { toast } from '@/components/ui/use-toast';
|
|
15
|
-
import { DatePicker } from '@/components/ui/date-picker';
|
|
16
|
-
|
|
17
|
-
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';
|
|
18
|
-
const labelClass = 'text-xs font-mono text-muted-foreground uppercase tracking-wider';
|
|
19
|
-
|
|
20
|
-
function HealthBadge({ score }: { score: number }) {
|
|
21
|
-
const color = score >= 80 ? 'text-green-400 bg-green-500/15' : score >= 50 ? 'text-yellow-400 bg-yellow-500/15' : 'text-red-400 bg-red-500/15';
|
|
22
|
-
return (
|
|
23
|
-
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold ${color}`}>
|
|
24
|
-
<Heart className="w-3 h-3" /> {score}
|
|
25
|
-
</span>
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function formatRevenue(revenue: number) {
|
|
30
|
-
if (revenue >= 1_000_000) return `$${(revenue / 1_000_000).toFixed(1)}M`;
|
|
31
|
-
if (revenue >= 1_000) return `$${(revenue / 1_000).toFixed(0)}K`;
|
|
32
|
-
return `$${revenue}`;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function AccountEditForm({
|
|
36
|
-
account,
|
|
37
|
-
onSave,
|
|
38
|
-
onCancel,
|
|
39
|
-
onDelete,
|
|
40
|
-
isSaving,
|
|
41
|
-
}: {
|
|
42
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
43
|
-
account: any;
|
|
44
|
-
onSave: (data: Record<string, unknown>) => void;
|
|
45
|
-
onCancel: () => void;
|
|
46
|
-
onDelete: () => void;
|
|
47
|
-
isSaving: boolean;
|
|
48
|
-
}) {
|
|
49
|
-
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
50
|
-
const [fields, setFields] = useState<Record<string, string>>({
|
|
51
|
-
name: account.name ?? '',
|
|
52
|
-
industry: account.industry ?? '',
|
|
53
|
-
website: account.website ?? '',
|
|
54
|
-
domain: account.domain ?? '',
|
|
55
|
-
employee_count: account.employee_count != null ? String(account.employee_count) : '',
|
|
56
|
-
annual_revenue: account.annual_revenue != null ? String(account.annual_revenue) : '',
|
|
57
|
-
health_score: account.health_score != null ? String(account.health_score) : '',
|
|
58
|
-
owner_id: account.owner_id ?? '',
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
const [customFieldValues, setCustomFieldValues] = useState<Record<string, string>>(() => {
|
|
62
|
-
const init: Record<string, string> = {};
|
|
63
|
-
if (account.custom_fields) {
|
|
64
|
-
for (const [k, v] of Object.entries(account.custom_fields as Record<string, unknown>)) {
|
|
65
|
-
init[k] = String(v ?? '');
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return init;
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
72
|
-
const { data: usersData } = useUsers() as any;
|
|
73
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
74
|
-
const users: any[] = usersData?.data ?? [];
|
|
75
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
76
|
-
const { data: customFieldDefs } = useCustomFields('account') as any;
|
|
77
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
78
|
-
const fieldDefs: any[] = customFieldDefs?.fields ?? [];
|
|
79
|
-
|
|
80
|
-
const set = (key: string, val: string) => setFields(prev => ({ ...prev, [key]: val }));
|
|
81
|
-
const setCF = (key: string, val: string) => setCustomFieldValues(prev => ({ ...prev, [key]: val }));
|
|
82
|
-
|
|
83
|
-
const handleSave = () => {
|
|
84
|
-
const payload: Record<string, unknown> = {};
|
|
85
|
-
for (const [k, v] of Object.entries(fields)) {
|
|
86
|
-
if (v === '') continue;
|
|
87
|
-
if (k === 'employee_count' || k === 'annual_revenue' || k === 'health_score') payload[k] = Number(v) || 0;
|
|
88
|
-
else payload[k] = v;
|
|
89
|
-
}
|
|
90
|
-
const cfPayload: Record<string, unknown> = {};
|
|
91
|
-
for (const def of fieldDefs) {
|
|
92
|
-
const val = customFieldValues[def.field_key] ?? '';
|
|
93
|
-
if (val === '') continue;
|
|
94
|
-
if (def.field_type === 'number') cfPayload[def.field_key] = Number(val);
|
|
95
|
-
else if (def.field_type === 'boolean') cfPayload[def.field_key] = val === 'true';
|
|
96
|
-
else cfPayload[def.field_key] = val;
|
|
97
|
-
}
|
|
98
|
-
if (Object.keys(cfPayload).length > 0) payload.custom_fields = cfPayload;
|
|
99
|
-
onSave(payload);
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
return (
|
|
103
|
-
<div className="flex flex-col h-full">
|
|
104
|
-
<div className="flex items-center gap-2 px-5 py-3 border-b border-border">
|
|
105
|
-
<button onClick={onCancel} className="flex items-center gap-1 text-xs text-accent hover:underline">
|
|
106
|
-
<ChevronLeft className="w-3.5 h-3.5" /> Back
|
|
107
|
-
</button>
|
|
108
|
-
<span className="text-xs text-muted-foreground ml-auto">Editing account</span>
|
|
109
|
-
</div>
|
|
110
|
-
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
|
111
|
-
{[
|
|
112
|
-
{ key: 'name', label: 'Company Name', type: 'text', placeholder: 'e.g. Acme Corp', required: true },
|
|
113
|
-
{ key: 'industry', label: 'Industry', type: 'text', placeholder: 'e.g. Technology' },
|
|
114
|
-
{ key: 'website', label: 'Website', type: 'url', placeholder: 'https://acme.com' },
|
|
115
|
-
{ key: 'domain', label: 'Domain', type: 'text', placeholder: 'acme.com' },
|
|
116
|
-
{ key: 'employee_count', label: 'Employees', type: 'number', placeholder: '250' },
|
|
117
|
-
{ key: 'annual_revenue', label: 'Annual Revenue ($)', type: 'number', placeholder: '5000000' },
|
|
118
|
-
{ key: 'health_score', label: 'Health Score (0–100)', type: 'number', placeholder: '75' },
|
|
119
|
-
].map(f => (
|
|
120
|
-
<div key={f.key} className="space-y-1.5">
|
|
121
|
-
<label className={labelClass}>{f.label}{f.required && <span className="text-destructive ml-0.5">*</span>}</label>
|
|
122
|
-
<input type={f.type} value={fields[f.key]} onChange={e => set(f.key, e.target.value)} placeholder={f.placeholder} className={inputClass} />
|
|
123
|
-
</div>
|
|
124
|
-
))}
|
|
125
|
-
{users.length > 0 && (
|
|
126
|
-
<div className="space-y-1.5">
|
|
127
|
-
<label className={labelClass}>Owner</label>
|
|
128
|
-
<select value={fields.owner_id} onChange={e => set('owner_id', e.target.value)} className={`${inputClass} pr-3`}>
|
|
129
|
-
<option value="">Unassigned</option>
|
|
130
|
-
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
131
|
-
{users.map((u: any) => (
|
|
132
|
-
<option key={u.id} value={u.id}>{u.name || u.email}</option>
|
|
133
|
-
))}
|
|
134
|
-
</select>
|
|
135
|
-
</div>
|
|
136
|
-
)}
|
|
137
|
-
{fieldDefs.length > 0 && (
|
|
138
|
-
<>
|
|
139
|
-
<div className="border-t border-border pt-2">
|
|
140
|
-
<p className={labelClass}>Custom Fields</p>
|
|
141
|
-
</div>
|
|
142
|
-
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
143
|
-
{fieldDefs.map((def: any) => (
|
|
144
|
-
<div key={def.field_key} className="space-y-1.5">
|
|
145
|
-
<label className={labelClass}>{def.label}{def.required && <span className="text-destructive ml-0.5">*</span>}</label>
|
|
146
|
-
{(def.field_type === 'text' || !def.field_type) && (
|
|
147
|
-
<input type="text" value={customFieldValues[def.field_key] ?? ''} onChange={e => setCF(def.field_key, e.target.value)} className={inputClass} />
|
|
148
|
-
)}
|
|
149
|
-
{def.field_type === 'number' && (
|
|
150
|
-
<input type="number" value={customFieldValues[def.field_key] ?? ''} onChange={e => setCF(def.field_key, e.target.value)} className={inputClass} />
|
|
151
|
-
)}
|
|
152
|
-
{def.field_type === 'date' && (
|
|
153
|
-
<DatePicker
|
|
154
|
-
value={customFieldValues[def.field_key] ?? ''}
|
|
155
|
-
onChange={val => setCF(def.field_key, val)}
|
|
156
|
-
required={def.required}
|
|
157
|
-
/>
|
|
158
|
-
)}
|
|
159
|
-
{def.field_type === 'boolean' && (
|
|
160
|
-
<div className="flex items-center gap-2 h-10">
|
|
161
|
-
<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" />
|
|
162
|
-
<span className="text-sm text-foreground">Yes</span>
|
|
163
|
-
</div>
|
|
164
|
-
)}
|
|
165
|
-
{(def.field_type === 'select' || def.field_type === 'multi_select') && (
|
|
166
|
-
<select value={customFieldValues[def.field_key] ?? ''} onChange={e => setCF(def.field_key, e.target.value)} className={`${inputClass} pr-3`}>
|
|
167
|
-
<option value="">Select…</option>
|
|
168
|
-
{(def.options ?? []).map((opt: string) => <option key={opt} value={opt}>{opt}</option>)}
|
|
169
|
-
</select>
|
|
170
|
-
)}
|
|
171
|
-
</div>
|
|
172
|
-
))}
|
|
173
|
-
</>
|
|
174
|
-
)}
|
|
175
|
-
<button
|
|
176
|
-
onClick={handleSave}
|
|
177
|
-
disabled={!fields.name.trim() || isSaving}
|
|
178
|
-
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"
|
|
179
|
-
>
|
|
180
|
-
{isSaving ? 'Saving…' : 'Save Changes'}
|
|
181
|
-
</button>
|
|
182
|
-
{!confirmDelete ? (
|
|
183
|
-
<button
|
|
184
|
-
onClick={() => setConfirmDelete(true)}
|
|
185
|
-
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"
|
|
186
|
-
>
|
|
187
|
-
<Trash2 className="w-3.5 h-3.5" /> Delete Account
|
|
188
|
-
</button>
|
|
189
|
-
) : (
|
|
190
|
-
<div className="flex gap-2">
|
|
191
|
-
<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">
|
|
192
|
-
Cancel
|
|
193
|
-
</button>
|
|
194
|
-
<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">
|
|
195
|
-
Confirm Delete
|
|
196
|
-
</button>
|
|
197
|
-
</div>
|
|
198
|
-
)}
|
|
199
|
-
</div>
|
|
200
|
-
</div>
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export function AccountDrawer() {
|
|
205
|
-
const { drawerEntityId, openAIWithContext, closeDrawer } = useAppStore();
|
|
206
|
-
const { enabled: agentEnabled } = useAgentSettings();
|
|
207
|
-
const navigate = useNavigate();
|
|
208
|
-
const [editing, setEditing] = useState(false);
|
|
209
|
-
const [briefing, setBriefing] = useState(false);
|
|
210
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
211
|
-
const { data: accountData, isLoading } = useAccount(drawerEntityId ?? '') as any;
|
|
212
|
-
const updateAccount = useUpdateAccount(drawerEntityId ?? '');
|
|
213
|
-
const deleteAccount = useDeleteAccount(drawerEntityId ?? '');
|
|
214
|
-
|
|
215
|
-
if (isLoading) {
|
|
216
|
-
return (
|
|
217
|
-
<div className="flex flex-col gap-4 p-6 animate-pulse">
|
|
218
|
-
<div className="flex gap-4">
|
|
219
|
-
<div className="w-14 h-14 rounded-2xl bg-muted" />
|
|
220
|
-
<div className="flex-1 space-y-2">
|
|
221
|
-
<div className="h-4 bg-muted rounded w-3/4" />
|
|
222
|
-
<div className="h-3 bg-muted rounded w-1/2" />
|
|
223
|
-
</div>
|
|
224
|
-
</div>
|
|
225
|
-
</div>
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
if (!accountData?.account) {
|
|
230
|
-
return <div className="p-4 text-muted-foreground">Account not found</div>;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const account = accountData.account;
|
|
234
|
-
const name: string = account.name ?? '';
|
|
235
|
-
const industry: string = account.industry ?? '';
|
|
236
|
-
const website: string = account.website ?? '';
|
|
237
|
-
const revenue: number = account.annual_revenue ?? 0;
|
|
238
|
-
const employeeCount: number = account.employee_count ?? 0;
|
|
239
|
-
const healthScore: number = account.health_score ?? 0;
|
|
240
|
-
|
|
241
|
-
if (briefing) {
|
|
242
|
-
return <BriefingPanel subjectType="account" subjectId={drawerEntityId!} onClose={() => setBriefing(false)} />;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
if (editing) {
|
|
246
|
-
return (
|
|
247
|
-
<AccountEditForm
|
|
248
|
-
account={account}
|
|
249
|
-
onSave={async (data) => {
|
|
250
|
-
try {
|
|
251
|
-
await updateAccount.mutateAsync(data);
|
|
252
|
-
setEditing(false);
|
|
253
|
-
toast({ title: 'Account updated' });
|
|
254
|
-
} catch (err) {
|
|
255
|
-
toast({ title: 'Failed to update account', description: err instanceof Error ? err.message : 'Please try again.', variant: 'destructive' });
|
|
256
|
-
}
|
|
257
|
-
}}
|
|
258
|
-
onCancel={() => setEditing(false)}
|
|
259
|
-
onDelete={async () => {
|
|
260
|
-
try {
|
|
261
|
-
await deleteAccount.mutateAsync();
|
|
262
|
-
closeDrawer();
|
|
263
|
-
toast({ title: 'Account deleted' });
|
|
264
|
-
} catch (err) {
|
|
265
|
-
toast({ title: 'Failed to delete account', description: err instanceof Error ? err.message : 'Please try again.', variant: 'destructive' });
|
|
266
|
-
}
|
|
267
|
-
}}
|
|
268
|
-
isSaving={updateAccount.isPending}
|
|
269
|
-
/>
|
|
270
|
-
);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
return (
|
|
274
|
-
<div className="flex flex-col">
|
|
275
|
-
{/* Header */}
|
|
276
|
-
<div className="p-6 border-b border-border">
|
|
277
|
-
<div className="flex items-start gap-4">
|
|
278
|
-
<ContactAvatar name={name} className="w-14 h-14 rounded-2xl text-lg" />
|
|
279
|
-
<div className="flex-1">
|
|
280
|
-
<h2 className="font-display font-extrabold text-xl text-foreground">{name}</h2>
|
|
281
|
-
{industry && <p className="text-sm text-muted-foreground">{industry}</p>}
|
|
282
|
-
<div className="flex items-center gap-2 mt-2">
|
|
283
|
-
{healthScore > 0 && <HealthBadge score={healthScore} />}
|
|
284
|
-
</div>
|
|
285
|
-
</div>
|
|
286
|
-
</div>
|
|
287
|
-
<div className="flex gap-2 mt-4">
|
|
288
|
-
{website && (
|
|
289
|
-
<a
|
|
290
|
-
href={website.startsWith('http') ? website : `https://${website}`}
|
|
291
|
-
target="_blank"
|
|
292
|
-
rel="noopener noreferrer"
|
|
293
|
-
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"
|
|
294
|
-
>
|
|
295
|
-
<Globe className="w-3.5 h-3.5" /> Website
|
|
296
|
-
</a>
|
|
297
|
-
)}
|
|
298
|
-
<button
|
|
299
|
-
onClick={() => setEditing(true)}
|
|
300
|
-
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"
|
|
301
|
-
>
|
|
302
|
-
<Pencil className="w-3.5 h-3.5" /> Edit
|
|
303
|
-
</button>
|
|
304
|
-
<button
|
|
305
|
-
onClick={() => setBriefing(true)}
|
|
306
|
-
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"
|
|
307
|
-
>
|
|
308
|
-
<FileText className="w-3.5 h-3.5" /> Brief
|
|
309
|
-
</button>
|
|
310
|
-
{agentEnabled && (
|
|
311
|
-
<button
|
|
312
|
-
onClick={() => {
|
|
313
|
-
openAIWithContext({ type: 'account', id: account.id, name, detail: industry });
|
|
314
|
-
closeDrawer();
|
|
315
|
-
navigate('/agent');
|
|
316
|
-
}}
|
|
317
|
-
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"
|
|
318
|
-
>
|
|
319
|
-
<Sparkles className="w-3.5 h-3.5" /> Chat
|
|
320
|
-
</button>
|
|
321
|
-
)}
|
|
322
|
-
</div>
|
|
323
|
-
</div>
|
|
324
|
-
|
|
325
|
-
{/* Stats */}
|
|
326
|
-
<div className="grid grid-cols-3 gap-3 p-4 mx-4 mt-4">
|
|
327
|
-
{[
|
|
328
|
-
{ icon: DollarSign, label: 'Revenue', value: revenue ? formatRevenue(revenue) : '—' },
|
|
329
|
-
{ icon: Users, label: 'Employees', value: employeeCount ? String(employeeCount) : '—' },
|
|
330
|
-
{ icon: Heart, label: 'Health', value: healthScore ? String(healthScore) : '—' },
|
|
331
|
-
].map((stat) => (
|
|
332
|
-
<div key={stat.label} className="bg-muted/50 rounded-xl p-3 text-center">
|
|
333
|
-
<stat.icon className="w-4 h-4 text-muted-foreground mx-auto mb-1" />
|
|
334
|
-
<p className="text-sm font-display font-bold text-foreground">{stat.value}</p>
|
|
335
|
-
<p className="text-[10px] text-muted-foreground">{stat.label}</p>
|
|
336
|
-
</div>
|
|
337
|
-
))}
|
|
338
|
-
</div>
|
|
339
|
-
|
|
340
|
-
{/* Details */}
|
|
341
|
-
<div className="p-4 mx-4 mt-2 space-y-3">
|
|
342
|
-
<h3 className="text-xs font-display font-bold text-muted-foreground uppercase tracking-wide">Details</h3>
|
|
343
|
-
{[
|
|
344
|
-
{ label: 'Industry', value: industry },
|
|
345
|
-
{ label: 'Website', value: website },
|
|
346
|
-
{ label: 'Created', value: account.created_at ? new Date(account.created_at as string).toLocaleDateString() : undefined },
|
|
347
|
-
]
|
|
348
|
-
.filter((f) => f.value)
|
|
349
|
-
.map((field) => (
|
|
350
|
-
<div key={field.label} className="flex items-center justify-between">
|
|
351
|
-
<span className="text-xs text-muted-foreground">{field.label}</span>
|
|
352
|
-
<span className="text-sm text-foreground">{field.value}</span>
|
|
353
|
-
</div>
|
|
354
|
-
))}
|
|
355
|
-
</div>
|
|
356
|
-
|
|
357
|
-
{/* Custom Fields */}
|
|
358
|
-
<CustomFieldsSection objectType="account" values={(account.custom_fields ?? {}) as Record<string, unknown>} />
|
|
359
|
-
|
|
360
|
-
{/* Context */}
|
|
361
|
-
<ContextPanel subjectType="account" subjectId={drawerEntityId!} />
|
|
362
|
-
|
|
363
|
-
{/* Description */}
|
|
364
|
-
{account.description && (
|
|
365
|
-
<div className="p-4 mx-4 mt-2 mb-6">
|
|
366
|
-
<h3 className="text-xs font-display font-bold text-muted-foreground uppercase tracking-wide mb-2">About</h3>
|
|
367
|
-
<p className="text-sm text-foreground leading-relaxed">{account.description as string}</p>
|
|
368
|
-
</div>
|
|
369
|
-
)}
|
|
370
|
-
</div>
|
|
371
|
-
);
|
|
372
|
-
}
|