@crmy/web 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.html +23 -0
- package/package.json +76 -0
- package/postcss.config.js +6 -0
- package/public/android-chrome-192x192.png +0 -0
- package/public/android-chrome-512x512.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +13 -0
- package/public/site.webmanifest +1 -0
- package/src/App.tsx +158 -0
- package/src/api/client.ts +82 -0
- package/src/api/hooks.ts +689 -0
- package/src/assets/crmy-logo.png +0 -0
- package/src/components/CustomFields.tsx +240 -0
- package/src/components/NavLink.tsx +28 -0
- package/src/components/crm/AIFab.tsx +37 -0
- package/src/components/crm/AccountDrawer.tsx +372 -0
- package/src/components/crm/ActivityTimeline.tsx +115 -0
- package/src/components/crm/AssignmentDrawer.tsx +396 -0
- package/src/components/crm/BriefingPanel.tsx +217 -0
- package/src/components/crm/CommandPalette.tsx +254 -0
- package/src/components/crm/ContactAvatar.tsx +49 -0
- package/src/components/crm/ContactDrawer.tsx +438 -0
- package/src/components/crm/ContextPanel.tsx +200 -0
- package/src/components/crm/CrmWidgets.tsx +417 -0
- package/src/components/crm/DrawerShell.tsx +77 -0
- package/src/components/crm/ListToolbar.tsx +252 -0
- package/src/components/crm/OpportunityDrawer.tsx +372 -0
- package/src/components/crm/PaginationBar.tsx +111 -0
- package/src/components/crm/QuickAddDrawer.tsx +652 -0
- package/src/components/crm/ShortcutsOverlay.tsx +65 -0
- package/src/components/crm/UseCaseDrawer.tsx +454 -0
- package/src/components/layout/MobileNav.tsx +49 -0
- package/src/components/layout/Sidebar.tsx +157 -0
- package/src/components/layout/TopBar.tsx +54 -0
- package/src/components/settings/ActorsSettings.tsx +1190 -0
- package/src/components/ui/accordion.tsx +52 -0
- package/src/components/ui/alert-dialog.tsx +104 -0
- package/src/components/ui/alert.tsx +43 -0
- package/src/components/ui/aspect-ratio.tsx +5 -0
- package/src/components/ui/avatar.tsx +38 -0
- package/src/components/ui/badge.tsx +29 -0
- package/src/components/ui/breadcrumb.tsx +90 -0
- package/src/components/ui/button.tsx +47 -0
- package/src/components/ui/calendar.tsx +54 -0
- package/src/components/ui/card.tsx +43 -0
- package/src/components/ui/carousel.tsx +224 -0
- package/src/components/ui/chart.tsx +303 -0
- package/src/components/ui/checkbox.tsx +26 -0
- package/src/components/ui/collapsible.tsx +9 -0
- package/src/components/ui/command.tsx +132 -0
- package/src/components/ui/context-menu.tsx +178 -0
- package/src/components/ui/date-picker.tsx +313 -0
- package/src/components/ui/dialog.tsx +95 -0
- package/src/components/ui/drawer.tsx +87 -0
- package/src/components/ui/dropdown-menu.tsx +179 -0
- package/src/components/ui/form.tsx +129 -0
- package/src/components/ui/hover-card.tsx +27 -0
- package/src/components/ui/input-otp.tsx +61 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/label.tsx +17 -0
- package/src/components/ui/menubar.tsx +207 -0
- package/src/components/ui/navigation-menu.tsx +120 -0
- package/src/components/ui/pagination.tsx +81 -0
- package/src/components/ui/popover.tsx +29 -0
- package/src/components/ui/progress.tsx +23 -0
- package/src/components/ui/radio-group.tsx +36 -0
- package/src/components/ui/resizable.tsx +37 -0
- package/src/components/ui/scroll-area.tsx +38 -0
- package/src/components/ui/select.tsx +143 -0
- package/src/components/ui/separator.tsx +20 -0
- package/src/components/ui/sheet.tsx +107 -0
- package/src/components/ui/sidebar.tsx +637 -0
- package/src/components/ui/skeleton.tsx +7 -0
- package/src/components/ui/slider.tsx +23 -0
- package/src/components/ui/sonner.tsx +24 -0
- package/src/components/ui/switch.tsx +27 -0
- package/src/components/ui/table.tsx +72 -0
- package/src/components/ui/tabs.tsx +53 -0
- package/src/components/ui/textarea.tsx +21 -0
- package/src/components/ui/toast.tsx +111 -0
- package/src/components/ui/toaster.tsx +24 -0
- package/src/components/ui/toggle-group.tsx +49 -0
- package/src/components/ui/toggle.tsx +37 -0
- package/src/components/ui/tooltip.tsx +28 -0
- package/src/components/ui/use-toast.ts +1 -0
- package/src/components/ui/utils.ts +9 -0
- package/src/contexts/AgentSettingsContext.tsx +24 -0
- package/src/hooks/use-mobile.tsx +19 -0
- package/src/hooks/use-toast.ts +186 -0
- package/src/hooks/useKeyboardShortcuts.ts +95 -0
- package/src/hooks/useTheme.ts +24 -0
- package/src/index.css +245 -0
- package/src/lib/entityColors.ts +18 -0
- package/src/lib/stageConfig.ts +32 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +25 -0
- package/src/pages/Accounts.tsx +205 -0
- package/src/pages/Activities.tsx +251 -0
- package/src/pages/Agent.tsx +237 -0
- package/src/pages/AgentSettings.tsx +544 -0
- package/src/pages/Assignments.tsx +750 -0
- package/src/pages/Contacts.tsx +200 -0
- package/src/pages/Dashboard.tsx +143 -0
- package/src/pages/Inbox.tsx +615 -0
- package/src/pages/NotFound.tsx +24 -0
- package/src/pages/Opportunities.tsx +386 -0
- package/src/pages/SearchResults.tsx +49 -0
- package/src/pages/Settings.tsx +1884 -0
- package/src/pages/UseCases.tsx +396 -0
- package/src/pages/auth/Login.tsx +261 -0
- package/src/pages/hitl/HITL.tsx +101 -0
- package/src/store/appStore.ts +103 -0
- package/src/vite-env.d.ts +14 -0
- package/tailwind.config.js +121 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +27 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useEffect, useCallback } from 'react';
|
|
2
|
+
import { useNavigate, useLocation } from 'react-router-dom';
|
|
3
|
+
import { useAppStore } from '@/store/appStore';
|
|
4
|
+
|
|
5
|
+
export function useKeyboardShortcuts() {
|
|
6
|
+
const navigate = useNavigate();
|
|
7
|
+
const location = useLocation();
|
|
8
|
+
const { setCommandPaletteOpen, openDrawer, setShortcutsOpen, toggleZenMode, openQuickAdd } = useAppStore();
|
|
9
|
+
|
|
10
|
+
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
11
|
+
const target = e.target as HTMLElement;
|
|
12
|
+
const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
|
13
|
+
|
|
14
|
+
// Cmd+K — command palette
|
|
15
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
16
|
+
e.preventDefault();
|
|
17
|
+
setCommandPaletteOpen(true);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Cmd+J — AI agent
|
|
22
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'j') {
|
|
23
|
+
e.preventDefault();
|
|
24
|
+
navigate('/agent');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Cmd+Shift+Z — zen mode
|
|
29
|
+
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'Z') {
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
toggleZenMode();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Escape
|
|
36
|
+
if (e.key === 'Escape') {
|
|
37
|
+
// On /agent, go back to previous page
|
|
38
|
+
if (location.pathname === '/agent') {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
navigate(-1);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
useAppStore.getState().closeDrawer();
|
|
44
|
+
useAppStore.getState().closeQuickAdd();
|
|
45
|
+
setCommandPaletteOpen(false);
|
|
46
|
+
setShortcutsOpen(false);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (isInput) return;
|
|
51
|
+
|
|
52
|
+
// ? — shortcuts
|
|
53
|
+
if (e.key === '?') {
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
setShortcutsOpen(true);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// N — new contact (on contacts page)
|
|
60
|
+
if (e.key === 'n' || e.key === 'N') {
|
|
61
|
+
if (location.pathname === '/contacts') {
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
openQuickAdd('contact');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// D — new opportunity (on opportunities page, not with meta key)
|
|
69
|
+
if (e.key === 'd' && !e.metaKey && !e.ctrlKey) {
|
|
70
|
+
if (location.pathname === '/opportunities') {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
openQuickAdd('opportunity');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// G then navigation
|
|
78
|
+
if (e.key === 'g') {
|
|
79
|
+
const handler = (e2: KeyboardEvent) => {
|
|
80
|
+
window.removeEventListener('keydown', handler);
|
|
81
|
+
if (e2.key === 'h') navigate('/');
|
|
82
|
+
else if (e2.key === 'c') navigate('/contacts');
|
|
83
|
+
else if (e2.key === 'd') navigate('/opportunities');
|
|
84
|
+
};
|
|
85
|
+
window.addEventListener('keydown', handler);
|
|
86
|
+
setTimeout(() => window.removeEventListener('keydown', handler), 1000);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}, [navigate, location.pathname, setCommandPaletteOpen, openDrawer, setShortcutsOpen, toggleZenMode, openQuickAdd]);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
93
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
94
|
+
}, [handleKeyDown]);
|
|
95
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useAppStore } from '@/store/appStore';
|
|
3
|
+
|
|
4
|
+
type Theme = 'light' | 'dark';
|
|
5
|
+
|
|
6
|
+
export function useTheme() {
|
|
7
|
+
const { darkVariant } = useAppStore();
|
|
8
|
+
const [theme, setTheme] = useState<Theme>(() => {
|
|
9
|
+
const stored = localStorage.getItem('theme') as Theme | null;
|
|
10
|
+
if (stored) return stored;
|
|
11
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const root = document.documentElement;
|
|
16
|
+
root.classList.toggle('dark', theme === 'dark');
|
|
17
|
+
root.classList.toggle('charcoal', darkVariant === 'charcoal');
|
|
18
|
+
localStorage.setItem('theme', theme);
|
|
19
|
+
}, [theme, darkVariant]);
|
|
20
|
+
|
|
21
|
+
const toggle = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
|
|
22
|
+
|
|
23
|
+
return { theme, setTheme, toggle };
|
|
24
|
+
}
|
package/src/index.css
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
@layer base {
|
|
6
|
+
:root {
|
|
7
|
+
--background: 30 25% 95%;
|
|
8
|
+
--foreground: 12 100% 4%;
|
|
9
|
+
--card: 30 30% 100%;
|
|
10
|
+
--card-foreground: 12 100% 4%;
|
|
11
|
+
--popover: 30 30% 100%;
|
|
12
|
+
--popover-foreground: 12 100% 4%;
|
|
13
|
+
--primary: 24 95% 53%;
|
|
14
|
+
--primary-foreground: 0 0% 100%;
|
|
15
|
+
--secondary: 45 97% 65%;
|
|
16
|
+
--secondary-foreground: 12 100% 4%;
|
|
17
|
+
--accent: 199 90% 60%;
|
|
18
|
+
--accent-foreground: 0 0% 100%;
|
|
19
|
+
--muted: 30 20% 90%;
|
|
20
|
+
--muted-foreground: 20 10% 44%;
|
|
21
|
+
--destructive: 0 84% 60%;
|
|
22
|
+
--destructive-foreground: 0 0% 100%;
|
|
23
|
+
--border: 30 15% 86%;
|
|
24
|
+
--input: 30 15% 88%;
|
|
25
|
+
--ring: 24 95% 53%;
|
|
26
|
+
--radius: 0.75rem;
|
|
27
|
+
|
|
28
|
+
--success: 152 55% 42%;
|
|
29
|
+
--success-foreground: 0 0% 100%;
|
|
30
|
+
--warning: 45 97% 65%;
|
|
31
|
+
--warning-foreground: 12 100% 4%;
|
|
32
|
+
--info: 199 90% 60%;
|
|
33
|
+
--info-foreground: 0 0% 100%;
|
|
34
|
+
|
|
35
|
+
--surface: 30 25% 97%;
|
|
36
|
+
--surface-raised: 30 30% 100%;
|
|
37
|
+
--surface-sunken: 30 20% 91%;
|
|
38
|
+
|
|
39
|
+
--sidebar-background: 15 25% 10%;
|
|
40
|
+
--sidebar-foreground: 20 10% 55%;
|
|
41
|
+
--sidebar-primary: 24 95% 53%;
|
|
42
|
+
--sidebar-primary-foreground: 0 0% 100%;
|
|
43
|
+
--sidebar-accent: 15 20% 15%;
|
|
44
|
+
--sidebar-accent-foreground: 30 20% 92%;
|
|
45
|
+
--sidebar-border: 15 20% 15%;
|
|
46
|
+
--sidebar-ring: 24 95% 53%;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* Charcoal variant in light mode — sidebar uses cool blue-gray, content area uses cool light gray */
|
|
50
|
+
.charcoal:not(.dark) {
|
|
51
|
+
--background: 220 20% 94%;
|
|
52
|
+
--card: 0 0% 100%;
|
|
53
|
+
--popover: 0 0% 100%;
|
|
54
|
+
--muted: 220 16% 88%;
|
|
55
|
+
--border: 220 14% 84%;
|
|
56
|
+
--input: 220 14% 88%;
|
|
57
|
+
|
|
58
|
+
--surface: 220 18% 96%;
|
|
59
|
+
--surface-raised: 0 0% 100%;
|
|
60
|
+
--surface-sunken: 220 18% 90%;
|
|
61
|
+
|
|
62
|
+
--sidebar-background: 220 18% 10%;
|
|
63
|
+
--sidebar-foreground: 220 8% 55%;
|
|
64
|
+
--sidebar-primary: 24 95% 53%;
|
|
65
|
+
--sidebar-primary-foreground: 0 0% 100%;
|
|
66
|
+
--sidebar-accent: 220 12% 16%;
|
|
67
|
+
--sidebar-accent-foreground: 36 15% 92%;
|
|
68
|
+
--sidebar-border: 220 12% 16%;
|
|
69
|
+
--sidebar-ring: 24 95% 53%;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.dark {
|
|
73
|
+
--background: 15 25% 7%;
|
|
74
|
+
--foreground: 25 100% 98%;
|
|
75
|
+
--card: 15 22% 10%;
|
|
76
|
+
--card-foreground: 25 100% 98%;
|
|
77
|
+
--popover: 15 22% 12%;
|
|
78
|
+
--popover-foreground: 25 100% 98%;
|
|
79
|
+
--primary: 24 95% 53%;
|
|
80
|
+
--primary-foreground: 0 0% 100%;
|
|
81
|
+
--secondary: 45 97% 65%;
|
|
82
|
+
--secondary-foreground: 15 25% 7%;
|
|
83
|
+
--accent: 199 90% 60%;
|
|
84
|
+
--accent-foreground: 0 0% 100%;
|
|
85
|
+
--muted: 15 20% 15%;
|
|
86
|
+
--muted-foreground: 20 15% 55%;
|
|
87
|
+
--destructive: 0 90% 65%;
|
|
88
|
+
--destructive-foreground: 0 0% 100%;
|
|
89
|
+
--border: 15 20% 15%;
|
|
90
|
+
--input: 15 20% 15%;
|
|
91
|
+
--ring: 24 95% 53%;
|
|
92
|
+
|
|
93
|
+
--success: 152 50% 45%;
|
|
94
|
+
--success-foreground: 0 0% 100%;
|
|
95
|
+
--warning: 45 90% 60%;
|
|
96
|
+
--warning-foreground: 15 25% 7%;
|
|
97
|
+
--info: 199 85% 60%;
|
|
98
|
+
--info-foreground: 0 0% 100%;
|
|
99
|
+
|
|
100
|
+
--surface: 15 22% 8%;
|
|
101
|
+
--surface-raised: 15 22% 12%;
|
|
102
|
+
--surface-sunken: 15 25% 5%;
|
|
103
|
+
|
|
104
|
+
--sidebar-background: 15 25% 5%;
|
|
105
|
+
--sidebar-foreground: 20 15% 50%;
|
|
106
|
+
--sidebar-primary: 24 95% 53%;
|
|
107
|
+
--sidebar-primary-foreground: 0 0% 100%;
|
|
108
|
+
--sidebar-accent: 15 20% 12%;
|
|
109
|
+
--sidebar-accent-foreground: 25 100% 95%;
|
|
110
|
+
--sidebar-border: 15 20% 12%;
|
|
111
|
+
--sidebar-ring: 24 95% 53%;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.dark.charcoal {
|
|
115
|
+
--background: 220 16% 8%;
|
|
116
|
+
--foreground: 36 15% 92%;
|
|
117
|
+
--card: 220 14% 11%;
|
|
118
|
+
--card-foreground: 36 15% 92%;
|
|
119
|
+
--popover: 220 14% 13%;
|
|
120
|
+
--popover-foreground: 36 15% 92%;
|
|
121
|
+
--primary: 24 95% 53%;
|
|
122
|
+
--primary-foreground: 0 0% 100%;
|
|
123
|
+
--secondary: 45 97% 65%;
|
|
124
|
+
--secondary-foreground: 220 16% 8%;
|
|
125
|
+
--accent: 199 90% 60%;
|
|
126
|
+
--accent-foreground: 0 0% 100%;
|
|
127
|
+
--muted: 220 10% 14%;
|
|
128
|
+
--muted-foreground: 220 8% 50%;
|
|
129
|
+
--destructive: 0 62.8% 30.6%;
|
|
130
|
+
--destructive-foreground: 36 15% 95%;
|
|
131
|
+
--border: 220 10% 16%;
|
|
132
|
+
--input: 220 10% 18%;
|
|
133
|
+
--ring: 24 95% 53%;
|
|
134
|
+
|
|
135
|
+
--success: 152 50% 45%;
|
|
136
|
+
--success-foreground: 0 0% 100%;
|
|
137
|
+
--warning: 45 90% 60%;
|
|
138
|
+
--warning-foreground: 220 16% 8%;
|
|
139
|
+
--info: 199 85% 60%;
|
|
140
|
+
--info-foreground: 0 0% 100%;
|
|
141
|
+
|
|
142
|
+
--surface: 220 14% 9%;
|
|
143
|
+
--surface-raised: 220 14% 13%;
|
|
144
|
+
--surface-sunken: 220 16% 5%;
|
|
145
|
+
|
|
146
|
+
--sidebar-background: 220 18% 5%;
|
|
147
|
+
--sidebar-foreground: 220 8% 50%;
|
|
148
|
+
--sidebar-primary: 24 95% 53%;
|
|
149
|
+
--sidebar-primary-foreground: 0 0% 100%;
|
|
150
|
+
--sidebar-accent: 220 12% 12%;
|
|
151
|
+
--sidebar-accent-foreground: 36 15% 92%;
|
|
152
|
+
--sidebar-border: 220 12% 12%;
|
|
153
|
+
--sidebar-ring: 24 95% 53%;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
@layer base {
|
|
158
|
+
* {
|
|
159
|
+
@apply border-border;
|
|
160
|
+
}
|
|
161
|
+
body {
|
|
162
|
+
@apply bg-background text-foreground font-body antialiased;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
@layer utilities {
|
|
167
|
+
.font-display {
|
|
168
|
+
font-family: 'Plus Jakarta Sans', sans-serif;
|
|
169
|
+
}
|
|
170
|
+
.font-body {
|
|
171
|
+
font-family: 'DM Sans', sans-serif;
|
|
172
|
+
}
|
|
173
|
+
.font-mono {
|
|
174
|
+
font-family: 'Space Mono', monospace;
|
|
175
|
+
}
|
|
176
|
+
.safe-area-bottom {
|
|
177
|
+
padding-bottom: env(safe-area-inset-bottom);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* Scrollbar styling */
|
|
182
|
+
::-webkit-scrollbar {
|
|
183
|
+
width: 5px;
|
|
184
|
+
height: 5px;
|
|
185
|
+
}
|
|
186
|
+
::-webkit-scrollbar-track {
|
|
187
|
+
background: transparent;
|
|
188
|
+
}
|
|
189
|
+
::-webkit-scrollbar-thumb {
|
|
190
|
+
background: hsl(var(--muted-foreground) / 0.2);
|
|
191
|
+
border-radius: 3px;
|
|
192
|
+
}
|
|
193
|
+
::-webkit-scrollbar-thumb:hover {
|
|
194
|
+
background: hsl(var(--muted-foreground) / 0.4);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* Pulsing dot animation */
|
|
198
|
+
@keyframes pulse-dot {
|
|
199
|
+
0%, 100% { opacity: 1; transform: scale(1); }
|
|
200
|
+
50% { opacity: 0.6; transform: scale(1.3); }
|
|
201
|
+
}
|
|
202
|
+
.animate-pulse-dot {
|
|
203
|
+
animation: pulse-dot 2s ease-in-out infinite;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* FAB gradient animation */
|
|
207
|
+
@keyframes fab-glow {
|
|
208
|
+
0%, 100% { box-shadow: 0 0 20px hsl(24 90% 55% / 0.4), 0 0 60px hsl(24 90% 55% / 0.1); }
|
|
209
|
+
50% { box-shadow: 0 0 30px hsl(210 80% 55% / 0.4), 0 0 80px hsl(210 80% 55% / 0.1); }
|
|
210
|
+
}
|
|
211
|
+
.animate-fab-glow {
|
|
212
|
+
animation: fab-glow 3s ease-in-out infinite;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* Gradient text */
|
|
216
|
+
.gradient-text {
|
|
217
|
+
background: linear-gradient(135deg, hsl(24 95% 53%), hsl(45 97% 65%));
|
|
218
|
+
-webkit-background-clip: text;
|
|
219
|
+
-webkit-text-fill-color: transparent;
|
|
220
|
+
background-clip: text;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.dark .gradient-text {
|
|
224
|
+
background: linear-gradient(135deg, hsl(24 95% 53%), hsl(45 97% 65%));
|
|
225
|
+
-webkit-background-clip: text;
|
|
226
|
+
-webkit-text-fill-color: transparent;
|
|
227
|
+
background-clip: text;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* Hide scrollbar but keep scroll functionality */
|
|
231
|
+
.no-scrollbar {
|
|
232
|
+
-ms-overflow-style: none;
|
|
233
|
+
scrollbar-width: none;
|
|
234
|
+
}
|
|
235
|
+
.no-scrollbar::-webkit-scrollbar {
|
|
236
|
+
display: none;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* Active press state */
|
|
240
|
+
.press-scale {
|
|
241
|
+
transition: transform 0.1s ease;
|
|
242
|
+
}
|
|
243
|
+
.press-scale:active {
|
|
244
|
+
transform: scale(0.97);
|
|
245
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Copyright 2026 CRMy Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Canonical entity color tokens — single source of truth used by the
|
|
6
|
+
* Sidebar active indicator, Command Palette icons, and any other place
|
|
7
|
+
* entity types are colour-coded throughout the app.
|
|
8
|
+
*/
|
|
9
|
+
export const ENTITY_COLORS = {
|
|
10
|
+
dashboard: { text: 'text-[#14b8a6]', bg: 'bg-[#14b8a6]/15', bar: 'bg-[#14b8a6]' },
|
|
11
|
+
contacts: { text: 'text-primary', bg: 'bg-primary/15', bar: 'bg-primary' },
|
|
12
|
+
accounts: { text: 'text-[#8b5cf6]', bg: 'bg-[#8b5cf6]/15', bar: 'bg-[#8b5cf6]' },
|
|
13
|
+
opportunities: { text: 'text-accent', bg: 'bg-accent/15', bar: 'bg-accent' },
|
|
14
|
+
useCases: { text: 'text-success', bg: 'bg-success/15', bar: 'bg-success' },
|
|
15
|
+
activities: { text: 'text-warning', bg: 'bg-warning/15', bar: 'bg-warning' },
|
|
16
|
+
assignments: { text: 'text-destructive', bg: 'bg-destructive/15',bar: 'bg-destructive' },
|
|
17
|
+
inbox: { text: 'text-destructive', bg: 'bg-destructive/15',bar: 'bg-destructive' },
|
|
18
|
+
} as const;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Copyright 2026 CRMy Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
export const stageConfig: Record<string, { label: string; color: string }> = {
|
|
5
|
+
prospecting: { label: 'Prospecting', color: '#94a3b8' },
|
|
6
|
+
qualification: { label: 'Qualification', color: '#60a5fa' },
|
|
7
|
+
proposal: { label: 'Proposal', color: '#a78bfa' },
|
|
8
|
+
negotiation: { label: 'Negotiation', color: '#fb923c' },
|
|
9
|
+
closed_won: { label: 'Won', color: '#4ade80' },
|
|
10
|
+
closed_lost: { label: 'Lost', color: '#f87171' },
|
|
11
|
+
// Contact stages
|
|
12
|
+
new: { label: 'New', color: '#94a3b8' },
|
|
13
|
+
contacted: { label: 'Contacted', color: '#60a5fa' },
|
|
14
|
+
qualified: { label: 'Qualified', color: '#a78bfa' },
|
|
15
|
+
active: { label: 'Active', color: '#fb923c' },
|
|
16
|
+
inactive: { label: 'Inactive', color: '#f87171' },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const useCaseStageConfig: Record<string, { label: string; color: string }> = {
|
|
20
|
+
discovery: { label: 'Discovery', color: '#94a3b8' },
|
|
21
|
+
poc: { label: 'PoC', color: '#60a5fa' },
|
|
22
|
+
production: { label: 'Production', color: '#4ade80' },
|
|
23
|
+
scaling: { label: 'Scaling', color: '#fb923c' },
|
|
24
|
+
sunset: { label: 'Sunset', color: '#f87171' },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const accountStageConfig: Record<string, { label: string; color: string }> = {
|
|
28
|
+
prospect: { label: 'Prospect', color: '#94a3b8' },
|
|
29
|
+
customer: { label: 'Customer', color: '#4ade80' },
|
|
30
|
+
partner: { label: 'Partner', color: '#a78bfa' },
|
|
31
|
+
churned: { label: 'Churned', color: '#f87171' },
|
|
32
|
+
};
|
package/src/lib/utils.ts
ADDED
package/src/main.tsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Copyright 2026 CRMy Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import ReactDOM from 'react-dom/client';
|
|
6
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
7
|
+
import { App } from './App';
|
|
8
|
+
import './index.css';
|
|
9
|
+
|
|
10
|
+
const queryClient = new QueryClient({
|
|
11
|
+
defaultOptions: {
|
|
12
|
+
queries: {
|
|
13
|
+
staleTime: 30_000,
|
|
14
|
+
retry: 1,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
20
|
+
<React.StrictMode>
|
|
21
|
+
<QueryClientProvider client={queryClient}>
|
|
22
|
+
<App />
|
|
23
|
+
</QueryClientProvider>
|
|
24
|
+
</React.StrictMode>,
|
|
25
|
+
);
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Copyright 2026 CRMy Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { useState, useMemo, useEffect } from 'react';
|
|
5
|
+
import { useNavigate } from 'react-router-dom';
|
|
6
|
+
import { ContactAvatar } from '@/components/crm/ContactAvatar';
|
|
7
|
+
import { TopBar } from '@/components/layout/TopBar';
|
|
8
|
+
import { useAccounts } from '@/api/hooks';
|
|
9
|
+
import { useAppStore } from '@/store/appStore';
|
|
10
|
+
import { useAgentSettings } from '@/contexts/AgentSettingsContext';
|
|
11
|
+
import { ListToolbar, type FilterConfig, type SortOption } from '@/components/crm/ListToolbar';
|
|
12
|
+
import { motion } from 'framer-motion';
|
|
13
|
+
import { LayoutGrid, List, ChevronUp, ChevronDown, Sparkles, Globe, DollarSign, Heart } from 'lucide-react';
|
|
14
|
+
import { PaginationBar } from '@/components/crm/PaginationBar';
|
|
15
|
+
import { useIsMobile } from '@/hooks/use-mobile';
|
|
16
|
+
|
|
17
|
+
type ViewMode = 'table' | 'cards';
|
|
18
|
+
|
|
19
|
+
const filterConfigs: FilterConfig[] = [];
|
|
20
|
+
|
|
21
|
+
const sortOptions: SortOption[] = [
|
|
22
|
+
{ key: 'name', label: 'Name' },
|
|
23
|
+
{ key: 'annual_revenue', label: 'Revenue' },
|
|
24
|
+
{ key: 'health_score', label: 'Health' },
|
|
25
|
+
{ key: 'employee_count', label: 'Employees' },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
|
+
type Account = any;
|
|
30
|
+
|
|
31
|
+
function HealthBadge({ score }: { score: number }) {
|
|
32
|
+
const color = score >= 80 ? 'bg-green-500/15 text-green-400' : score >= 50 ? 'bg-yellow-500/15 text-yellow-400' : 'bg-red-500/15 text-red-400';
|
|
33
|
+
return (
|
|
34
|
+
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${color}`}>
|
|
35
|
+
<Heart className="w-3 h-3" />{score}
|
|
36
|
+
</span>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatRevenue(revenue: number) {
|
|
41
|
+
if (revenue >= 1_000_000) return `$${(revenue / 1_000_000).toFixed(1)}M`;
|
|
42
|
+
if (revenue >= 1_000) return `$${(revenue / 1_000).toFixed(0)}K`;
|
|
43
|
+
return `$${revenue}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default function Accounts() {
|
|
47
|
+
const isMobile = useIsMobile();
|
|
48
|
+
const [view, setView] = useState<ViewMode>('table');
|
|
49
|
+
const effectiveView = isMobile ? 'cards' : view;
|
|
50
|
+
const { openDrawer, openQuickAdd, openAIWithContext } = useAppStore();
|
|
51
|
+
const { enabled: agentEnabled } = useAgentSettings();
|
|
52
|
+
const navigate = useNavigate();
|
|
53
|
+
const [search, setSearch] = useState('');
|
|
54
|
+
const [activeFilters, setActiveFilters] = useState<Record<string, string[]>>({});
|
|
55
|
+
const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' } | null>(null);
|
|
56
|
+
const [page, setPage] = useState(1);
|
|
57
|
+
const [pageSize, setPageSize] = useState(25);
|
|
58
|
+
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
60
|
+
const { data, isLoading } = useAccounts({ q: search || undefined, limit: 200 }) as any;
|
|
61
|
+
const allAccounts: Account[] = data?.data ?? [];
|
|
62
|
+
|
|
63
|
+
const handleFilterChange = (key: string, values: string[]) => {
|
|
64
|
+
setActiveFilters(prev => { const next = { ...prev }; if (values.length === 0) delete next[key]; else next[key] = values; return next; });
|
|
65
|
+
};
|
|
66
|
+
const handleSortChange = (key: string) => {
|
|
67
|
+
setSort(prev => prev?.key === key ? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' });
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const filtered = useMemo(() => {
|
|
71
|
+
let result = [...allAccounts];
|
|
72
|
+
if (activeFilters.industry?.length) result = result.filter(a => activeFilters.industry.includes(a.industry as string));
|
|
73
|
+
if (sort) {
|
|
74
|
+
result.sort((a, b) => {
|
|
75
|
+
const aVal = (a[sort.key] ?? '') as string | number;
|
|
76
|
+
const bVal = (b[sort.key] ?? '') as string | number;
|
|
77
|
+
if (typeof aVal === 'number' && typeof bVal === 'number') return sort.dir === 'asc' ? aVal - bVal : bVal - aVal;
|
|
78
|
+
return sort.dir === 'asc' ? String(aVal).localeCompare(String(bVal)) : String(bVal).localeCompare(String(aVal));
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}, [allAccounts, activeFilters, sort]);
|
|
83
|
+
|
|
84
|
+
useEffect(() => { setPage(1); }, [search, activeFilters, sort]);
|
|
85
|
+
const paginated = filtered.slice((page - 1) * pageSize, page * pageSize);
|
|
86
|
+
|
|
87
|
+
const SortHeader = ({ label, sortKey }: { label: string; sortKey: string }) => (
|
|
88
|
+
<th onClick={() => handleSortChange(sortKey)}
|
|
89
|
+
className="text-left px-4 py-3 text-xs font-display font-semibold text-muted-foreground cursor-pointer hover:text-foreground transition-colors select-none">
|
|
90
|
+
<span className="inline-flex items-center gap-1">
|
|
91
|
+
{label}
|
|
92
|
+
{sort?.key === sortKey ? (sort.dir === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />) : null}
|
|
93
|
+
</span>
|
|
94
|
+
</th>
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className="flex flex-col h-full">
|
|
99
|
+
<TopBar title="Accounts">
|
|
100
|
+
<div className="hidden md:flex items-center gap-1 bg-muted rounded-xl p-0.5">
|
|
101
|
+
<button onClick={() => setView('table')} className={`p-1.5 rounded-lg text-sm transition-all ${view === 'table' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground'}`}>
|
|
102
|
+
<List className="w-4 h-4" />
|
|
103
|
+
</button>
|
|
104
|
+
<button onClick={() => setView('cards')} className={`p-1.5 rounded-lg text-sm transition-all ${view === 'cards' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground'}`}>
|
|
105
|
+
<LayoutGrid className="w-4 h-4" />
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
</TopBar>
|
|
109
|
+
|
|
110
|
+
<ListToolbar
|
|
111
|
+
searchValue={search} onSearchChange={setSearch} searchPlaceholder="Search accounts..."
|
|
112
|
+
filters={filterConfigs} activeFilters={activeFilters} onFilterChange={handleFilterChange}
|
|
113
|
+
onClearFilters={() => setActiveFilters({})} sortOptions={sortOptions} currentSort={sort}
|
|
114
|
+
onSortChange={handleSortChange} onAdd={() => openQuickAdd('account')} addLabel="New Account" entityType="accounts"
|
|
115
|
+
/>
|
|
116
|
+
|
|
117
|
+
<div className="flex-1 overflow-y-auto px-4 md:px-6 pb-24 md:pb-6">
|
|
118
|
+
{isLoading ? (
|
|
119
|
+
<div className="space-y-2 pt-2">
|
|
120
|
+
{[...Array(5)].map((_, i) => <div key={i} className="h-14 bg-muted/50 rounded-xl animate-pulse" />)}
|
|
121
|
+
</div>
|
|
122
|
+
) : filtered.length === 0 ? (
|
|
123
|
+
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
|
124
|
+
<p className="text-sm">No accounts found.</p>
|
|
125
|
+
<button onClick={() => { setSearch(''); setActiveFilters({}); }} className="mt-2 text-xs text-primary font-semibold hover:underline">Clear all filters</button>
|
|
126
|
+
</div>
|
|
127
|
+
) : effectiveView === 'table' ? (
|
|
128
|
+
<div className="bg-card border border-border rounded-2xl overflow-hidden shadow-sm">
|
|
129
|
+
<div className="overflow-x-auto">
|
|
130
|
+
<table className="w-full text-sm">
|
|
131
|
+
<thead>
|
|
132
|
+
<tr className="border-b border-border bg-surface-sunken/50">
|
|
133
|
+
<SortHeader label="Name" sortKey="name" />
|
|
134
|
+
<th className="text-left px-4 py-3 text-xs font-display font-semibold text-muted-foreground">Industry</th>
|
|
135
|
+
<SortHeader label="Revenue" sortKey="annual_revenue" />
|
|
136
|
+
<SortHeader label="Employees" sortKey="employee_count" />
|
|
137
|
+
<SortHeader label="Health" sortKey="health_score" />
|
|
138
|
+
{agentEnabled && <th className="px-2 py-3 w-8"></th>}
|
|
139
|
+
</tr>
|
|
140
|
+
</thead>
|
|
141
|
+
<tbody>
|
|
142
|
+
{paginated.map((a, i) => (
|
|
143
|
+
<tr key={a.id as string} onClick={() => openDrawer('account', a.id as string)}
|
|
144
|
+
className={`border-b border-border last:border-0 hover:bg-primary/5 cursor-pointer group transition-colors ${i % 2 === 1 ? 'bg-surface-sunken/30' : ''}`}>
|
|
145
|
+
<td className="px-4 py-3">
|
|
146
|
+
<div className="flex items-center gap-3">
|
|
147
|
+
<ContactAvatar name={a.name as string} className="w-8 h-8 text-xs" />
|
|
148
|
+
<div>
|
|
149
|
+
<span className="font-semibold text-foreground">{a.name as string}</span>
|
|
150
|
+
{a.website && <p className="text-xs text-muted-foreground">{a.website as string}</p>}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</td>
|
|
154
|
+
<td className="px-4 py-3 text-muted-foreground">{(a.industry as string) || '—'}</td>
|
|
155
|
+
<td className="px-4 py-3 text-foreground font-medium">{a.annual_revenue ? formatRevenue(a.annual_revenue as number) : '—'}</td>
|
|
156
|
+
<td className="px-4 py-3 text-muted-foreground">{(a.employee_count as number) || '—'}</td>
|
|
157
|
+
<td className="px-4 py-3">{a.health_score ? <HealthBadge score={a.health_score as number} /> : '—'}</td>
|
|
158
|
+
{agentEnabled && (
|
|
159
|
+
<td className="px-2 py-3">
|
|
160
|
+
<button onClick={(e) => { e.stopPropagation(); openAIWithContext({ type: 'account', id: a.id as string, name: a.name as string, detail: a.industry as string }); navigate('/agent'); }}
|
|
161
|
+
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-accent/10 transition-all">
|
|
162
|
+
<Sparkles className="w-3.5 h-3.5 text-accent" />
|
|
163
|
+
</button>
|
|
164
|
+
</td>
|
|
165
|
+
)}
|
|
166
|
+
</tr>
|
|
167
|
+
))}
|
|
168
|
+
</tbody>
|
|
169
|
+
</table>
|
|
170
|
+
</div>
|
|
171
|
+
<div className="px-4">
|
|
172
|
+
<PaginationBar page={page} pageSize={pageSize} total={filtered.length} onPageChange={setPage} onPageSizeChange={setPageSize} />
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
) : (
|
|
176
|
+
<>
|
|
177
|
+
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
|
178
|
+
{paginated.map((a, i) => (
|
|
179
|
+
<motion.div key={a.id as string} initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.02 }}
|
|
180
|
+
onClick={() => openDrawer('account', a.id as string)}
|
|
181
|
+
className="bg-card border border-border rounded-2xl p-4 cursor-pointer hover:shadow-lg hover:border-primary/20 transition-all press-scale group relative">
|
|
182
|
+
<div className="flex items-center gap-3 mb-3">
|
|
183
|
+
<ContactAvatar name={a.name as string} className="w-11 h-11 rounded-2xl text-sm" />
|
|
184
|
+
<div className="min-w-0">
|
|
185
|
+
<p className="font-display font-bold text-foreground truncate">{a.name as string}</p>
|
|
186
|
+
<p className="text-xs text-muted-foreground">{(a.industry as string) || '—'}</p>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
<div className="flex items-center gap-2 mb-3">
|
|
190
|
+
{a.health_score && <HealthBadge score={a.health_score as number} />}
|
|
191
|
+
</div>
|
|
192
|
+
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
193
|
+
{a.annual_revenue && <span className="inline-flex items-center gap-1"><DollarSign className="w-3 h-3" />{formatRevenue(a.annual_revenue as number)}</span>}
|
|
194
|
+
{a.website && <span className="inline-flex items-center gap-1"><Globe className="w-3 h-3" />{a.website as string}</span>}
|
|
195
|
+
</div>
|
|
196
|
+
</motion.div>
|
|
197
|
+
))}
|
|
198
|
+
</div>
|
|
199
|
+
<PaginationBar page={page} pageSize={pageSize} total={filtered.length} onPageChange={setPage} onPageSizeChange={setPageSize} />
|
|
200
|
+
</>
|
|
201
|
+
)}
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
}
|