@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,240 @@
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
+ }
@@ -0,0 +1,28 @@
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 };
@@ -0,0 +1,37 @@
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
+ }
@@ -0,0 +1,372 @@
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
+ }