@crmy/web 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/index.html +23 -0
  2. package/package.json +76 -0
  3. package/postcss.config.js +6 -0
  4. package/public/android-chrome-192x192.png +0 -0
  5. package/public/android-chrome-512x512.png +0 -0
  6. package/public/apple-touch-icon.png +0 -0
  7. package/public/favicon-16x16.png +0 -0
  8. package/public/favicon-32x32.png +0 -0
  9. package/public/favicon.ico +0 -0
  10. package/public/favicon.svg +13 -0
  11. package/public/site.webmanifest +1 -0
  12. package/src/App.tsx +158 -0
  13. package/src/api/client.ts +82 -0
  14. package/src/api/hooks.ts +689 -0
  15. package/src/assets/crmy-logo.png +0 -0
  16. package/src/components/CustomFields.tsx +240 -0
  17. package/src/components/NavLink.tsx +28 -0
  18. package/src/components/crm/AIFab.tsx +37 -0
  19. package/src/components/crm/AccountDrawer.tsx +372 -0
  20. package/src/components/crm/ActivityTimeline.tsx +115 -0
  21. package/src/components/crm/AssignmentDrawer.tsx +396 -0
  22. package/src/components/crm/BriefingPanel.tsx +217 -0
  23. package/src/components/crm/CommandPalette.tsx +254 -0
  24. package/src/components/crm/ContactAvatar.tsx +49 -0
  25. package/src/components/crm/ContactDrawer.tsx +438 -0
  26. package/src/components/crm/ContextPanel.tsx +200 -0
  27. package/src/components/crm/CrmWidgets.tsx +417 -0
  28. package/src/components/crm/DrawerShell.tsx +77 -0
  29. package/src/components/crm/ListToolbar.tsx +252 -0
  30. package/src/components/crm/OpportunityDrawer.tsx +372 -0
  31. package/src/components/crm/PaginationBar.tsx +111 -0
  32. package/src/components/crm/QuickAddDrawer.tsx +652 -0
  33. package/src/components/crm/ShortcutsOverlay.tsx +65 -0
  34. package/src/components/crm/UseCaseDrawer.tsx +454 -0
  35. package/src/components/layout/MobileNav.tsx +49 -0
  36. package/src/components/layout/Sidebar.tsx +157 -0
  37. package/src/components/layout/TopBar.tsx +54 -0
  38. package/src/components/settings/ActorsSettings.tsx +1190 -0
  39. package/src/components/ui/accordion.tsx +52 -0
  40. package/src/components/ui/alert-dialog.tsx +104 -0
  41. package/src/components/ui/alert.tsx +43 -0
  42. package/src/components/ui/aspect-ratio.tsx +5 -0
  43. package/src/components/ui/avatar.tsx +38 -0
  44. package/src/components/ui/badge.tsx +29 -0
  45. package/src/components/ui/breadcrumb.tsx +90 -0
  46. package/src/components/ui/button.tsx +47 -0
  47. package/src/components/ui/calendar.tsx +54 -0
  48. package/src/components/ui/card.tsx +43 -0
  49. package/src/components/ui/carousel.tsx +224 -0
  50. package/src/components/ui/chart.tsx +303 -0
  51. package/src/components/ui/checkbox.tsx +26 -0
  52. package/src/components/ui/collapsible.tsx +9 -0
  53. package/src/components/ui/command.tsx +132 -0
  54. package/src/components/ui/context-menu.tsx +178 -0
  55. package/src/components/ui/date-picker.tsx +313 -0
  56. package/src/components/ui/dialog.tsx +95 -0
  57. package/src/components/ui/drawer.tsx +87 -0
  58. package/src/components/ui/dropdown-menu.tsx +179 -0
  59. package/src/components/ui/form.tsx +129 -0
  60. package/src/components/ui/hover-card.tsx +27 -0
  61. package/src/components/ui/input-otp.tsx +61 -0
  62. package/src/components/ui/input.tsx +22 -0
  63. package/src/components/ui/label.tsx +17 -0
  64. package/src/components/ui/menubar.tsx +207 -0
  65. package/src/components/ui/navigation-menu.tsx +120 -0
  66. package/src/components/ui/pagination.tsx +81 -0
  67. package/src/components/ui/popover.tsx +29 -0
  68. package/src/components/ui/progress.tsx +23 -0
  69. package/src/components/ui/radio-group.tsx +36 -0
  70. package/src/components/ui/resizable.tsx +37 -0
  71. package/src/components/ui/scroll-area.tsx +38 -0
  72. package/src/components/ui/select.tsx +143 -0
  73. package/src/components/ui/separator.tsx +20 -0
  74. package/src/components/ui/sheet.tsx +107 -0
  75. package/src/components/ui/sidebar.tsx +637 -0
  76. package/src/components/ui/skeleton.tsx +7 -0
  77. package/src/components/ui/slider.tsx +23 -0
  78. package/src/components/ui/sonner.tsx +24 -0
  79. package/src/components/ui/switch.tsx +27 -0
  80. package/src/components/ui/table.tsx +72 -0
  81. package/src/components/ui/tabs.tsx +53 -0
  82. package/src/components/ui/textarea.tsx +21 -0
  83. package/src/components/ui/toast.tsx +111 -0
  84. package/src/components/ui/toaster.tsx +24 -0
  85. package/src/components/ui/toggle-group.tsx +49 -0
  86. package/src/components/ui/toggle.tsx +37 -0
  87. package/src/components/ui/tooltip.tsx +28 -0
  88. package/src/components/ui/use-toast.ts +1 -0
  89. package/src/components/ui/utils.ts +9 -0
  90. package/src/contexts/AgentSettingsContext.tsx +24 -0
  91. package/src/hooks/use-mobile.tsx +19 -0
  92. package/src/hooks/use-toast.ts +186 -0
  93. package/src/hooks/useKeyboardShortcuts.ts +95 -0
  94. package/src/hooks/useTheme.ts +24 -0
  95. package/src/index.css +245 -0
  96. package/src/lib/entityColors.ts +18 -0
  97. package/src/lib/stageConfig.ts +32 -0
  98. package/src/lib/utils.ts +6 -0
  99. package/src/main.tsx +25 -0
  100. package/src/pages/Accounts.tsx +205 -0
  101. package/src/pages/Activities.tsx +251 -0
  102. package/src/pages/Agent.tsx +237 -0
  103. package/src/pages/AgentSettings.tsx +544 -0
  104. package/src/pages/Assignments.tsx +750 -0
  105. package/src/pages/Contacts.tsx +200 -0
  106. package/src/pages/Dashboard.tsx +143 -0
  107. package/src/pages/Inbox.tsx +615 -0
  108. package/src/pages/NotFound.tsx +24 -0
  109. package/src/pages/Opportunities.tsx +386 -0
  110. package/src/pages/SearchResults.tsx +49 -0
  111. package/src/pages/Settings.tsx +1884 -0
  112. package/src/pages/UseCases.tsx +396 -0
  113. package/src/pages/auth/Login.tsx +261 -0
  114. package/src/pages/hitl/HITL.tsx +101 -0
  115. package/src/store/appStore.ts +103 -0
  116. package/src/vite-env.d.ts +14 -0
  117. package/tailwind.config.js +121 -0
  118. package/tsconfig.json +24 -0
  119. package/vite.config.ts +27 -0
@@ -0,0 +1,417 @@
1
+ // Copyright 2026 CRMy Contributors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { useState } from 'react';
5
+ import { Phone, Mail, StickyNote, CheckSquare, MessageSquare, ChevronDown, ChevronUp, Presentation, FileText, Search, ArrowRightLeft, RefreshCw, Activity, Heart, Bot, User } from 'lucide-react';
6
+ import { ContactAvatar } from './ContactAvatar';
7
+ import { useOpportunities, useUseCases, useAccounts, useActivities, useCustomFields } from '@/api/hooks';
8
+ import { useAppStore } from '@/store/appStore';
9
+ import { stageConfig, useCaseStageConfig } from '@/lib/stageConfig';
10
+
11
+ export function StageBadge({ stage }: { stage: string }) {
12
+ const config = stageConfig[stage] ?? { label: stage, color: '#94a3b8' };
13
+ return (
14
+ <span
15
+ className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold"
16
+ style={{ backgroundColor: config.color + '18', color: config.color }}
17
+ >
18
+ {config.label}
19
+ </span>
20
+ );
21
+ }
22
+
23
+ export function LeadScoreBadge({ score }: { score: number }) {
24
+ const color = score >= 80 ? 'hsl(152, 55%, 42%)' : score >= 50 ? 'hsl(38, 92%, 50%)' : 'hsl(var(--muted-foreground))';
25
+ return (
26
+ <span
27
+ className="inline-flex items-center justify-center w-8 h-6 rounded-lg text-xs font-mono font-bold"
28
+ style={{ backgroundColor: color + '18', color }}
29
+ >
30
+ {score}
31
+ </span>
32
+ );
33
+ }
34
+
35
+ export function AgentStatusDot() {
36
+ return <div className="w-2.5 h-2.5 rounded-full bg-success animate-pulse-dot" />;
37
+ }
38
+
39
+ export function activityIcon(type: string) {
40
+ switch (type) {
41
+ case 'call': return <Phone className="w-3.5 h-3.5" />;
42
+ case 'email': return <Mail className="w-3.5 h-3.5" />;
43
+ case 'meeting': return <MessageSquare className="w-3.5 h-3.5" />;
44
+ case 'task': return <CheckSquare className="w-3.5 h-3.5" />;
45
+ case 'demo': return <Presentation className="w-3.5 h-3.5" />;
46
+ case 'proposal': return <FileText className="w-3.5 h-3.5" />;
47
+ case 'research': return <Search className="w-3.5 h-3.5" />;
48
+ case 'handoff': return <ArrowRightLeft className="w-3.5 h-3.5" />;
49
+ case 'status_update': return <Activity className="w-3.5 h-3.5" />;
50
+ case 'stage_change': return <RefreshCw className="w-3.5 h-3.5" />;
51
+ case 'health_update': return <Heart className="w-3.5 h-3.5" />;
52
+ default: return <StickyNote className="w-3.5 h-3.5" />;
53
+ }
54
+ }
55
+
56
+ const OUTCOME_COLORS: Record<string, string> = {
57
+ connected: 'hsl(152, 55%, 42%)',
58
+ positive: 'hsl(152, 55%, 42%)',
59
+ voicemail: 'hsl(38, 92%, 50%)',
60
+ neutral: 'hsl(38, 92%, 50%)',
61
+ follow_up_needed: 'hsl(38, 92%, 50%)',
62
+ negative: 'hsl(var(--destructive))',
63
+ no_show: 'hsl(var(--destructive))',
64
+ };
65
+
66
+ function OutcomeBadge({ outcome }: { outcome: string }) {
67
+ const color = OUTCOME_COLORS[outcome] ?? 'hsl(var(--muted-foreground))';
68
+ const label = outcome.replace(/_/g, ' ');
69
+ return (
70
+ <span
71
+ className="inline-flex items-center px-1.5 py-0.5 rounded-md text-[10px] font-medium capitalize"
72
+ style={{ backgroundColor: color + '18', color }}
73
+ >
74
+ {label}
75
+ </span>
76
+ );
77
+ }
78
+
79
+ const SUBJECT_TYPE_LABELS: Record<string, string> = {
80
+ contact: 'Contact',
81
+ account: 'Account',
82
+ opportunity: 'Opp',
83
+ use_case: 'Use Case',
84
+ };
85
+
86
+ type DrawerType = 'contact' | 'opportunity' | 'use-case' | 'account';
87
+ const SUBJECT_TYPE_DRAWER: Record<string, DrawerType> = {
88
+ contact: 'contact',
89
+ account: 'account',
90
+ opportunity: 'opportunity',
91
+ use_case: 'use-case',
92
+ };
93
+
94
+ export interface ActivityItem {
95
+ id: string;
96
+ type: string;
97
+ subject?: string;
98
+ description?: string;
99
+ body?: string;
100
+ contact_name?: string;
101
+ contactName?: string;
102
+ created_at?: string;
103
+ timestamp?: string;
104
+ // Context Engine fields
105
+ subject_type?: string;
106
+ subject_id?: string;
107
+ occurred_at?: string;
108
+ outcome?: string;
109
+ performed_by?: string;
110
+ performer_name?: string;
111
+ contact_id?: string;
112
+ account_id?: string;
113
+ opportunity_id?: string;
114
+ }
115
+
116
+ interface ActivityFeedProps {
117
+ limit?: number;
118
+ activities?: ActivityItem[];
119
+ filterWindow?: 'today' | 'week';
120
+ }
121
+
122
+ export function ActivityFeed({ limit, activities: propActivities, filterWindow }: ActivityFeedProps) {
123
+ const { openDrawer } = useAppStore();
124
+ const fetchLimit = filterWindow ? 100 : (limit ?? 20);
125
+ const { data, isLoading } = useActivities(propActivities ? undefined : { limit: fetchLimit });
126
+ let items: ActivityItem[] = (propActivities ?? data?.data ?? []) as ActivityItem[];
127
+
128
+ if (filterWindow) {
129
+ const cutoff = new Date();
130
+ if (filterWindow === 'today') {
131
+ cutoff.setHours(0, 0, 0, 0);
132
+ } else {
133
+ cutoff.setDate(cutoff.getDate() - 7);
134
+ cutoff.setHours(0, 0, 0, 0);
135
+ }
136
+ items = items.filter((a) => {
137
+ const ts = a.occurred_at ?? a.created_at ?? a.timestamp;
138
+ return ts ? new Date(ts) >= cutoff : false;
139
+ });
140
+ }
141
+
142
+ const displayed = limit ? items.slice(0, limit) : items;
143
+
144
+ if (isLoading && !propActivities) {
145
+ return (
146
+ <div className="space-y-3">
147
+ {[...Array(4)].map((_, i) => (
148
+ <div key={i} className="flex gap-3 animate-pulse">
149
+ <div className="w-7 h-7 rounded-xl bg-muted flex-shrink-0" />
150
+ <div className="flex-1 space-y-1">
151
+ <div className="h-3 bg-muted rounded w-3/4" />
152
+ <div className="h-2.5 bg-muted rounded w-1/2" />
153
+ </div>
154
+ </div>
155
+ ))}
156
+ </div>
157
+ );
158
+ }
159
+
160
+ if (displayed.length === 0) {
161
+ return <p className="text-sm text-muted-foreground">No activity yet.</p>;
162
+ }
163
+
164
+ return (
165
+ <div className="space-y-1">
166
+ {displayed.map((a) => {
167
+ const name = a.contact_name ?? a.contactName ?? '';
168
+ const desc = a.description ?? a.body ?? a.subject ?? '';
169
+ const ts = a.occurred_at ?? a.created_at ?? a.timestamp ?? '';
170
+ const contactId = a.contact_id;
171
+ const hasSubjectLink = a.subject_type && a.subject_id;
172
+ const isClickable = hasSubjectLink || contactId;
173
+
174
+ const handleClick = () => {
175
+ if (hasSubjectLink) {
176
+ const drawerType = SUBJECT_TYPE_DRAWER[a.subject_type!];
177
+ if (drawerType) openDrawer(drawerType, a.subject_id!);
178
+ } else if (contactId) {
179
+ openDrawer('contact', contactId);
180
+ }
181
+ };
182
+
183
+ // Build metadata segments
184
+ const meta: string[] = [];
185
+ if (a.performer_name) meta.push(a.performer_name);
186
+ else if (name) meta.push(name);
187
+ if (ts) meta.push(new Date(ts).toLocaleDateString());
188
+
189
+ return (
190
+ <div
191
+ key={a.id}
192
+ className={`flex gap-3 py-2 ${isClickable ? 'cursor-pointer hover:bg-muted/40 rounded-xl px-2 -mx-2 transition-colors' : ''}`}
193
+ onClick={isClickable ? handleClick : undefined}
194
+ >
195
+ <div className="w-7 h-7 rounded-xl bg-muted flex items-center justify-center text-muted-foreground flex-shrink-0 mt-0.5">
196
+ {activityIcon(a.type)}
197
+ </div>
198
+ <div className="flex-1 min-w-0">
199
+ <div className="flex items-start gap-2">
200
+ <p className="text-sm text-foreground flex-1 min-w-0 truncate">
201
+ {desc || `${a.type}${name ? ` with ${name}` : ''}`}
202
+ </p>
203
+ {a.outcome && <OutcomeBadge outcome={a.outcome} />}
204
+ </div>
205
+ <div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
206
+ {hasSubjectLink && (
207
+ <span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-primary/80 bg-primary/8 px-1.5 py-0.5 rounded">
208
+ {SUBJECT_TYPE_LABELS[a.subject_type!] ?? a.subject_type}
209
+ </span>
210
+ )}
211
+ {a.performer_name && (
212
+ <span className="inline-flex items-center gap-0.5 text-[10px] text-muted-foreground">
213
+ {a.performed_by ? <Bot className="w-2.5 h-2.5" /> : <User className="w-2.5 h-2.5" />}
214
+ {a.performer_name}
215
+ </span>
216
+ )}
217
+ <span className="text-xs text-muted-foreground">
218
+ {meta.filter(Boolean).join(' · ')}
219
+ </span>
220
+ </div>
221
+ </div>
222
+ </div>
223
+ );
224
+ })}
225
+ </div>
226
+ );
227
+ }
228
+
229
+ export function PipelineSnapshot() {
230
+ const [view, setView] = useState<'opportunities' | 'use-cases'>('opportunities');
231
+ const { data: oppsData } = useOpportunities({ limit: 200 });
232
+ const { data: ucData } = useUseCases({ limit: 200 });
233
+
234
+ const opps = (oppsData?.data ?? []) as Record<string, unknown>[];
235
+ const ucs = (ucData?.data ?? []) as Record<string, unknown>[];
236
+
237
+ const dealStages = ['prospecting', 'qualification', 'proposal', 'negotiation', 'closed_won', 'closed_lost'];
238
+ const ucStages = ['discovery', 'poc', 'production', 'scaling', 'sunset'];
239
+
240
+ const dealData = dealStages.map((stage) => {
241
+ const stageDeals = opps.filter((d: Record<string, unknown>) => d.stage === stage);
242
+ const cfg = stageConfig[stage] ?? { label: stage, color: '#94a3b8' };
243
+ return {
244
+ key: stage,
245
+ label: cfg.label,
246
+ color: cfg.color,
247
+ count: stageDeals.length,
248
+ total: stageDeals.reduce((sum: number, d: Record<string, unknown>) => sum + (parseFloat(String(d.amount ?? 0)) || 0), 0),
249
+ };
250
+ });
251
+
252
+ const ucStateData = ucStages.map((stage) => {
253
+ const stageUCs = ucs.filter((u: Record<string, unknown>) => u.stage === stage);
254
+ const cfg = useCaseStageConfig[stage] ?? { label: stage, color: '#94a3b8' };
255
+ return {
256
+ key: stage,
257
+ label: cfg.label,
258
+ color: cfg.color,
259
+ count: stageUCs.length,
260
+ total: stageUCs.reduce((sum: number, u: Record<string, unknown>) => sum + (parseFloat(String(u.attributed_arr ?? 0)) || 0), 0),
261
+ };
262
+ });
263
+
264
+ const data = view === 'opportunities' ? dealData : ucStateData;
265
+ const maxTotal = Math.max(...data.map((s) => s.total), 1);
266
+ const valueLabel = (v: number): string => {
267
+ if (!isFinite(v) || isNaN(v) || v <= 0) return '$0';
268
+ if (v >= 1e12) return `$${(v / 1e12).toFixed(1)}T`;
269
+ if (v >= 1e9) return `$${v >= 10e9 ? Math.round(v / 1e9) : (v / 1e9).toFixed(1)}B`;
270
+ if (v >= 1e6) return `$${v >= 10e6 ? Math.round(v / 1e6) : (v / 1e6).toFixed(1)}M`;
271
+ if (v >= 1e3) return `$${v >= 10e3 ? Math.round(v / 1e3) : (v / 1e3).toFixed(1)}K`;
272
+ return `$${Math.round(v)}`;
273
+ };
274
+
275
+ return (
276
+ <div className="bg-card border border-border rounded-2xl p-5 shadow-sm">
277
+ <div className="flex items-center justify-between mb-4">
278
+ <h3 className="font-display font-bold text-foreground">Pipeline health</h3>
279
+ <div className="flex items-center gap-0.5 bg-muted rounded-lg p-0.5">
280
+ <button
281
+ onClick={() => setView('opportunities')}
282
+ className={`px-2.5 py-1 rounded-md text-[11px] font-semibold transition-all ${view === 'opportunities' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
283
+ >
284
+ Opps
285
+ </button>
286
+ <button
287
+ onClick={() => setView('use-cases')}
288
+ className={`px-2.5 py-1 rounded-md text-[11px] font-semibold transition-all ${view === 'use-cases' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
289
+ >
290
+ Use Cases
291
+ </button>
292
+ </div>
293
+ </div>
294
+ <div className="flex gap-3 overflow-x-auto no-scrollbar md:hidden pb-1 -mx-1 px-1">
295
+ {data.map((s) => (
296
+ <div key={s.key} className="flex-shrink-0 w-28 rounded-xl p-3 border border-border" style={{ backgroundColor: s.color + '08' }}>
297
+ <div className="w-3 h-3 rounded-full mb-2" style={{ backgroundColor: s.color }} />
298
+ <p className="text-[10px] text-muted-foreground font-medium truncate">{s.label}</p>
299
+ <p className="text-lg font-display font-bold text-foreground">{s.count}</p>
300
+ <p className="text-[10px] text-muted-foreground font-mono truncate">{valueLabel(s.total)}</p>
301
+ </div>
302
+ ))}
303
+ </div>
304
+ <div className="hidden md:block space-y-3">
305
+ {data.map((s) => (
306
+ <div key={s.key} className="flex items-center gap-2">
307
+ <span className="text-xs w-24 flex-shrink-0 truncate font-medium" style={{ color: s.color }}>{s.label}</span>
308
+ <div className="flex-1 min-w-0 h-2.5 bg-muted rounded-full overflow-hidden">
309
+ <div className="h-full rounded-full transition-all duration-700" style={{ width: `${(s.total / maxTotal) * 100}%`, backgroundColor: s.color }} />
310
+ </div>
311
+ <span className="text-xs text-muted-foreground font-mono w-6 flex-shrink-0 text-right">{s.count}</span>
312
+ <span className="text-xs text-foreground font-mono w-[4.5rem] flex-shrink-0 text-right truncate">{valueLabel(s.total)}</span>
313
+ </div>
314
+ ))}
315
+ </div>
316
+ </div>
317
+ );
318
+ }
319
+
320
+ export function AccountHealth() {
321
+ const { data } = useAccounts({ limit: 200 });
322
+ const accounts = (data?.data ?? []) as Record<string, unknown>[];
323
+
324
+ const green = accounts.filter((a: Record<string, unknown>) => ((a.health_score as number) ?? 0) >= 80);
325
+ const yellow = accounts.filter((a: Record<string, unknown>) => {
326
+ const s = (a.health_score as number) ?? 0;
327
+ return s >= 50 && s < 80;
328
+ });
329
+ const red = accounts.filter((a: Record<string, unknown>) => ((a.health_score as number) ?? 0) < 50);
330
+ const total = accounts.length;
331
+
332
+ const segments = [
333
+ { label: 'Healthy', count: green.length, color: 'hsl(152, 55%, 42%)' },
334
+ { label: 'At Risk', count: yellow.length, color: 'hsl(38, 92%, 50%)' },
335
+ { label: 'Critical', count: red.length, color: 'hsl(var(--destructive))' },
336
+ ];
337
+
338
+ return (
339
+ <div className="bg-card border border-border rounded-2xl p-5 shadow-sm">
340
+ <h3 className="font-display font-bold text-foreground mb-4">Account health</h3>
341
+ <div className="flex gap-3 overflow-x-auto no-scrollbar md:hidden pb-1 -mx-1 px-1">
342
+ {segments.map((s) => (
343
+ <div key={s.label} className="flex-shrink-0 w-28 rounded-xl p-3 border border-border" style={{ backgroundColor: s.color + '08' }}>
344
+ <div className="w-3 h-3 rounded-full mb-2" style={{ backgroundColor: s.color }} />
345
+ <p className="text-[10px] text-muted-foreground font-medium truncate">{s.label}</p>
346
+ <p className="text-lg font-display font-bold text-foreground">{s.count}</p>
347
+ <p className="text-[10px] text-muted-foreground font-mono">{total > 0 ? Math.round((s.count / total) * 100) : 0}%</p>
348
+ </div>
349
+ ))}
350
+ </div>
351
+ <div className="hidden md:block space-y-3">
352
+ {segments.map((s) => (
353
+ <div key={s.label} className="flex items-center gap-3">
354
+ <span className="text-xs w-28 truncate font-medium" style={{ color: s.color }}>{s.label}</span>
355
+ <div className="flex-1 h-2.5 bg-muted rounded-full overflow-hidden">
356
+ <div className="h-full rounded-full transition-all duration-700" style={{ width: total > 0 ? `${(s.count / total) * 100}%` : '0%', backgroundColor: s.color }} />
357
+ </div>
358
+ <span className="text-xs text-foreground font-mono">{s.count}</span>
359
+ </div>
360
+ ))}
361
+ </div>
362
+ </div>
363
+ );
364
+ }
365
+
366
+ function formatCustomFieldValue(fieldType: string, val: unknown): string {
367
+ if (val === null || val === undefined) return '—';
368
+ if (fieldType === 'boolean') return val ? 'Yes' : 'No';
369
+ if (fieldType === 'date') { try { return new Date(String(val)).toLocaleDateString(); } catch { return String(val); } }
370
+ if (Array.isArray(val)) return val.join(', ');
371
+ return String(val);
372
+ }
373
+
374
+ export function CustomFieldsSection({ objectType, values }: { objectType: string; values: Record<string, unknown> }) {
375
+ const [open, setOpen] = useState(false);
376
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
377
+ const { data } = useCustomFields(objectType) as any;
378
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
379
+ const fieldDefs: any[] = data?.fields ?? [];
380
+
381
+ if (fieldDefs.length === 0) return null;
382
+
383
+ return (
384
+ <div className="px-4 mx-4 mt-2 border-t border-border pt-3">
385
+ <button
386
+ onClick={() => setOpen(!open)}
387
+ className="flex items-center gap-1.5 w-full text-left"
388
+ >
389
+ <h3 className="text-xs font-display font-bold text-muted-foreground uppercase tracking-wide flex-1">
390
+ Custom Fields
391
+ <span className="ml-1.5 font-mono font-normal text-muted-foreground/50">({fieldDefs.length})</span>
392
+ </h3>
393
+ {open ? <ChevronUp className="w-3.5 h-3.5 text-muted-foreground" /> : <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />}
394
+ </button>
395
+ {open && (
396
+ <div className="mt-3 space-y-2">
397
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
398
+ {fieldDefs.map((def: any) => {
399
+ const val = values?.[def.field_key];
400
+ const hasValue = val !== undefined && val !== null && val !== '';
401
+ return (
402
+ <div key={def.field_key} className="flex items-center justify-between gap-4">
403
+ <span className="text-xs text-muted-foreground shrink-0">
404
+ {def.label}
405
+ {def.is_required && <span className="text-destructive ml-0.5">*</span>}
406
+ </span>
407
+ <span className={hasValue ? 'text-sm text-foreground text-right' : 'text-xs text-muted-foreground/50'}>
408
+ {hasValue ? formatCustomFieldValue(def.field_type, val) : '—'}
409
+ </span>
410
+ </div>
411
+ );
412
+ })}
413
+ </div>
414
+ )}
415
+ </div>
416
+ );
417
+ }
@@ -0,0 +1,77 @@
1
+ import { motion, AnimatePresence } from 'framer-motion';
2
+ import { X, GripHorizontal } from 'lucide-react';
3
+ import { useAppStore } from '@/store/appStore';
4
+
5
+ interface DrawerShellProps {
6
+ children: React.ReactNode;
7
+ title?: string;
8
+ }
9
+
10
+ export function DrawerShell({ children, title }: DrawerShellProps) {
11
+ const { drawerOpen, closeDrawer } = useAppStore();
12
+
13
+ return (
14
+ <AnimatePresence>
15
+ {drawerOpen && (
16
+ <>
17
+ {/* Overlay */}
18
+ <motion.div
19
+ initial={{ opacity: 0 }}
20
+ animate={{ opacity: 1 }}
21
+ exit={{ opacity: 0 }}
22
+ className="fixed inset-0 z-[65] bg-foreground/20 backdrop-blur-sm"
23
+ onClick={closeDrawer}
24
+ />
25
+
26
+ {/* Desktop: slide from right */}
27
+ <motion.div
28
+ initial={{ x: '100%' }}
29
+ animate={{ x: 0 }}
30
+ exit={{ x: '100%' }}
31
+ transition={{ type: 'spring', damping: 30, stiffness: 300 }}
32
+ className="hidden md:flex fixed right-0 top-0 h-full w-[480px] z-[70] bg-card border-l border-border shadow-2xl flex-col rounded-l-2xl"
33
+ >
34
+ <div className="flex items-center justify-between h-14 px-5 border-b border-border">
35
+ {title && <h2 className="font-display font-bold text-foreground">{title}</h2>}
36
+ <button
37
+ onClick={closeDrawer}
38
+ className="ml-auto p-2 rounded-xl hover:bg-muted text-muted-foreground transition-colors"
39
+ >
40
+ <X className="w-4 h-4" />
41
+ </button>
42
+ </div>
43
+ <div className="flex-1 overflow-y-auto">
44
+ {children}
45
+ </div>
46
+ </motion.div>
47
+
48
+ {/* Mobile: slide from bottom */}
49
+ <motion.div
50
+ initial={{ y: '100%' }}
51
+ animate={{ y: 0 }}
52
+ exit={{ y: '100%' }}
53
+ transition={{ type: 'spring', damping: 30, stiffness: 300 }}
54
+ className="md:hidden fixed left-0 right-0 bottom-0 z-[70] bg-card border-t border-border shadow-2xl flex flex-col rounded-t-3xl max-h-[90vh]"
55
+ >
56
+ {/* Drag handle */}
57
+ <div className="flex justify-center py-2">
58
+ <div className="w-10 h-1 rounded-full bg-muted-foreground/20" />
59
+ </div>
60
+ <div className="flex items-center justify-between px-5 pb-3 border-b border-border">
61
+ {title && <h2 className="font-display font-bold text-foreground">{title}</h2>}
62
+ <button
63
+ onClick={closeDrawer}
64
+ className="ml-auto p-2 rounded-xl hover:bg-muted text-muted-foreground transition-colors"
65
+ >
66
+ <X className="w-4 h-4" />
67
+ </button>
68
+ </div>
69
+ <div className="flex-1 overflow-y-auto pb-safe">
70
+ {children}
71
+ </div>
72
+ </motion.div>
73
+ </>
74
+ )}
75
+ </AnimatePresence>
76
+ );
77
+ }