@crmy/web 0.5.5 → 0.5.6

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,396 +0,0 @@
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 { TopBar } from '@/components/layout/TopBar';
7
- import { useUseCases } from '@/api/hooks';
8
- import { useAppStore } from '@/store/appStore';
9
- import { useAgentSettings } from '@/contexts/AgentSettingsContext';
10
- import { ListToolbar, type FilterConfig, type SortOption } from '@/components/crm/ListToolbar';
11
- import { DatePicker } from '@/components/ui/date-picker';
12
- import { motion } from 'framer-motion';
13
- import { Columns3, List, BarChart3, Plus, Sparkles, ChevronUp, ChevronDown } from 'lucide-react';
14
- import { PaginationBar } from '@/components/crm/PaginationBar';
15
- import { useCaseStageConfig } from '@/lib/stageConfig';
16
-
17
- type ViewMode = 'kanban' | 'table' | 'dashboard';
18
- const kanbanStages = ['discovery', 'poc', 'production', 'scaling', 'sunset'];
19
-
20
- type ProdDatePreset = 'all' | 'today' | 'this_week' | 'this_month' | 'this_quarter' | 'custom';
21
-
22
- const PROD_DATE_OPTIONS: { value: ProdDatePreset; label: string }[] = [
23
- { value: 'all', label: 'All' },
24
- { value: 'today', label: 'Today' },
25
- { value: 'this_week', label: 'This Week' },
26
- { value: 'this_month', label: 'This Month' },
27
- { value: 'this_quarter', label: 'This Quarter' },
28
- { value: 'custom', label: 'Custom' },
29
- ];
30
-
31
- function getProdDateRange(preset: ProdDatePreset): { start: Date; end: Date } | null {
32
- if (preset === 'all') return null;
33
- const now = new Date();
34
- if (preset === 'today') {
35
- const start = new Date(now); start.setHours(0, 0, 0, 0);
36
- const end = new Date(now); end.setHours(23, 59, 59, 999);
37
- return { start, end };
38
- }
39
- if (preset === 'this_week') {
40
- const start = new Date(now);
41
- const day = start.getDay();
42
- start.setDate(start.getDate() + (day === 0 ? -6 : 1 - day));
43
- start.setHours(0, 0, 0, 0);
44
- const end = new Date(start); end.setDate(start.getDate() + 6); end.setHours(23, 59, 59, 999);
45
- return { start, end };
46
- }
47
- if (preset === 'this_month') {
48
- const start = new Date(now.getFullYear(), now.getMonth(), 1);
49
- const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
50
- return { start, end };
51
- }
52
- if (preset === 'this_quarter') {
53
- const q = Math.floor(now.getMonth() / 3);
54
- const start = new Date(now.getFullYear(), q * 3, 1);
55
- const end = new Date(now.getFullYear(), q * 3 + 3, 0, 23, 59, 59, 999);
56
- return { start, end };
57
- }
58
- return null;
59
- }
60
-
61
- const filterConfigs: FilterConfig[] = [
62
- { key: 'stage', label: 'Stage', options: kanbanStages.map(k => ({ value: k, label: useCaseStageConfig[k]?.label ?? k })) },
63
- ];
64
- const sortOptions: SortOption[] = [
65
- { key: 'name', label: 'Name' }, { key: 'stage', label: 'Stage' },
66
- { key: 'attributed_arr', label: 'ARR' }, { key: 'health_score', label: 'Health' },
67
- { key: 'target_prod_date', label: 'Prod Date' }, { key: 'created_at', label: 'Created' },
68
- ];
69
-
70
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
- type UseCase = any;
72
-
73
- export default function UseCases() {
74
- const { openDrawer, openQuickAdd, openAIWithContext } = useAppStore();
75
- const { enabled: agentEnabled } = useAgentSettings();
76
- const navigate = useNavigate();
77
- const [view, setView] = useState<ViewMode>('table');
78
- const [search, setSearch] = useState('');
79
- const [activeFilters, setActiveFilters] = useState<Record<string, string[]>>({});
80
- const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' } | null>(null);
81
- const [prodDate, setProdDate] = useState<ProdDatePreset>('all');
82
- const [customFrom, setCustomFrom] = useState('');
83
- const [customTo, setCustomTo] = useState('');
84
- const [page, setPage] = useState(1);
85
- const [pageSize, setPageSize] = useState(25);
86
-
87
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
88
- const { data, isLoading } = useUseCases({ q: search || undefined, limit: 200 }) as any;
89
- const allUseCases: UseCase[] = data?.data ?? [];
90
-
91
- const handleFilterChange = (key: string, values: string[]) => {
92
- setActiveFilters(prev => { const next = { ...prev }; if (values.length === 0) delete next[key]; else next[key] = values; return next; });
93
- };
94
- const handleSortChange = (key: string) => {
95
- setSort(prev => prev?.key === key ? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' });
96
- };
97
-
98
- const filtered = useMemo(() => {
99
- let result = [...allUseCases];
100
- if (activeFilters.stage?.length) result = result.filter(u => activeFilters.stage.includes(u.stage as string));
101
-
102
- // Production date filtering
103
- if (prodDate !== 'all') {
104
- let start: Date, end: Date;
105
- if (prodDate === 'custom') {
106
- start = customFrom ? new Date(customFrom + 'T00:00:00') : new Date(0);
107
- end = customTo ? new Date(customTo + 'T23:59:59') : new Date(8640000000000000);
108
- } else {
109
- const range = getProdDateRange(prodDate);
110
- if (range) { start = range.start; end = range.end; }
111
- else { start = new Date(0); end = new Date(8640000000000000); }
112
- }
113
- result = result.filter(d => {
114
- if (!d.target_prod_date) return true; // unscheduled use cases always visible
115
- const pd = new Date(d.target_prod_date);
116
- return pd >= start && pd <= end;
117
- });
118
- }
119
-
120
- if (sort) {
121
- result.sort((a, b) => {
122
- const aVal = (a[sort.key] ?? '') as string | number;
123
- const bVal = (b[sort.key] ?? '') as string | number;
124
- if (typeof aVal === 'number' && typeof bVal === 'number') return sort.dir === 'asc' ? aVal - bVal : bVal - aVal;
125
- return sort.dir === 'asc' ? String(aVal).localeCompare(String(bVal)) : String(bVal).localeCompare(String(aVal));
126
- });
127
- }
128
- return result;
129
- }, [allUseCases, activeFilters, sort, prodDate, customFrom, customTo]);
130
-
131
- useEffect(() => { setPage(1); }, [search, activeFilters, sort, prodDate, customFrom, customTo]);
132
- const paginated = filtered.slice((page - 1) * pageSize, page * pageSize);
133
-
134
- const SortHeader = ({ label, sortKey }: { label: string; sortKey: string }) => (
135
- <th onClick={() => handleSortChange(sortKey)} 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">
136
- <span className="inline-flex items-center gap-1">
137
- {label}
138
- {sort?.key === sortKey && (sort.dir === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
139
- </span>
140
- </th>
141
- );
142
-
143
- return (
144
- <div className="flex flex-col h-full">
145
- <TopBar title="Use Cases">
146
- <div className="hidden md:flex items-center gap-1 bg-muted rounded-xl p-0.5">
147
- {[
148
- { mode: 'kanban', icon: Columns3 },
149
- { mode: 'table', icon: List },
150
- { mode: 'dashboard', icon: BarChart3 },
151
- ].map(({ mode, icon: Icon }) => (
152
- <button key={mode} onClick={() => setView(mode as ViewMode)}
153
- className={`p-1.5 rounded-lg text-sm transition-all ${view === mode ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground'}`}>
154
- <Icon className="w-4 h-4" />
155
- </button>
156
- ))}
157
- </div>
158
- </TopBar>
159
-
160
- {/* Production date selector */}
161
- <div className="px-4 md:px-6 pt-3 pb-1 flex flex-wrap items-center gap-2">
162
- <span className="text-xs text-muted-foreground font-semibold">Prod Date:</span>
163
- <div className="inline-flex rounded-xl border border-border bg-muted/40 p-0.5 gap-0.5">
164
- {PROD_DATE_OPTIONS.map(opt => (
165
- <button key={opt.value} onClick={() => setProdDate(opt.value)}
166
- className={['px-3 py-1.5 text-xs font-medium rounded-lg transition-all',
167
- prodDate === opt.value ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
168
- ].join(' ')}>
169
- {opt.label}
170
- </button>
171
- ))}
172
- </div>
173
- {prodDate === 'custom' && (
174
- <div className="flex items-center gap-2">
175
- <DatePicker
176
- value={customFrom}
177
- onChange={setCustomFrom}
178
- size="sm"
179
- placeholder="From"
180
- className="w-36"
181
- />
182
- <span className="text-xs text-muted-foreground">to</span>
183
- <DatePicker
184
- value={customTo}
185
- onChange={setCustomTo}
186
- size="sm"
187
- placeholder="To"
188
- className="w-36"
189
- />
190
- </div>
191
- )}
192
- </div>
193
-
194
- <ListToolbar searchValue={search} onSearchChange={setSearch} searchPlaceholder="Search use cases..."
195
- filters={filterConfigs} activeFilters={activeFilters} onFilterChange={handleFilterChange}
196
- onClearFilters={() => { setActiveFilters({}); setProdDate('all'); }} sortOptions={sortOptions} currentSort={sort}
197
- onSortChange={handleSortChange} onAdd={() => openQuickAdd('use-case')} addLabel="New Use Case" entityType="use cases" />
198
-
199
- <div className="flex-1 overflow-y-auto pb-24 md:pb-6">
200
- {isLoading ? (
201
- <div className="flex gap-4 px-4 md:px-6 pt-2">
202
- {[...Array(4)].map((_, i) => <div key={i} className="flex-shrink-0 w-72 h-64 bg-muted/50 rounded-2xl animate-pulse" />)}
203
- </div>
204
- ) : view === 'kanban' ? (
205
- <>
206
- <div className="border-t border-border mb-4" />
207
- <div className="flex gap-3 md:gap-4 px-4 md:px-6 pb-4 overflow-x-auto min-h-full snap-x snap-mandatory md:snap-none no-scrollbar">
208
- {kanbanStages.map((stage) => {
209
- const config = useCaseStageConfig[stage] ?? { label: stage, color: '#94a3b8' };
210
- const stageUCs = filtered.filter((u) => u.stage === stage);
211
- const totalArr = stageUCs.reduce((sum, u) => sum + ((u.attributed_arr as number) || 0), 0);
212
- return (
213
- <div key={stage} className="flex-shrink-0 w-[280px] md:w-72 flex flex-col snap-center">
214
- <div className="flex items-center justify-between mb-3 px-1">
215
- <div className="flex items-center gap-2">
216
- <span className="px-2.5 py-1 rounded-lg text-xs font-semibold" style={{ backgroundColor: config.color + '20', color: config.color }}>
217
- {config.label}
218
- </span>
219
- <span className="text-xs text-muted-foreground font-mono">{stageUCs.length}</span>
220
- </div>
221
- {totalArr > 0 && <span className="text-xs text-muted-foreground font-mono">${(totalArr / 1000).toFixed(0)}K ARR</span>}
222
- </div>
223
- <div className="flex-1 space-y-2">
224
- {stageUCs.map((uc, i) => {
225
- const client = (uc.account_name ?? uc.client ?? '') as string;
226
- const arr = (uc.attributed_arr as number) ?? 0;
227
- const health = (uc.health_score as number) ?? 0;
228
- return (
229
- <motion.div key={uc.id as string} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.03 }}
230
- onClick={() => openDrawer('use-case', uc.id as string)}
231
- className="bg-card border border-border rounded-2xl p-3.5 cursor-pointer hover:border-primary/30 hover:shadow-md transition-all press-scale group">
232
- <div className="flex items-start justify-between">
233
- <p className="text-sm font-display font-bold text-foreground">{uc.name as string}</p>
234
- {agentEnabled && (
235
- <button onClick={(e) => { e.stopPropagation(); openAIWithContext({ type: 'use-case', id: uc.id as string, name: uc.name as string, detail: client }); navigate('/agent'); }}
236
- className="p-0.5 rounded-lg md:opacity-0 md:group-hover:opacity-100 hover:bg-accent/10 transition-all">
237
- <Sparkles className="w-3.5 h-3.5 text-accent" />
238
- </button>
239
- )}
240
- </div>
241
- {client && <p className="text-xs text-muted-foreground mt-1">{client}</p>}
242
- <div className="flex items-center justify-between mt-2.5">
243
- {arr > 0 ? (
244
- <span className="text-sm font-display font-extrabold text-foreground">
245
- ${arr >= 1000 ? `${(arr / 1000).toFixed(0)}K` : arr}
246
- </span>
247
- ) : <span />}
248
- {health > 0 && (
249
- <span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${health >= 80 ? 'bg-green-500/15 text-green-400' : health >= 50 ? 'bg-yellow-500/15 text-yellow-400' : 'bg-red-500/15 text-red-400'}`}>
250
- {health}
251
- </span>
252
- )}
253
- </div>
254
- </motion.div>
255
- );
256
- })}
257
- <button onClick={() => openQuickAdd('use-case')}
258
- className="w-full flex items-center justify-center gap-1 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-xl transition-colors press-scale">
259
- <Plus className="w-3.5 h-3.5" /> Add Use Case
260
- </button>
261
- </div>
262
- </div>
263
- );
264
- })}
265
- </div>
266
- </>
267
- ) : view === 'table' ? (
268
- <div className="px-4 md:px-6">
269
- {filtered.length === 0 ? (
270
- <div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
271
- <p className="text-sm">No use cases found.</p>
272
- <button onClick={() => { setSearch(''); setActiveFilters({}); setProdDate('all'); }} className="mt-2 text-xs text-primary font-semibold hover:underline">Clear all filters</button>
273
- </div>
274
- ) : (
275
- <div className="bg-card border border-border rounded-2xl overflow-hidden shadow-sm">
276
- <div className="overflow-x-auto">
277
- <table className="w-full text-sm">
278
- <thead>
279
- <tr className="border-b border-border bg-surface-sunken/50">
280
- <SortHeader label="Use Case" sortKey="name" />
281
- <th className="text-left px-4 py-3 text-xs font-display font-semibold text-muted-foreground">Client</th>
282
- <SortHeader label="Stage" sortKey="stage" />
283
- <SortHeader label="ARR" sortKey="attributed_arr" />
284
- <SortHeader label="Health" sortKey="health_score" />
285
- <SortHeader label="Prod Date" sortKey="target_prod_date" />
286
- <SortHeader label="Created" sortKey="created_at" />
287
- {agentEnabled && <th className="px-2 py-3 w-8"></th>}
288
- </tr>
289
- </thead>
290
- <tbody>
291
- {paginated.map((uc, i) => {
292
- const stage = (uc.stage ?? '') as string;
293
- const config = useCaseStageConfig[stage] ?? { label: stage, color: '#94a3b8' };
294
- const arr = (uc.attributed_arr ?? 0) as number;
295
- const health = (uc.health_score ?? 0) as number;
296
- const client = (uc.account_name ?? uc.client ?? '') as string;
297
- return (
298
- <tr key={uc.id as string} onClick={() => openDrawer('use-case', uc.id as string)}
299
- className={`border-b border-border last:border-0 hover:bg-primary/5 cursor-pointer transition-colors group ${i % 2 === 1 ? 'bg-surface-sunken/30' : ''}`}>
300
- <td className="px-4 py-3 font-display font-bold text-foreground">{uc.name as string}</td>
301
- <td className="px-4 py-3 text-muted-foreground">{client || '—'}</td>
302
- <td className="px-4 py-3">
303
- <span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold"
304
- style={{ backgroundColor: config.color + '18', color: config.color }}>
305
- {config.label}
306
- </span>
307
- </td>
308
- <td className="px-4 py-3 font-display font-bold text-foreground">{arr > 0 ? `$${(arr / 1000).toFixed(0)}K` : '—'}</td>
309
- <td className="px-4 py-3">
310
- {health > 0 ? (
311
- <span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${health >= 80 ? 'bg-green-500/15 text-green-400' : health >= 50 ? 'bg-yellow-500/15 text-yellow-400' : 'bg-red-500/15 text-red-400'}`}>
312
- {health}
313
- </span>
314
- ) : '—'}
315
- </td>
316
- <td className="px-4 py-3 text-muted-foreground text-xs">
317
- {uc.target_prod_date ? new Date(uc.target_prod_date as string).toLocaleDateString() : '—'}
318
- </td>
319
- <td className="px-4 py-3 text-muted-foreground text-xs">
320
- {uc.created_at ? new Date(uc.created_at as string).toLocaleDateString() : '—'}
321
- </td>
322
- {agentEnabled && (
323
- <td className="px-2 py-3">
324
- <button onClick={(e) => { e.stopPropagation(); openAIWithContext({ type: 'use-case', id: uc.id as string, name: uc.name as string, detail: client }); navigate('/agent'); }}
325
- className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-accent/10 transition-all">
326
- <Sparkles className="w-3.5 h-3.5 text-accent" />
327
- </button>
328
- </td>
329
- )}
330
- </tr>
331
- );
332
- })}
333
- </tbody>
334
- </table>
335
- </div>
336
- <div className="px-4">
337
- <PaginationBar page={page} pageSize={pageSize} total={filtered.length} onPageChange={setPage} onPageSizeChange={setPageSize} />
338
- </div>
339
- </div>
340
- )}
341
- </div>
342
- ) : (
343
- <div className="px-4 md:px-6">
344
- <div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-6">
345
- {[
346
- { label: 'Total ARR', value: filtered.filter(u => u.stage !== 'sunset').reduce((s, u) => s + ((u.attributed_arr as number) || 0), 0), isCount: false },
347
- { label: 'Production ARR', value: filtered.filter(u => u.stage === 'production' || u.stage === 'scaling').reduce((s, u) => s + ((u.attributed_arr as number) || 0), 0), isCount: false },
348
- { label: 'Active Use Cases', value: filtered.filter(u => u.stage !== 'sunset').length, isCount: true },
349
- ].map((card) => (
350
- <div key={card.label} className="bg-card border border-border rounded-2xl p-5 shadow-sm">
351
- <p className="text-xs text-muted-foreground font-display font-semibold">{card.label}</p>
352
- <p className="text-2xl font-display font-extrabold text-foreground mt-1">
353
- {card.isCount ? card.value : (
354
- (card.value as number) >= 1_000_000
355
- ? `$${((card.value as number) / 1_000_000).toFixed(2)}M`
356
- : `$${((card.value as number) / 1000).toFixed(0)}K`
357
- )}
358
- </p>
359
- </div>
360
- ))}
361
- </div>
362
- <div className="bg-card border border-border rounded-2xl p-5 shadow-sm">
363
- <h3 className="font-display font-bold text-foreground mb-4">ARR by stage</h3>
364
- <div className="space-y-4">
365
- {kanbanStages.map((stage) => {
366
- const config = useCaseStageConfig[stage] ?? { label: stage, color: '#94a3b8' };
367
- const total = filtered.filter(u => u.stage === stage).reduce((s, u) => s + ((u.attributed_arr as number) || 0), 0);
368
- const count = filtered.filter(u => u.stage === stage).length;
369
- const max = Math.max(...kanbanStages.map(s => filtered.filter(u => u.stage === s).reduce((sum, u) => sum + ((u.attributed_arr as number) || 0), 0)), 1);
370
- return (
371
- <div key={stage} className="flex items-center gap-3">
372
- <span className="text-xs w-24 truncate font-semibold" style={{ color: config.color }}>{config.label}</span>
373
- <div className="flex-1 h-8 bg-muted rounded-xl overflow-hidden">
374
- <motion.div initial={{ width: 0 }} animate={{ width: total > 0 ? `${(total / max) * 100}%` : '0%' }}
375
- transition={{ duration: 0.8, ease: 'easeOut' }}
376
- className="h-full rounded-xl flex items-center px-3"
377
- style={{ backgroundColor: config.color + '30' }}>
378
- {total > 0 && (
379
- <span className="text-xs font-display font-bold" style={{ color: config.color }}>
380
- ${(total / 1000).toFixed(0)}K
381
- </span>
382
- )}
383
- </motion.div>
384
- </div>
385
- <span className="text-xs text-muted-foreground font-mono w-6 text-right">{count}</span>
386
- </div>
387
- );
388
- })}
389
- </div>
390
- </div>
391
- </div>
392
- )}
393
- </div>
394
- </div>
395
- );
396
- }
@@ -1,261 +0,0 @@
1
- // Copyright 2026 CRMy Contributors
2
- // SPDX-License-Identifier: Apache-2.0
3
-
4
- import { useState } from 'react';
5
- import { useNavigate, Navigate } from 'react-router-dom';
6
- import { Button } from '../../components/ui/button';
7
- import { Input } from '../../components/ui/input';
8
- import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card';
9
- import { auth, setToken, setUser } from '../../api/client';
10
- import crMyLogo from '../../assets/crmy-logo.png';
11
- import { X, Check, Sun, Moon } from 'lucide-react';
12
- import { useAppStore } from '../../store/appStore';
13
-
14
- const PASSWORD_RULES = [
15
- { label: 'At least 8 characters', test: (p: string) => p.length >= 8 },
16
- { label: 'One uppercase letter', test: (p: string) => /[A-Z]/.test(p) },
17
- { label: 'One number', test: (p: string) => /\d/.test(p) },
18
- { label: 'One special character', test: (p: string) => /[^A-Za-z0-9]/.test(p) },
19
- ];
20
-
21
- function isValidEmail(email: string) {
22
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
23
- }
24
-
25
- function isValidPassword(password: string) {
26
- return PASSWORD_RULES.every((r) => r.test(password));
27
- }
28
-
29
- // Light-mode inline overrides: card always appears as a dark panel to contrast against the light page background
30
- const CHARCOAL_LIGHT_STYLE: React.CSSProperties = {
31
- '--background': '220 10% 32%',
32
- '--card': '220 14% 18%',
33
- '--card-foreground': '36 15% 92%',
34
- '--popover': '220 14% 22%',
35
- '--popover-foreground': '36 15% 92%',
36
- '--muted': '220 10% 24%',
37
- '--muted-foreground': '220 8% 62%',
38
- '--input': '220 10% 32%',
39
- '--border': '220 10% 28%',
40
- '--foreground': '36 15% 92%',
41
- } as React.CSSProperties;
42
-
43
- const WARM_LIGHT_STYLE: React.CSSProperties = {
44
- '--background': '15 25% 32%',
45
- '--card': '15 22% 18%',
46
- '--card-foreground': '25 100% 95%',
47
- '--popover': '15 22% 22%',
48
- '--popover-foreground': '25 100% 95%',
49
- '--muted': '15 20% 24%',
50
- '--muted-foreground': '20 15% 62%',
51
- '--input': '15 20% 32%',
52
- '--border': '15 20% 28%',
53
- '--foreground': '25 100% 95%',
54
- } as React.CSSProperties;
55
-
56
- export function LoginPage() {
57
- const navigate = useNavigate();
58
- const { darkVariant } = useAppStore();
59
- const isCharcoal = darkVariant === 'charcoal';
60
-
61
- const [mode, setMode] = useState<'login' | 'register'>('login');
62
- const [email, setEmail] = useState('');
63
- const [password, setPassword] = useState('');
64
- const [name, setName] = useState('');
65
- const [tenantName, setTenantName] = useState('');
66
- const [error, setError] = useState('');
67
- const [loading, setLoading] = useState(false);
68
- const [touched, setTouched] = useState<Record<string, boolean>>({});
69
- const [isDark, setIsDark] = useState(() => document.documentElement.classList.contains('dark'));
70
-
71
- const toggleTheme = () => {
72
- const next = !isDark;
73
- setIsDark(next);
74
- document.documentElement.classList.toggle('dark', next);
75
- localStorage.setItem('crmy_theme', next ? 'dark' : 'light');
76
- };
77
-
78
- if (localStorage.getItem('crmy_token')) {
79
- return <Navigate to="/" replace />;
80
- }
81
-
82
- const touch = (field: string) => setTouched((t) => ({ ...t, [field]: true }));
83
-
84
- const emailError = touched.email && !isValidEmail(email) ? 'Enter a valid email address' : '';
85
- const passwordError =
86
- touched.password && mode === 'register' && !isValidPassword(password)
87
- ? 'Password does not meet requirements'
88
- : touched.password && !password
89
- ? 'Password is required'
90
- : '';
91
- const nameError = touched.name && mode === 'register' && !name.trim() ? 'Name is required' : '';
92
- const tenantError =
93
- touched.tenantName && mode === 'register' && !tenantName.trim() ? 'Organization name is required' : '';
94
-
95
- const handleSubmit = async (e: React.FormEvent) => {
96
- e.preventDefault();
97
- setTouched({ email: true, password: true, name: true, tenantName: true });
98
-
99
- if (!isValidEmail(email)) return;
100
- if (!password) return;
101
- if (mode === 'register') {
102
- if (!isValidPassword(password) || !name.trim() || !tenantName.trim()) return;
103
- }
104
-
105
- setError('');
106
- setLoading(true);
107
- try {
108
- const result =
109
- mode === 'login'
110
- ? await auth.login(email, password)
111
- : await auth.register({ email, password, name, tenant_name: tenantName });
112
- setToken(result.token);
113
- setUser(result.user);
114
- navigate('/');
115
- } catch (err) {
116
- setError(err instanceof Error ? err.message : 'Authentication failed');
117
- } finally {
118
- setLoading(false);
119
- }
120
- };
121
-
122
- const switchMode = (next: 'login' | 'register') => {
123
- setMode(next);
124
- setTouched({});
125
- setError('');
126
- };
127
-
128
- // Page background: light mode uses a tinted background; dark mode uses CSS variable
129
- const pageBg = isDark ? 'bg-background' : isCharcoal ? 'bg-[hsl(220_20%_93%)]' : 'bg-[hsl(30_25%_93%)]';
130
-
131
- // Card wrapper: always rendered as a dark panel, variant-aware
132
- const cardClass = isCharcoal ? 'dark charcoal' : 'dark';
133
- // In light mode, override CSS vars so the card appears as a medium-dark panel against the light page
134
- const cardStyle = !isDark ? (isCharcoal ? CHARCOAL_LIGHT_STYLE : WARM_LIGHT_STYLE) : undefined;
135
-
136
- return (
137
- <div className={`flex min-h-screen items-center justify-center p-4 ${pageBg}`}>
138
- <button
139
- onClick={toggleTheme}
140
- className="fixed top-4 right-4 p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
141
- aria-label="Toggle theme"
142
- >
143
- {isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
144
- </button>
145
- <div className={`${cardClass} w-full max-w-md`} style={cardStyle}>
146
- <Card className="w-full">
147
- <CardHeader className="text-left">
148
- <div className="flex items-center gap-3 mb-1">
149
- <img src={crMyLogo} alt="CRMy" className="h-14 w-14 object-contain" />
150
- <CardTitle className="font-brand font-bold text-2xl flex-1">
151
- {mode === 'login' ? (
152
- <>Sign in to <span className="text-primary">CRMy</span></>
153
- ) : (
154
- <>Create your account</>
155
- )}
156
- </CardTitle>
157
- {mode === 'register' && (
158
- <button
159
- onClick={() => switchMode('login')}
160
- className="ml-auto p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
161
- aria-label="Back to sign in"
162
- >
163
- <X className="w-5 h-5" />
164
- </button>
165
- )}
166
- </div>
167
- {mode === 'register' && (
168
- <CardDescription className="text-center">Set up a new tenant and admin account</CardDescription>
169
- )}
170
- </CardHeader>
171
- <CardContent>
172
- <form onSubmit={handleSubmit} className="space-y-4" noValidate>
173
- {mode === 'register' && (
174
- <>
175
- <div>
176
- <label className="mb-1 block text-sm font-medium">Your name <span className="text-destructive">*</span></label>
177
- <Input
178
- value={name}
179
- onChange={(e) => setName(e.target.value)}
180
- onBlur={() => touch('name')}
181
- aria-invalid={!!nameError}
182
- />
183
- {nameError && <p className="mt-1 text-xs text-destructive">{nameError}</p>}
184
- </div>
185
- <div>
186
- <label className="mb-1 block text-sm font-medium">Organization name <span className="text-destructive">*</span></label>
187
- <Input
188
- value={tenantName}
189
- onChange={(e) => setTenantName(e.target.value)}
190
- onBlur={() => touch('tenantName')}
191
- aria-invalid={!!tenantError}
192
- />
193
- {tenantError && <p className="mt-1 text-xs text-destructive">{tenantError}</p>}
194
- </div>
195
- </>
196
- )}
197
- <div>
198
- <label className="mb-1 block text-sm font-medium">Email <span className="text-destructive">*</span></label>
199
- <Input
200
- type="email"
201
- value={email}
202
- onChange={(e) => setEmail(e.target.value)}
203
- onBlur={() => touch('email')}
204
- aria-invalid={!!emailError}
205
- />
206
- {emailError && <p className="mt-1 text-xs text-destructive">{emailError}</p>}
207
- </div>
208
- <div>
209
- <label className="mb-1 block text-sm font-medium">Password <span className="text-destructive">*</span></label>
210
- <Input
211
- type="password"
212
- value={password}
213
- onChange={(e) => setPassword(e.target.value)}
214
- onBlur={() => touch('password')}
215
- aria-invalid={!!passwordError}
216
- />
217
- {passwordError && mode !== 'register' && (
218
- <p className="mt-1 text-xs text-destructive">{passwordError}</p>
219
- )}
220
- {mode === 'register' && touched.password && (
221
- <ul className="mt-2 space-y-1">
222
- {PASSWORD_RULES.map((rule) => {
223
- const passing = rule.test(password);
224
- return (
225
- <li key={rule.label} className={`flex items-center gap-1.5 text-xs ${passing ? 'text-success' : 'text-muted-foreground'}`}>
226
- <Check className={`w-3 h-3 ${passing ? 'opacity-100' : 'opacity-30'}`} />
227
- {rule.label}
228
- </li>
229
- );
230
- })}
231
- </ul>
232
- )}
233
- </div>
234
- {error && <p className="text-sm text-destructive">{error}</p>}
235
- <Button type="submit" className="w-full" disabled={loading}>
236
- {loading ? 'Please wait...' : mode === 'login' ? 'Sign in' : 'Create account'}
237
- </Button>
238
- </form>
239
- <div className="mt-4 text-center text-sm text-muted-foreground">
240
- {mode === 'login' ? (
241
- <>
242
- Don't have an account?{' '}
243
- <button className="text-primary hover:underline" onClick={() => switchMode('register')}>
244
- Register
245
- </button>
246
- </>
247
- ) : (
248
- <>
249
- Already have an account?{' '}
250
- <button className="text-primary hover:underline" onClick={() => switchMode('login')}>
251
- Sign in
252
- </button>
253
- </>
254
- )}
255
- </div>
256
- </CardContent>
257
- </Card>
258
- </div>
259
- </div>
260
- );
261
- }