@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.
Files changed (121) hide show
  1. package/dist/assets/index-CskfWp8E.js +560 -0
  2. package/dist/assets/index-D763l57m.css +1 -0
  3. package/{index.html → dist/index.html} +2 -1
  4. package/package.json +4 -1
  5. package/postcss.config.js +0 -6
  6. package/src/App.tsx +0 -158
  7. package/src/api/client.ts +0 -82
  8. package/src/api/hooks.ts +0 -689
  9. package/src/components/CustomFields.tsx +0 -240
  10. package/src/components/NavLink.tsx +0 -28
  11. package/src/components/crm/AIFab.tsx +0 -37
  12. package/src/components/crm/AccountDrawer.tsx +0 -372
  13. package/src/components/crm/ActivityTimeline.tsx +0 -115
  14. package/src/components/crm/AssignmentDrawer.tsx +0 -396
  15. package/src/components/crm/BriefingPanel.tsx +0 -217
  16. package/src/components/crm/CommandPalette.tsx +0 -254
  17. package/src/components/crm/ContactAvatar.tsx +0 -49
  18. package/src/components/crm/ContactDrawer.tsx +0 -438
  19. package/src/components/crm/ContextPanel.tsx +0 -200
  20. package/src/components/crm/CrmWidgets.tsx +0 -417
  21. package/src/components/crm/DrawerShell.tsx +0 -77
  22. package/src/components/crm/ListToolbar.tsx +0 -252
  23. package/src/components/crm/OpportunityDrawer.tsx +0 -372
  24. package/src/components/crm/PaginationBar.tsx +0 -111
  25. package/src/components/crm/QuickAddDrawer.tsx +0 -652
  26. package/src/components/crm/ShortcutsOverlay.tsx +0 -65
  27. package/src/components/crm/UseCaseDrawer.tsx +0 -454
  28. package/src/components/layout/MobileNav.tsx +0 -49
  29. package/src/components/layout/Sidebar.tsx +0 -157
  30. package/src/components/layout/TopBar.tsx +0 -54
  31. package/src/components/settings/ActorsSettings.tsx +0 -1190
  32. package/src/components/ui/accordion.tsx +0 -52
  33. package/src/components/ui/alert-dialog.tsx +0 -104
  34. package/src/components/ui/alert.tsx +0 -43
  35. package/src/components/ui/aspect-ratio.tsx +0 -5
  36. package/src/components/ui/avatar.tsx +0 -38
  37. package/src/components/ui/badge.tsx +0 -29
  38. package/src/components/ui/breadcrumb.tsx +0 -90
  39. package/src/components/ui/button.tsx +0 -47
  40. package/src/components/ui/calendar.tsx +0 -54
  41. package/src/components/ui/card.tsx +0 -43
  42. package/src/components/ui/carousel.tsx +0 -224
  43. package/src/components/ui/chart.tsx +0 -303
  44. package/src/components/ui/checkbox.tsx +0 -26
  45. package/src/components/ui/collapsible.tsx +0 -9
  46. package/src/components/ui/command.tsx +0 -132
  47. package/src/components/ui/context-menu.tsx +0 -178
  48. package/src/components/ui/date-picker.tsx +0 -313
  49. package/src/components/ui/dialog.tsx +0 -95
  50. package/src/components/ui/drawer.tsx +0 -87
  51. package/src/components/ui/dropdown-menu.tsx +0 -179
  52. package/src/components/ui/form.tsx +0 -129
  53. package/src/components/ui/hover-card.tsx +0 -27
  54. package/src/components/ui/input-otp.tsx +0 -61
  55. package/src/components/ui/input.tsx +0 -22
  56. package/src/components/ui/label.tsx +0 -17
  57. package/src/components/ui/menubar.tsx +0 -207
  58. package/src/components/ui/navigation-menu.tsx +0 -120
  59. package/src/components/ui/pagination.tsx +0 -81
  60. package/src/components/ui/popover.tsx +0 -29
  61. package/src/components/ui/progress.tsx +0 -23
  62. package/src/components/ui/radio-group.tsx +0 -36
  63. package/src/components/ui/resizable.tsx +0 -37
  64. package/src/components/ui/scroll-area.tsx +0 -38
  65. package/src/components/ui/select.tsx +0 -143
  66. package/src/components/ui/separator.tsx +0 -20
  67. package/src/components/ui/sheet.tsx +0 -107
  68. package/src/components/ui/sidebar.tsx +0 -637
  69. package/src/components/ui/skeleton.tsx +0 -7
  70. package/src/components/ui/slider.tsx +0 -23
  71. package/src/components/ui/sonner.tsx +0 -24
  72. package/src/components/ui/switch.tsx +0 -27
  73. package/src/components/ui/table.tsx +0 -72
  74. package/src/components/ui/tabs.tsx +0 -53
  75. package/src/components/ui/textarea.tsx +0 -21
  76. package/src/components/ui/toast.tsx +0 -111
  77. package/src/components/ui/toaster.tsx +0 -24
  78. package/src/components/ui/toggle-group.tsx +0 -49
  79. package/src/components/ui/toggle.tsx +0 -37
  80. package/src/components/ui/tooltip.tsx +0 -28
  81. package/src/components/ui/use-toast.ts +0 -1
  82. package/src/components/ui/utils.ts +0 -9
  83. package/src/contexts/AgentSettingsContext.tsx +0 -24
  84. package/src/hooks/use-mobile.tsx +0 -19
  85. package/src/hooks/use-toast.ts +0 -186
  86. package/src/hooks/useKeyboardShortcuts.ts +0 -95
  87. package/src/hooks/useTheme.ts +0 -24
  88. package/src/index.css +0 -245
  89. package/src/lib/entityColors.ts +0 -18
  90. package/src/lib/stageConfig.ts +0 -32
  91. package/src/lib/utils.ts +0 -6
  92. package/src/main.tsx +0 -25
  93. package/src/pages/Accounts.tsx +0 -205
  94. package/src/pages/Activities.tsx +0 -251
  95. package/src/pages/Agent.tsx +0 -237
  96. package/src/pages/AgentSettings.tsx +0 -544
  97. package/src/pages/Assignments.tsx +0 -750
  98. package/src/pages/Contacts.tsx +0 -200
  99. package/src/pages/Dashboard.tsx +0 -143
  100. package/src/pages/Inbox.tsx +0 -615
  101. package/src/pages/NotFound.tsx +0 -24
  102. package/src/pages/Opportunities.tsx +0 -386
  103. package/src/pages/SearchResults.tsx +0 -49
  104. package/src/pages/Settings.tsx +0 -1884
  105. package/src/pages/UseCases.tsx +0 -396
  106. package/src/pages/auth/Login.tsx +0 -261
  107. package/src/pages/hitl/HITL.tsx +0 -101
  108. package/src/store/appStore.ts +0 -103
  109. package/src/vite-env.d.ts +0 -14
  110. package/tailwind.config.js +0 -121
  111. package/tsconfig.json +0 -24
  112. package/vite.config.ts +0 -27
  113. /package/{public → dist}/android-chrome-192x192.png +0 -0
  114. /package/{public → dist}/android-chrome-512x512.png +0 -0
  115. /package/{public → dist}/apple-touch-icon.png +0 -0
  116. /package/{src/assets/crmy-logo.png → dist/assets/crmy-logo-DWN0xBPW.png} +0 -0
  117. /package/{public → dist}/favicon-16x16.png +0 -0
  118. /package/{public → dist}/favicon-32x32.png +0 -0
  119. /package/{public → dist}/favicon.ico +0 -0
  120. /package/{public → dist}/favicon.svg +0 -0
  121. /package/{public → dist}/site.webmanifest +0 -0
@@ -1,252 +0,0 @@
1
- import { useState, useRef, useEffect } from 'react';
2
- import { Search, Filter, X, ChevronDown, Plus, ArrowUpDown } from 'lucide-react';
3
- import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
4
- import { Checkbox } from '@/components/ui/checkbox';
5
-
6
- export type FilterConfig = {
7
- key: string;
8
- label: string;
9
- options: { value: string; label: string }[];
10
- };
11
-
12
- export type SortOption = {
13
- key: string;
14
- label: string;
15
- };
16
-
17
- interface ListToolbarProps {
18
- searchValue: string;
19
- onSearchChange: (val: string) => void;
20
- searchPlaceholder?: string;
21
- filters: FilterConfig[];
22
- activeFilters: Record<string, string[]>;
23
- onFilterChange: (key: string, values: string[]) => void;
24
- onClearFilters: () => void;
25
- sortOptions: SortOption[];
26
- currentSort: { key: string; dir: 'asc' | 'desc' } | null;
27
- onSortChange: (key: string) => void;
28
- onAdd: () => void;
29
- addLabel: string;
30
- entityType: string;
31
- }
32
-
33
- const ENTITY_GRADIENTS: Record<string, string> = {
34
- contacts: 'from-primary to-primary/80',
35
- accounts: 'from-[#8b5cf6] to-[#8b5cf6]/80',
36
- opportunities: 'from-accent to-accent/80',
37
- 'use cases': 'from-success to-success/80',
38
- activities: 'from-warning to-warning/80',
39
- assignments: 'from-destructive to-destructive/80',
40
- };
41
-
42
- export function ListToolbar({
43
- searchValue,
44
- onSearchChange,
45
- searchPlaceholder = 'Search...',
46
- filters,
47
- activeFilters,
48
- onFilterChange,
49
- onClearFilters,
50
- sortOptions,
51
- currentSort,
52
- onSortChange,
53
- onAdd,
54
- addLabel,
55
- entityType,
56
- }: ListToolbarProps) {
57
- const gradientClasses = ENTITY_GRADIENTS[entityType] ?? 'from-primary to-primary/80';
58
- const searchRef = useRef<HTMLInputElement>(null);
59
- const activeFilterCount = Object.values(activeFilters).reduce((sum, arr) => sum + arr.length, 0);
60
-
61
- useEffect(() => {
62
- const handler = (e: KeyboardEvent) => {
63
- const target = e.target as HTMLElement;
64
- const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
65
- if (e.key === '/' && !isInput) {
66
- e.preventDefault();
67
- searchRef.current?.focus();
68
- }
69
- };
70
- window.addEventListener('keydown', handler);
71
- return () => window.removeEventListener('keydown', handler);
72
- }, []);
73
-
74
- return (
75
- <div className="flex flex-col gap-2 px-4 md:px-6 py-2 md:py-3">
76
- {/* Single row: search + filter + sort + add */}
77
- <div className="flex items-center gap-2">
78
- {/* Search */}
79
- <div className="relative flex-1 min-w-0 max-w-sm">
80
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
81
- <input
82
- ref={searchRef}
83
- value={searchValue}
84
- onChange={(e) => onSearchChange(e.target.value)}
85
- placeholder={searchPlaceholder}
86
- className="w-full h-9 pl-9 pr-8 rounded-xl border border-border bg-card text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-2 focus:ring-primary/30 transition-all"
87
- />
88
- {searchValue && (
89
- <button onClick={() => onSearchChange('')} className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1">
90
- <X className="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
91
- </button>
92
- )}
93
- {!searchValue && (
94
- <kbd className="absolute right-2.5 top-1/2 -translate-y-1/2 hidden md:inline-flex items-center px-1.5 py-0.5 rounded-md text-[10px] font-mono text-muted-foreground/50 bg-muted border border-border">
95
- /
96
- </kbd>
97
- )}
98
- </div>
99
-
100
- {/* Filter */}
101
- <Popover>
102
- <PopoverTrigger asChild>
103
- <button className="h-9 px-3 flex items-center gap-1.5 rounded-xl border border-border bg-card text-sm text-muted-foreground hover:text-foreground hover:border-primary/30 transition-all flex-shrink-0 press-scale">
104
- <Filter className="w-3.5 h-3.5" />
105
- <span className="hidden sm:inline">Filter</span>
106
- {activeFilterCount > 0 && (
107
- <span className="ml-0.5 px-1.5 py-0.5 rounded-full bg-primary text-primary-foreground text-[10px] font-semibold">
108
- {activeFilterCount}
109
- </span>
110
- )}
111
- </button>
112
- </PopoverTrigger>
113
- <PopoverContent className="w-72 p-0 rounded-xl" align="start">
114
- <div className="p-3 border-b border-border flex items-center justify-between">
115
- <span className="text-sm font-display font-bold text-foreground">Filters</span>
116
- {activeFilterCount > 0 && (
117
- <button onClick={onClearFilters} className="text-xs text-muted-foreground hover:text-foreground">
118
- Clear all
119
- </button>
120
- )}
121
- </div>
122
- <div className="p-2 max-h-80 overflow-y-auto space-y-1">
123
- {filters.map((filter) => (
124
- <FilterSection
125
- key={filter.key}
126
- filter={filter}
127
- selected={activeFilters[filter.key] || []}
128
- onChange={(values) => onFilterChange(filter.key, values)}
129
- />
130
- ))}
131
- </div>
132
- </PopoverContent>
133
- </Popover>
134
-
135
- {/* Sort */}
136
- <Popover>
137
- <PopoverTrigger asChild>
138
- <button className="h-9 px-3 flex items-center gap-1.5 rounded-xl border border-border bg-card text-sm text-muted-foreground hover:text-foreground hover:border-primary/30 transition-all flex-shrink-0 press-scale">
139
- <ArrowUpDown className="w-3.5 h-3.5" />
140
- <span className="hidden sm:inline">
141
- {currentSort ? sortOptions.find(s => s.key === currentSort.key)?.label || 'Sort' : 'Sort'}
142
- </span>
143
- {currentSort && (
144
- <span className="text-[10px] font-mono">{currentSort.dir === 'asc' ? '↑' : '↓'}</span>
145
- )}
146
- </button>
147
- </PopoverTrigger>
148
- <PopoverContent className="w-48 p-1 rounded-xl" align="start">
149
- {sortOptions.map((opt) => (
150
- <button
151
- key={opt.key}
152
- onClick={() => onSortChange(opt.key)}
153
- className={`w-full text-left px-3 py-2.5 text-sm rounded-lg transition-colors ${
154
- currentSort?.key === opt.key ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
155
- }`}
156
- >
157
- {opt.label}
158
- {currentSort?.key === opt.key && (
159
- <span className="ml-auto float-right text-[10px] font-mono">{currentSort.dir === 'asc' ? '↑' : '↓'}</span>
160
- )}
161
- </button>
162
- ))}
163
- </PopoverContent>
164
- </Popover>
165
-
166
- {/* Add New */}
167
- <button
168
- onClick={onAdd}
169
- className={`h-9 px-4 flex items-center gap-1.5 rounded-xl bg-gradient-to-r ${gradientClasses} text-primary-foreground text-sm font-semibold hover:shadow-md transition-all flex-shrink-0 press-scale`}
170
- >
171
- <Plus className="w-4 h-4" />
172
- <span className="hidden sm:inline">{addLabel}</span>
173
- </button>
174
- </div>
175
-
176
- {/* Active filter pills */}
177
- {activeFilterCount > 0 && (
178
- <div className="flex items-center gap-1.5 flex-wrap">
179
- {Object.entries(activeFilters).map(([key, values]) =>
180
- values.map((val) => {
181
- const filterConfig = filters.find(f => f.key === key);
182
- const optionLabel = filterConfig?.options.find(o => o.value === val)?.label || val;
183
- return (
184
- <span
185
- key={`${key}-${val}`}
186
- className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-muted text-xs text-foreground"
187
- >
188
- <span className="text-muted-foreground">{filterConfig?.label}:</span> {optionLabel}
189
- <button
190
- onClick={() => onFilterChange(key, values.filter(v => v !== val))}
191
- className="ml-0.5 hover:text-destructive p-0.5"
192
- >
193
- <X className="w-3 h-3" />
194
- </button>
195
- </span>
196
- );
197
- })
198
- )}
199
- </div>
200
- )}
201
- </div>
202
- );
203
- }
204
-
205
- function FilterSection({
206
- filter,
207
- selected,
208
- onChange,
209
- }: {
210
- filter: FilterConfig;
211
- selected: string[];
212
- onChange: (values: string[]) => void;
213
- }) {
214
- const [open, setOpen] = useState(true);
215
-
216
- return (
217
- <div className="rounded-lg">
218
- <button
219
- onClick={() => setOpen(!open)}
220
- className="w-full flex items-center justify-between px-2 py-2 text-xs font-display font-semibold text-muted-foreground hover:text-foreground"
221
- >
222
- {filter.label}
223
- {selected.length > 0 && (
224
- <span className="text-primary font-sans">{selected.length}</span>
225
- )}
226
- <ChevronDown className={`w-3 h-3 transition-transform ${open ? '' : '-rotate-90'}`} />
227
- </button>
228
- {open && (
229
- <div className="space-y-0.5 pl-1 pb-1">
230
- {filter.options.map((opt) => {
231
- const checked = selected.includes(opt.value);
232
- return (
233
- <label
234
- key={opt.value}
235
- className="flex items-center gap-2 px-2 py-2 text-sm text-foreground hover:bg-muted/50 rounded-lg cursor-pointer min-h-[40px]"
236
- >
237
- <Checkbox
238
- checked={checked}
239
- onCheckedChange={() => {
240
- onChange(checked ? selected.filter(v => v !== opt.value) : [...selected, opt.value]);
241
- }}
242
- className="w-4 h-4"
243
- />
244
- {opt.label}
245
- </label>
246
- );
247
- })}
248
- </div>
249
- )}
250
- </div>
251
- );
252
- }
@@ -1,372 +0,0 @@
1
- // Copyright 2026 CRMy Contributors
2
- // SPDX-License-Identifier: Apache-2.0
3
-
4
- import { useState } from 'react';
5
- import { useOpportunity, useUpdateOpportunity, useDeleteOpportunity, useUsers, useCustomFields } from '@/api/hooks';
6
- import { useNavigate } from 'react-router-dom';
7
- import { useAppStore } from '@/store/appStore';
8
- import { useAgentSettings } from '@/contexts/AgentSettingsContext';
9
- import { StageBadge, CustomFieldsSection } from './CrmWidgets';
10
- import { Sparkles, TrendingUp, Calendar, User, Pencil, ChevronLeft, Trash2, FileText } from 'lucide-react';
11
- import { ContextPanel } from './ContextPanel';
12
- import { BriefingPanel } from './BriefingPanel';
13
- import { toast } from '@/components/ui/use-toast';
14
- import { DatePicker } from '@/components/ui/date-picker';
15
-
16
- 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';
17
- const labelClass = 'text-xs font-mono text-muted-foreground uppercase tracking-wider';
18
-
19
- const OPP_STAGES = ['prospecting', 'qualification', 'proposal', 'negotiation', 'closed_won', 'closed_lost'];
20
-
21
- function OpportunityEditForm({
22
- opportunity,
23
- onSave,
24
- onCancel,
25
- onDelete,
26
- isSaving,
27
- }: {
28
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
- opportunity: any;
30
- onSave: (data: Record<string, unknown>) => void;
31
- onCancel: () => void;
32
- onDelete: () => void;
33
- isSaving: boolean;
34
- }) {
35
- const [confirmDelete, setConfirmDelete] = useState(false);
36
- const [fields, setFields] = useState<Record<string, string>>({
37
- name: opportunity.name ?? '',
38
- amount: opportunity.amount != null ? String(opportunity.amount) : '',
39
- stage: opportunity.stage ?? 'prospecting',
40
- close_date: opportunity.close_date ? opportunity.close_date.slice(0, 10) : '',
41
- probability: opportunity.probability != null ? String(opportunity.probability) : '',
42
- description: opportunity.description ?? '',
43
- owner_id: opportunity.owner_id ?? '',
44
- });
45
-
46
- const [customFieldValues, setCustomFieldValues] = useState<Record<string, string>>(() => {
47
- const init: Record<string, string> = {};
48
- if (opportunity.custom_fields) {
49
- for (const [k, v] of Object.entries(opportunity.custom_fields as Record<string, unknown>)) {
50
- init[k] = String(v ?? '');
51
- }
52
- }
53
- return init;
54
- });
55
-
56
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
- const { data: usersData } = useUsers() as any;
58
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
- const users: any[] = usersData?.data ?? [];
60
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
- const { data: customFieldDefs } = useCustomFields('opportunity') as any;
62
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
63
- const fieldDefs: any[] = customFieldDefs?.fields ?? [];
64
-
65
- const set = (key: string, val: string) => setFields(prev => ({ ...prev, [key]: val }));
66
- const setCF = (key: string, val: string) => setCustomFieldValues(prev => ({ ...prev, [key]: val }));
67
-
68
- const handleSave = () => {
69
- const payload: Record<string, unknown> = {};
70
- for (const [k, v] of Object.entries(fields)) {
71
- if (v === '') continue;
72
- if (k === 'amount' || k === 'probability') payload[k] = Number(v) || 0;
73
- else payload[k] = v;
74
- }
75
- const cfPayload: Record<string, unknown> = {};
76
- for (const def of fieldDefs) {
77
- const val = customFieldValues[def.field_key] ?? '';
78
- if (val === '') continue;
79
- if (def.field_type === 'number') cfPayload[def.field_key] = Number(val);
80
- else if (def.field_type === 'boolean') cfPayload[def.field_key] = val === 'true';
81
- else cfPayload[def.field_key] = val;
82
- }
83
- if (Object.keys(cfPayload).length > 0) payload.custom_fields = cfPayload;
84
- onSave(payload);
85
- };
86
-
87
- return (
88
- <div className="flex flex-col h-full">
89
- <div className="flex items-center gap-2 px-5 py-3 border-b border-border">
90
- <button onClick={onCancel} className="flex items-center gap-1 text-xs text-accent hover:underline">
91
- <ChevronLeft className="w-3.5 h-3.5" /> Back
92
- </button>
93
- <span className="text-xs text-muted-foreground ml-auto">Editing opportunity</span>
94
- </div>
95
- <div className="flex-1 overflow-y-auto p-5 space-y-4">
96
- <div className="space-y-1.5">
97
- <label className={labelClass}>Opportunity Name<span className="text-destructive ml-0.5">*</span></label>
98
- <input type="text" value={fields.name} onChange={e => set('name', e.target.value)} placeholder="e.g. Acme Enterprise" className={inputClass} />
99
- </div>
100
- <div className="space-y-1.5">
101
- <label className={labelClass}>Stage</label>
102
- <select value={fields.stage} onChange={e => set('stage', e.target.value)} className={`${inputClass} pr-3`}>
103
- {OPP_STAGES.map(s => <option key={s} value={s}>{s.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}</option>)}
104
- </select>
105
- </div>
106
- {[
107
- { key: 'amount', label: 'Amount ($)', type: 'number', placeholder: '50000' },
108
- { key: 'close_date', label: 'Close Date', type: 'date', placeholder: '' },
109
- { key: 'probability', label: 'Probability (%)', type: 'number', placeholder: '50' },
110
- ].map(f => (
111
- <div key={f.key} className="space-y-1.5">
112
- <label className={labelClass}>{f.label}</label>
113
- {f.type === 'date' ? (
114
- <DatePicker
115
- value={fields[f.key] ?? ''}
116
- onChange={val => set(f.key, val)}
117
- placeholder="Select close date"
118
- />
119
- ) : (
120
- <input type={f.type} value={fields[f.key]} onChange={e => set(f.key, e.target.value)} placeholder={f.placeholder} className={inputClass} />
121
- )}
122
- </div>
123
- ))}
124
- {users.length > 0 && (
125
- <div className="space-y-1.5">
126
- <label className={labelClass}>Owner</label>
127
- <select value={fields.owner_id} onChange={e => set('owner_id', e.target.value)} className={`${inputClass} pr-3`}>
128
- <option value="">Unassigned</option>
129
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
130
- {users.map((u: any) => (
131
- <option key={u.id} value={u.id}>{u.name || u.email}</option>
132
- ))}
133
- </select>
134
- </div>
135
- )}
136
- <div className="space-y-1.5">
137
- <label className={labelClass}>Description</label>
138
- <textarea
139
- value={fields.description}
140
- onChange={e => set('description', e.target.value)}
141
- placeholder="Optional notes"
142
- rows={3}
143
- 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"
144
- />
145
- </div>
146
- {fieldDefs.length > 0 && (
147
- <>
148
- <div className="border-t border-border pt-2">
149
- <p className={labelClass}>Custom Fields</p>
150
- </div>
151
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
152
- {fieldDefs.map((def: any) => (
153
- <div key={def.field_key} className="space-y-1.5">
154
- <label className={labelClass}>{def.label}{def.required && <span className="text-destructive ml-0.5">*</span>}</label>
155
- {(def.field_type === 'text' || !def.field_type) && (
156
- <input type="text" value={customFieldValues[def.field_key] ?? ''} onChange={e => setCF(def.field_key, e.target.value)} className={inputClass} />
157
- )}
158
- {def.field_type === 'number' && (
159
- <input type="number" value={customFieldValues[def.field_key] ?? ''} onChange={e => setCF(def.field_key, e.target.value)} className={inputClass} />
160
- )}
161
- {def.field_type === 'date' && (
162
- <DatePicker
163
- value={customFieldValues[def.field_key] ?? ''}
164
- onChange={val => setCF(def.field_key, val)}
165
- required={def.required}
166
- />
167
- )}
168
- {def.field_type === 'boolean' && (
169
- <div className="flex items-center gap-2 h-10">
170
- <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" />
171
- <span className="text-sm text-foreground">Yes</span>
172
- </div>
173
- )}
174
- {(def.field_type === 'select' || def.field_type === 'multi_select') && (
175
- <select value={customFieldValues[def.field_key] ?? ''} onChange={e => setCF(def.field_key, e.target.value)} className={`${inputClass} pr-3`}>
176
- <option value="">Select…</option>
177
- {(def.options ?? []).map((opt: string) => <option key={opt} value={opt}>{opt}</option>)}
178
- </select>
179
- )}
180
- </div>
181
- ))}
182
- </>
183
- )}
184
- {opportunity.created_at && (
185
- <div className="flex items-center justify-between py-2 border-t border-border mt-2">
186
- <span className="text-xs text-muted-foreground">Created</span>
187
- <span className="text-xs text-muted-foreground">{new Date(opportunity.created_at as string).toLocaleDateString()}</span>
188
- </div>
189
- )}
190
- <button
191
- onClick={handleSave}
192
- disabled={!fields.name.trim() || isSaving}
193
- 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"
194
- >
195
- {isSaving ? 'Saving…' : 'Save Changes'}
196
- </button>
197
- {!confirmDelete ? (
198
- <button
199
- onClick={() => setConfirmDelete(true)}
200
- 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"
201
- >
202
- <Trash2 className="w-3.5 h-3.5" /> Delete Opportunity
203
- </button>
204
- ) : (
205
- <div className="flex gap-2">
206
- <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">
207
- Cancel
208
- </button>
209
- <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">
210
- Confirm Delete
211
- </button>
212
- </div>
213
- )}
214
- </div>
215
- </div>
216
- );
217
- }
218
-
219
- export function OpportunityDrawer() {
220
- const { drawerEntityId, openAIWithContext, closeDrawer } = useAppStore();
221
- const { enabled: agentEnabled } = useAgentSettings();
222
- const navigate = useNavigate();
223
- const [editing, setEditing] = useState(false);
224
- const [briefing, setBriefing] = useState(false);
225
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
226
- const { data: oppData, isLoading } = useOpportunity(drawerEntityId ?? '') as any;
227
- const updateOpportunity = useUpdateOpportunity(drawerEntityId ?? '');
228
- const deleteOpportunity = useDeleteOpportunity(drawerEntityId ?? '');
229
-
230
- if (isLoading) {
231
- return (
232
- <div className="flex flex-col gap-4 p-6 animate-pulse">
233
- <div className="h-6 bg-muted rounded w-3/4" />
234
- <div className="h-4 bg-muted rounded w-1/2" />
235
- </div>
236
- );
237
- }
238
-
239
- if (!oppData?.opportunity) {
240
- return <div className="p-4 text-muted-foreground">Opportunity not found</div>;
241
- }
242
-
243
- const opportunity = oppData.opportunity;
244
- const name: string = opportunity.name ?? '';
245
- const amount: number = opportunity.amount ?? 0;
246
- const stage: string = opportunity.stage ?? '';
247
- const probability: number = opportunity.probability ?? 0;
248
- const forecastCat: string = opportunity.forecast_cat ?? '';
249
- const closeDate: string = opportunity.close_date ? new Date(opportunity.close_date as string).toLocaleDateString() : '—';
250
-
251
- if (briefing) {
252
- return <BriefingPanel subjectType="opportunity" subjectId={drawerEntityId!} onClose={() => setBriefing(false)} />;
253
- }
254
-
255
- if (editing) {
256
- return (
257
- <OpportunityEditForm
258
- opportunity={opportunity}
259
- onSave={async (data) => {
260
- try {
261
- await updateOpportunity.mutateAsync(data);
262
- setEditing(false);
263
- toast({ title: 'Opportunity updated' });
264
- } catch (err) {
265
- toast({ title: 'Failed to update opportunity', description: err instanceof Error ? err.message : 'Please try again.', variant: 'destructive' });
266
- }
267
- }}
268
- onCancel={() => setEditing(false)}
269
- onDelete={async () => {
270
- try {
271
- await deleteOpportunity.mutateAsync();
272
- closeDrawer();
273
- toast({ title: 'Opportunity deleted' });
274
- } catch (err) {
275
- toast({ title: 'Failed to delete opportunity', description: err instanceof Error ? err.message : 'Please try again.', variant: 'destructive' });
276
- }
277
- }}
278
- isSaving={updateOpportunity.isPending}
279
- />
280
- );
281
- }
282
-
283
- return (
284
- <div className="flex flex-col">
285
- {/* Header */}
286
- <div className="p-6 border-b border-border">
287
- <h2 className="font-display font-extrabold text-xl text-foreground">{name}</h2>
288
- <p className="text-3xl font-display font-extrabold text-foreground mt-2">
289
- ${amount >= 1000 ? `${(amount / 1000).toFixed(0)}K` : amount}
290
- </p>
291
- <div className="flex items-center gap-2 mt-3">
292
- {stage && <StageBadge stage={stage} />}
293
- {probability > 0 && (
294
- <span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-accent/10 text-accent">
295
- {probability}% probability
296
- </span>
297
- )}
298
- </div>
299
- <div className="flex gap-2 mt-4">
300
- <button
301
- onClick={() => setEditing(true)}
302
- 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"
303
- >
304
- <Pencil className="w-3.5 h-3.5" /> Edit
305
- </button>
306
- <button
307
- onClick={() => setBriefing(true)}
308
- 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"
309
- >
310
- <FileText className="w-3.5 h-3.5" /> Brief
311
- </button>
312
- {agentEnabled && (
313
- <button
314
- onClick={() => {
315
- openAIWithContext({ type: 'opportunity', id: opportunity.id, name, detail: `$${(amount / 1000).toFixed(0)}K` });
316
- closeDrawer();
317
- navigate('/agent');
318
- }}
319
- 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"
320
- >
321
- <Sparkles className="w-3.5 h-3.5" /> Chat
322
- </button>
323
- )}
324
- </div>
325
- </div>
326
-
327
- {/* Stats */}
328
- <div className="grid grid-cols-3 gap-3 p-4 mx-4 mt-4">
329
- {[
330
- { icon: TrendingUp, label: 'Probability', value: `${probability}%` },
331
- { icon: Calendar, label: 'Close Date', value: closeDate },
332
- { icon: User, label: 'Forecast', value: forecastCat || '—' },
333
- ].map((stat) => (
334
- <div key={stat.label} className="bg-muted/50 rounded-xl p-3 text-center">
335
- <stat.icon className="w-4 h-4 text-muted-foreground mx-auto mb-1" />
336
- <p className="text-sm font-display font-bold text-foreground truncate">{stat.value}</p>
337
- <p className="text-[10px] text-muted-foreground">{stat.label}</p>
338
- </div>
339
- ))}
340
- </div>
341
-
342
- {/* Details */}
343
- <div className="p-4 mx-4 mt-2 space-y-3">
344
- <h3 className="text-xs font-display font-bold text-muted-foreground uppercase tracking-wide">Details</h3>
345
- {[
346
- { label: 'Stage', value: stage },
347
- { label: 'Forecast', value: forecastCat || undefined },
348
- { label: 'Created', value: opportunity.created_at ? new Date(opportunity.created_at as string).toLocaleDateString() : undefined },
349
- ]
350
- .filter((f) => f.value)
351
- .map((field) => (
352
- <div key={field.label} className="flex items-center justify-between">
353
- <span className="text-xs text-muted-foreground">{field.label}</span>
354
- <span className="text-sm text-foreground">{field.value}</span>
355
- </div>
356
- ))}
357
- {opportunity.notes && (
358
- <div>
359
- <p className="text-xs text-muted-foreground mb-1">Notes</p>
360
- <p className="text-sm text-foreground leading-relaxed">{opportunity.notes as string}</p>
361
- </div>
362
- )}
363
- </div>
364
-
365
- {/* Custom Fields */}
366
- <CustomFieldsSection objectType="opportunity" values={(opportunity.custom_fields ?? {}) as Record<string, unknown>} />
367
-
368
- {/* Context */}
369
- <ContextPanel subjectType="opportunity" subjectId={drawerEntityId!} />
370
- </div>
371
- );
372
- }