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