@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,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
|
-
}
|