@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,1190 +0,0 @@
1
- // Copyright 2026 CRMy Contributors
2
- // SPDX-License-Identifier: Apache-2.0
3
-
4
- import React, { useState, useMemo, useEffect } from 'react';
5
- import { useActors, useCreateActor, useUpdateActor, useCreateUser, useUsers, useApiKeys, useCreateApiKey, useRevokeApiKey } from '@/api/hooks';
6
- import { ListToolbar, type FilterConfig, type SortOption } from '@/components/crm/ListToolbar';
7
- import { PaginationBar } from '@/components/crm/PaginationBar';
8
- import { motion, AnimatePresence } from 'framer-motion';
9
- import { useIsMobile } from '@/hooks/use-mobile';
10
- import { getUser } from '@/api/client';
11
- import { toast } from '@/hooks/use-toast';
12
- import {
13
- Users, Bot, LayoutGrid, List, ChevronUp, ChevronDown,
14
- Pencil, Trash2, Shield, Phone, Mail, MessageSquare,
15
- Plus, X, CheckCircle2, CircleDot, Power, PowerOff,
16
- Key, Copy, ChevronRight, Lock,
17
- } from 'lucide-react';
18
-
19
- // ─── Types ────────────────────────────────────────────────────────────────────
20
-
21
- type ActorType = 'human' | 'agent';
22
- type TabFilter = 'all' | 'human' | 'agent';
23
-
24
- interface ContactChannel {
25
- channel_type: string;
26
- handle: string;
27
- primary?: boolean;
28
- }
29
-
30
- interface ActorRow {
31
- id: string;
32
- actor_type: ActorType;
33
- display_name: string;
34
- email?: string;
35
- phone?: string;
36
- user_id?: string;
37
- role?: string;
38
- agent_identifier?: string;
39
- agent_model?: string;
40
- scopes: string[];
41
- metadata: Record<string, unknown>;
42
- is_active: boolean;
43
- created_at: string;
44
- updated_at: string;
45
- }
46
-
47
- // ─── Constants ────────────────────────────────────────────────────────────────
48
-
49
- const CHANNEL_TYPES = [
50
- { value: 'slack', label: 'Slack', icon: MessageSquare },
51
- { value: 'teams', label: 'Teams', icon: MessageSquare },
52
- { value: 'discord', label: 'Discord', icon: MessageSquare },
53
- { value: 'whatsapp', label: 'WhatsApp', icon: Phone },
54
- ];
55
-
56
- const roleLabels: Record<string, string> = {
57
- owner: 'Owner',
58
- admin: 'Admin',
59
- member: 'Member',
60
- };
61
-
62
- const rolePillCls: Record<string, string> = {
63
- owner: 'bg-accent/15 text-accent border-accent/30',
64
- admin: 'bg-primary/15 text-primary border-primary/30',
65
- member: 'bg-muted text-muted-foreground border-border',
66
- };
67
-
68
- const typePillCls: Record<string, string> = {
69
- human: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30',
70
- agent: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30',
71
- };
72
-
73
- const SCOPE_GROUPS = [
74
- { label: 'General', scopes: [
75
- { value: 'read', label: 'Read', desc: 'Read all CRM data' },
76
- { value: 'write', label: 'Write', desc: 'Create and modify CRM data' },
77
- ]},
78
- { label: 'Contacts', scopes: [
79
- { value: 'contacts:read', label: 'Read contacts' },
80
- { value: 'contacts:write', label: 'Write contacts' },
81
- ]},
82
- { label: 'Accounts', scopes: [
83
- { value: 'accounts:read', label: 'Read accounts' },
84
- { value: 'accounts:write', label: 'Write accounts' },
85
- ]},
86
- { label: 'Opportunities', scopes: [
87
- { value: 'opportunities:read', label: 'Read opportunities' },
88
- { value: 'opportunities:write', label: 'Write opportunities' },
89
- ]},
90
- { label: 'Activities', scopes: [
91
- { value: 'activities:read', label: 'Read activities' },
92
- { value: 'activities:write', label: 'Write activities' },
93
- ]},
94
- { label: 'Assignments', scopes: [
95
- { value: 'assignments:create', label: 'Create assignments' },
96
- { value: 'assignments:update', label: 'Update assignments' },
97
- ]},
98
- { label: 'Context', scopes: [
99
- { value: 'context:read', label: 'Read context' },
100
- { value: 'context:write', label: 'Write context' },
101
- ]},
102
- ];
103
-
104
- // ─── Actor Detail Panel ──────────────────────────────────────────────────────
105
-
106
- function ActorDetailPanel({
107
- actor,
108
- onClose,
109
- }: {
110
- actor: ActorRow;
111
- onClose: () => void;
112
- }) {
113
- const updateActor = useUpdateActor();
114
- const { data: keysData, isLoading: keysLoading } = useApiKeys(actor.id);
115
- const createKey = useCreateApiKey();
116
- const revokeKey = useRevokeApiKey();
117
- const [newKeyLabel, setNewKeyLabel] = useState('');
118
- const [revealedKey, setRevealedKey] = useState<string | null>(null);
119
- const [showCreateKey, setShowCreateKey] = useState(false);
120
- const [editingScopes, setEditingScopes] = useState(false);
121
- const [scopeDraft, setScopeDraft] = useState<string[]>(actor.scopes ?? []);
122
-
123
- const keys = (keysData as { data: Array<{ id: string; label: string; last_used_at?: string; created_at: string }> })?.data ?? [];
124
-
125
- const handleCreateKey = async () => {
126
- if (!newKeyLabel.trim()) return;
127
- try {
128
- const result = await createKey.mutateAsync({
129
- label: newKeyLabel.trim(),
130
- scopes: actor.scopes ?? ['read'],
131
- actor_id: actor.id,
132
- });
133
- setRevealedKey((result as { key?: string }).key ?? null);
134
- setNewKeyLabel('');
135
- setShowCreateKey(false);
136
- toast({ title: 'API key created', description: 'Copy it now — it won\'t be shown again.' });
137
- } catch {
138
- toast({ title: 'Error', description: 'Failed to create API key.', variant: 'destructive' });
139
- }
140
- };
141
-
142
- const handleRevokeKey = async (id: string) => {
143
- try {
144
- await revokeKey.mutateAsync(id);
145
- toast({ title: 'API key revoked' });
146
- } catch {
147
- toast({ title: 'Error', description: 'Failed to revoke key.', variant: 'destructive' });
148
- }
149
- };
150
-
151
- const toggleScope = (scope: string) => {
152
- setScopeDraft(prev => prev.includes(scope) ? prev.filter(s => s !== scope) : [...prev, scope]);
153
- };
154
-
155
- const handleSaveScopes = async () => {
156
- try {
157
- await updateActor.mutateAsync({ id: actor.id, scopes: scopeDraft });
158
- setEditingScopes(false);
159
- toast({ title: 'Permissions updated' });
160
- } catch {
161
- toast({ title: 'Error', description: 'Failed to update permissions.', variant: 'destructive' });
162
- }
163
- };
164
-
165
- const inputCls = 'w-full h-9 px-3 rounded-lg border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring';
166
-
167
- return (
168
- <motion.div
169
- initial={{ opacity: 0, height: 0 }}
170
- animate={{ opacity: 1, height: 'auto' }}
171
- exit={{ opacity: 0, height: 0 }}
172
- className="overflow-hidden"
173
- >
174
- <div className="px-4 py-4 bg-muted/20 border-t border-border space-y-4">
175
- {/* ── Permissions / Scopes ── */}
176
- <div className="rounded-lg border border-border bg-card overflow-hidden">
177
- <div className="flex items-center justify-between px-4 py-3 border-b border-border">
178
- <div className="flex items-center gap-2">
179
- <Shield className="w-4 h-4 text-muted-foreground" />
180
- <span className="text-sm font-semibold text-foreground">Permissions</span>
181
- <span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border border-border">
182
- {(actor.scopes ?? []).length} scopes
183
- </span>
184
- </div>
185
- {!editingScopes ? (
186
- <button onClick={() => { setEditingScopes(true); setScopeDraft(actor.scopes ?? []); }}
187
- className="text-xs font-semibold text-primary hover:underline">
188
- Edit
189
- </button>
190
- ) : (
191
- <div className="flex gap-2">
192
- <button onClick={handleSaveScopes} disabled={updateActor.isPending}
193
- className="px-2.5 py-1 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 disabled:opacity-40">
194
- {updateActor.isPending ? 'Saving…' : 'Save'}
195
- </button>
196
- <button onClick={() => setEditingScopes(false)}
197
- className="px-2.5 py-1 rounded-lg border border-border text-xs font-semibold text-muted-foreground hover:text-foreground">
198
- Cancel
199
- </button>
200
- </div>
201
- )}
202
- </div>
203
- <div className="px-4 py-3">
204
- {editingScopes ? (
205
- <div className="space-y-3">
206
- {SCOPE_GROUPS.map(group => (
207
- <div key={group.label}>
208
- <p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">{group.label}</p>
209
- <div className="flex flex-wrap gap-1.5">
210
- {group.scopes.map(s => {
211
- const active = scopeDraft.includes(s.value);
212
- return (
213
- <button key={s.value} onClick={() => toggleScope(s.value)}
214
- className={`px-2.5 py-1 rounded-md text-xs font-medium border transition-colors ${
215
- active
216
- ? 'bg-primary/10 text-primary border-primary/30'
217
- : 'bg-muted/50 border-border text-muted-foreground hover:text-foreground'
218
- }`}>
219
- {s.label}
220
- </button>
221
- );
222
- })}
223
- </div>
224
- </div>
225
- ))}
226
- </div>
227
- ) : (
228
- <div className="flex flex-wrap gap-1.5">
229
- {(actor.scopes ?? []).length === 0 ? (
230
- <p className="text-xs text-muted-foreground">No permissions assigned.</p>
231
- ) : (
232
- (actor.scopes ?? []).map(s => (
233
- <span key={s} className="px-2 py-0.5 rounded-md text-[11px] font-medium bg-primary/10 text-primary border border-primary/20">
234
- {s}
235
- </span>
236
- ))
237
- )}
238
- </div>
239
- )}
240
- </div>
241
- </div>
242
-
243
- {/* ── API Keys ── */}
244
- <div className="rounded-lg border border-border bg-card overflow-hidden">
245
- <div className="flex items-center justify-between px-4 py-3 border-b border-border">
246
- <div className="flex items-center gap-2">
247
- <Key className="w-4 h-4 text-muted-foreground" />
248
- <span className="text-sm font-semibold text-foreground">API Keys</span>
249
- <span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border border-border">
250
- {keys.length}
251
- </span>
252
- </div>
253
- <button onClick={() => setShowCreateKey(true)}
254
- className="flex items-center gap-1 px-2.5 py-1 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90">
255
- <Plus className="w-3 h-3" /> New Key
256
- </button>
257
- </div>
258
-
259
- <div className="px-4 py-3 space-y-2">
260
- {/* Revealed key banner */}
261
- {revealedKey && (
262
- <div className="p-3 rounded-lg border border-green-500/30 bg-green-500/5 space-y-2">
263
- <p className="text-xs font-semibold text-green-600 dark:text-green-400">Copy this key now — it won't be shown again:</p>
264
- <div className="flex items-center gap-2">
265
- <code className="flex-1 text-xs font-mono bg-background rounded px-2 py-1.5 border border-border truncate">{revealedKey}</code>
266
- <button onClick={() => { navigator.clipboard.writeText(revealedKey); toast({ title: 'Copied!' }); }}
267
- className="p-1.5 rounded-lg hover:bg-muted transition-colors flex-shrink-0">
268
- <Copy className="w-3.5 h-3.5 text-muted-foreground" />
269
- </button>
270
- </div>
271
- <button onClick={() => setRevealedKey(null)} className="text-xs text-muted-foreground hover:text-foreground">Dismiss</button>
272
- </div>
273
- )}
274
-
275
- {/* Create key form */}
276
- {showCreateKey && (
277
- <div className="flex items-center gap-2">
278
- <input value={newKeyLabel} onChange={e => setNewKeyLabel(e.target.value)}
279
- placeholder="Key label (e.g. Production)"
280
- className={inputCls + ' max-w-xs'}
281
- onKeyDown={e => e.key === 'Enter' && handleCreateKey()} />
282
- <button onClick={handleCreateKey} disabled={!newKeyLabel.trim() || createKey.isPending}
283
- className="px-2.5 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 disabled:opacity-40">
284
- Create
285
- </button>
286
- <button onClick={() => { setShowCreateKey(false); setNewKeyLabel(''); }}
287
- className="px-2.5 py-1.5 rounded-lg border border-border text-xs font-semibold text-muted-foreground">
288
- Cancel
289
- </button>
290
- </div>
291
- )}
292
-
293
- {/* Keys list */}
294
- {keysLoading ? (
295
- <div className="h-8 bg-muted/50 rounded animate-pulse" />
296
- ) : keys.length === 0 && !showCreateKey ? (
297
- <p className="text-xs text-muted-foreground py-2">No API keys. Create one to allow this actor to authenticate.</p>
298
- ) : (
299
- keys.map(k => (
300
- <div key={k.id} className="flex items-center gap-3 py-2 border-b border-border last:border-0">
301
- <Key className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
302
- <div className="flex-1 min-w-0">
303
- <p className="text-xs font-semibold text-foreground">{k.label}</p>
304
- <p className="text-[10px] text-muted-foreground">
305
- {k.last_used_at ? `Last used ${new Date(k.last_used_at).toLocaleDateString()}` : 'Never used'}
306
- {' · Created '}
307
- {new Date(k.created_at).toLocaleDateString()}
308
- </p>
309
- </div>
310
- <button onClick={() => handleRevokeKey(k.id)}
311
- className="p-1.5 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors flex-shrink-0">
312
- <Trash2 className="w-3.5 h-3.5" />
313
- </button>
314
- </div>
315
- ))
316
- )}
317
- </div>
318
- </div>
319
-
320
- <div className="flex justify-end">
321
- <button onClick={onClose}
322
- className="px-3 py-1.5 rounded-lg border border-border text-xs font-semibold text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors">
323
- Close details
324
- </button>
325
- </div>
326
- </div>
327
- </motion.div>
328
- );
329
- }
330
-
331
- // ─── Helpers ──────────────────────────────────────────────────────────────────
332
-
333
- function ActorAvatar({ actor, size = 'sm' }: { actor: ActorRow; size?: 'sm' | 'lg' }) {
334
- const sz = size === 'lg' ? 'w-10 h-10 text-sm' : 'w-8 h-8 text-xs';
335
- if (actor.actor_type === 'agent') {
336
- return (
337
- <div className={`${sz} rounded-xl bg-blue-500/15 flex items-center justify-center flex-shrink-0`}>
338
- <Bot className={size === 'lg' ? 'w-5 h-5 text-blue-500' : 'w-3.5 h-3.5 text-blue-500'} />
339
- </div>
340
- );
341
- }
342
- const initials = actor.display_name.trim().split(/\s+/).map(n => n[0]).slice(0, 2).join('').toUpperCase() || '?';
343
- return (
344
- <div className={`${sz} rounded-xl bg-amber-500/15 flex items-center justify-center flex-shrink-0`}>
345
- <span className="font-display font-bold text-amber-600 dark:text-amber-400">{initials}</span>
346
- </div>
347
- );
348
- }
349
-
350
- function getContactChannels(metadata: Record<string, unknown>): ContactChannel[] {
351
- const channels = metadata?.contact_channels;
352
- if (Array.isArray(channels)) return channels as ContactChannel[];
353
- return [];
354
- }
355
-
356
- // ─── Contact Channels Editor ──────────────────────────────────────────────────
357
-
358
- function ContactChannelsEditor({
359
- channels,
360
- onChange,
361
- }: {
362
- channels: ContactChannel[];
363
- onChange: (channels: ContactChannel[]) => void;
364
- }) {
365
- const addChannel = () => {
366
- onChange([...channels, { channel_type: 'slack', handle: '', primary: false }]);
367
- };
368
-
369
- const removeChannel = (idx: number) => {
370
- onChange(channels.filter((_, i) => i !== idx));
371
- };
372
-
373
- const updateChannel = (idx: number, patch: Partial<ContactChannel>) => {
374
- onChange(channels.map((c, i) => i === idx ? { ...c, ...patch } : c));
375
- };
376
-
377
- return (
378
- <div className="space-y-2">
379
- <div className="flex items-center justify-between">
380
- <label className="text-xs font-medium text-muted-foreground">Messaging Channels</label>
381
- <button
382
- type="button"
383
- onClick={addChannel}
384
- className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 font-medium"
385
- >
386
- <Plus className="w-3 h-3" /> Add channel
387
- </button>
388
- </div>
389
- {channels.length === 0 && (
390
- <p className="text-xs text-muted-foreground/60 italic">No messaging channels configured</p>
391
- )}
392
- <AnimatePresence>
393
- {channels.map((ch, idx) => (
394
- <motion.div
395
- key={idx}
396
- initial={{ opacity: 0, height: 0 }}
397
- animate={{ opacity: 1, height: 'auto' }}
398
- exit={{ opacity: 0, height: 0 }}
399
- className="flex items-center gap-2"
400
- >
401
- <select
402
- value={ch.channel_type}
403
- onChange={e => updateChannel(idx, { channel_type: e.target.value })}
404
- className="h-8 px-2 rounded-lg border border-border bg-background text-xs text-foreground outline-none focus:ring-1 focus:ring-ring w-28"
405
- >
406
- {CHANNEL_TYPES.map(ct => (
407
- <option key={ct.value} value={ct.value}>{ct.label}</option>
408
- ))}
409
- </select>
410
- <input
411
- value={ch.handle}
412
- onChange={e => updateChannel(idx, { handle: e.target.value })}
413
- placeholder="@handle or ID"
414
- className="flex-1 h-8 px-2 rounded-lg border border-border bg-background text-xs text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring"
415
- />
416
- <button
417
- onClick={() => removeChannel(idx)}
418
- className="p-1 rounded text-muted-foreground hover:text-destructive transition-colors"
419
- >
420
- <X className="w-3.5 h-3.5" />
421
- </button>
422
- </motion.div>
423
- ))}
424
- </AnimatePresence>
425
- </div>
426
- );
427
- }
428
-
429
- // ─── Create Forms ─────────────────────────────────────────────────────────────
430
-
431
- interface HumanFormState {
432
- name: string;
433
- email: string;
434
- phone: string;
435
- role: string;
436
- password: string;
437
- channels: ContactChannel[];
438
- createAuthUser: boolean;
439
- }
440
-
441
- interface AgentFormState {
442
- display_name: string;
443
- agent_identifier: string;
444
- agent_model: string;
445
- }
446
-
447
- const initHumanForm = (): HumanFormState => ({
448
- name: '', email: '', phone: '', role: 'member', password: '', channels: [], createAuthUser: true,
449
- });
450
-
451
- const initAgentForm = (): AgentFormState => ({
452
- display_name: '', agent_identifier: '', agent_model: '',
453
- });
454
-
455
- // ─── Main Component ───────────────────────────────────────────────────────────
456
-
457
- export default function ActorsSettings() {
458
- const currentUser = getUser();
459
- const isMobile = useIsMobile();
460
- const [view, setView] = useState<'table' | 'cards'>('table');
461
- const effectiveView = isMobile ? 'cards' : view;
462
-
463
- // Data
464
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
465
- const { data: actorsData, isLoading } = useActors({ limit: 200 }) as any;
466
- const allActors: ActorRow[] = actorsData?.data ?? [];
467
-
468
- // Mutations
469
- const createActor = useCreateActor();
470
- const updateActor = useUpdateActor();
471
- const createUser = useCreateUser();
472
-
473
- // Filters
474
- const [tab, setTab] = useState<TabFilter>('all');
475
- const [search, setSearch] = useState('');
476
- const [activeFilters, setActiveFilters] = useState<Record<string, string[]>>({});
477
- const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' } | null>(null);
478
- const [page, setPage] = useState(1);
479
- const [pageSize, setPageSize] = useState(25);
480
-
481
- // Detail panel
482
- const [expandedActorId, setExpandedActorId] = useState<string | null>(null);
483
-
484
- // Forms
485
- const [showCreate, setShowCreate] = useState<'human' | 'agent' | null>(null);
486
- const [humanForm, setHumanForm] = useState<HumanFormState>(initHumanForm());
487
- const [agentForm, setAgentForm] = useState<AgentFormState>(initAgentForm());
488
- const [editingId, setEditingId] = useState<string | null>(null);
489
- const [editPhone, setEditPhone] = useState('');
490
- const [editRole, setEditRole] = useState('');
491
- const [editName, setEditName] = useState('');
492
- const [editChannels, setEditChannels] = useState<ContactChannel[]>([]);
493
-
494
- // Computed
495
- const filtered = useMemo(() => {
496
- let result = [...allActors];
497
- if (tab !== 'all') result = result.filter(a => a.actor_type === tab);
498
- if (activeFilters.status?.length) {
499
- const wantActive = activeFilters.status.includes('active');
500
- const wantInactive = activeFilters.status.includes('inactive');
501
- if (wantActive && !wantInactive) result = result.filter(a => a.is_active);
502
- if (wantInactive && !wantActive) result = result.filter(a => !a.is_active);
503
- }
504
- if (activeFilters.role?.length) {
505
- result = result.filter(a => a.role && activeFilters.role.includes(a.role));
506
- }
507
- if (search) {
508
- const q = search.toLowerCase();
509
- result = result.filter(a =>
510
- a.display_name.toLowerCase().includes(q) ||
511
- (a.email ?? '').toLowerCase().includes(q) ||
512
- (a.phone ?? '').toLowerCase().includes(q) ||
513
- (a.agent_identifier ?? '').toLowerCase().includes(q)
514
- );
515
- }
516
- if (sort) {
517
- result.sort((a, b) => {
518
- const aVal = String((a as unknown as Record<string, unknown>)[sort.key] ?? '');
519
- const bVal = String((b as unknown as Record<string, unknown>)[sort.key] ?? '');
520
- return sort.dir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
521
- });
522
- }
523
- return result;
524
- }, [allActors, tab, search, activeFilters, sort]);
525
-
526
- useEffect(() => { setPage(1); }, [tab, search, activeFilters, sort]);
527
- const paginated = filtered.slice((page - 1) * pageSize, page * pageSize);
528
-
529
- const humanCount = allActors.filter(a => a.actor_type === 'human').length;
530
- const agentCount = allActors.filter(a => a.actor_type === 'agent').length;
531
-
532
- // Config
533
- const filterConfigs: FilterConfig[] = [
534
- {
535
- key: 'status', label: 'Status', options: [
536
- { value: 'active', label: 'Active' },
537
- { value: 'inactive', label: 'Inactive' },
538
- ],
539
- },
540
- {
541
- key: 'role', label: 'Role', options: [
542
- { value: 'owner', label: 'Owner' },
543
- { value: 'admin', label: 'Admin' },
544
- { value: 'member', label: 'Member' },
545
- ],
546
- },
547
- ];
548
-
549
- const sortOptions: SortOption[] = [
550
- { key: 'display_name', label: 'Name' },
551
- { key: 'email', label: 'Email' },
552
- { key: 'actor_type', label: 'Type' },
553
- { key: 'created_at', label: 'Created' },
554
- ];
555
-
556
- const handleFilterChange = (key: string, values: string[]) => {
557
- setActiveFilters(prev => {
558
- const next = { ...prev };
559
- if (values.length === 0) delete next[key]; else next[key] = values;
560
- return next;
561
- });
562
- };
563
-
564
- const handleSortChange = (key: string) => {
565
- setSort(prev => prev?.key === key ? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' });
566
- };
567
-
568
- // ─── Create Handlers ────────────────────────────────────────────────────────
569
-
570
- const handleCreateHuman = async () => {
571
- if (!humanForm.name.trim() || !humanForm.email.trim()) return;
572
- try {
573
- // If creating an auth user too, create user first (which auto-creates actor via backend)
574
- if (humanForm.createAuthUser && humanForm.password) {
575
- await createUser.mutateAsync({
576
- name: humanForm.name.trim(),
577
- email: humanForm.email.trim(),
578
- password: humanForm.password,
579
- role: humanForm.role,
580
- });
581
- } else {
582
- // Create actor only (no auth user)
583
- const metadata: Record<string, unknown> = {};
584
- if (humanForm.channels.length > 0) {
585
- metadata.contact_channels = humanForm.channels.filter(c => c.handle.trim());
586
- }
587
- await createActor.mutateAsync({
588
- actor_type: 'human',
589
- display_name: humanForm.name.trim(),
590
- email: humanForm.email.trim(),
591
- phone: humanForm.phone.trim() || undefined,
592
- role: humanForm.role,
593
- metadata,
594
- });
595
- }
596
- setShowCreate(null);
597
- setHumanForm(initHumanForm());
598
- toast({ title: 'Human actor created' });
599
- } catch (err) {
600
- toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to create', variant: 'destructive' });
601
- }
602
- };
603
-
604
- const handleCreateAgent = async () => {
605
- if (!agentForm.display_name.trim()) return;
606
- try {
607
- await createActor.mutateAsync({
608
- actor_type: 'agent',
609
- display_name: agentForm.display_name.trim(),
610
- agent_identifier: agentForm.agent_identifier.trim() || undefined,
611
- agent_model: agentForm.agent_model.trim() || undefined,
612
- metadata: {},
613
- });
614
- setShowCreate(null);
615
- setAgentForm(initAgentForm());
616
- toast({ title: 'Agent registered' });
617
- } catch (err) {
618
- toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to register', variant: 'destructive' });
619
- }
620
- };
621
-
622
- // ─── Edit / Toggle ──────────────────────────────────────────────────────────
623
-
624
- const startEdit = (actor: ActorRow) => {
625
- setEditingId(actor.id);
626
- setEditName(actor.display_name);
627
- setEditPhone(actor.phone ?? '');
628
- setEditRole(actor.role ?? 'member');
629
- setEditChannels(getContactChannels(actor.metadata));
630
- };
631
-
632
- const handleUpdate = async () => {
633
- if (!editingId) return;
634
- try {
635
- const metadata: Record<string, unknown> = {};
636
- if (editChannels.length > 0) {
637
- metadata.contact_channels = editChannels.filter(c => c.handle.trim());
638
- }
639
- await updateActor.mutateAsync({
640
- id: editingId,
641
- display_name: editName.trim(),
642
- phone: editPhone.trim() || null,
643
- role: editRole || null,
644
- metadata,
645
- });
646
- setEditingId(null);
647
- toast({ title: 'Actor updated' });
648
- } catch (err) {
649
- toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to update', variant: 'destructive' });
650
- }
651
- };
652
-
653
- const toggleExpand = (actorId: string) => {
654
- setExpandedActorId(prev => prev === actorId ? null : actorId);
655
- };
656
-
657
- const toggleActive = async (actor: ActorRow) => {
658
- try {
659
- await updateActor.mutateAsync({ id: actor.id, is_active: !actor.is_active });
660
- toast({ title: actor.is_active ? 'Actor deactivated' : 'Actor activated' });
661
- } catch (err) {
662
- toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed', variant: 'destructive' });
663
- }
664
- };
665
-
666
- // ─── Sort Header ────────────────────────────────────────────────────────────
667
-
668
- const SortHeader = ({ label, sortKey }: { label: string; sortKey: string }) => (
669
- <th
670
- onClick={() => handleSortChange(sortKey)}
671
- 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"
672
- >
673
- <span className="inline-flex items-center gap-1">
674
- {label}
675
- {sort?.key === sortKey ? (sort.dir === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />) : null}
676
- </span>
677
- </th>
678
- );
679
-
680
- // ─── Render ─────────────────────────────────────────────────────────────────
681
-
682
- const inputCls = 'w-full h-9 px-3 rounded-lg border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring';
683
-
684
- return (
685
- <div className="-mx-6 -my-6 flex flex-col">
686
- {/* Header */}
687
- <div className="flex items-start justify-between px-6 pt-6 pb-3">
688
- <div>
689
- <h2 className="font-display font-bold text-lg text-foreground">Actors</h2>
690
- <p className="text-sm text-muted-foreground mt-0.5">Manage humans and AI agents with access to your CRMy workspace.</p>
691
- </div>
692
- <div className="hidden md:flex items-center gap-1 bg-muted rounded-xl p-0.5 mt-0.5">
693
- <button
694
- onClick={() => setView('table')}
695
- className={`p-1.5 rounded-lg text-sm transition-all ${view === 'table' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
696
- >
697
- <List className="w-4 h-4" />
698
- </button>
699
- <button
700
- onClick={() => setView('cards')}
701
- className={`p-1.5 rounded-lg text-sm transition-all ${view === 'cards' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
702
- >
703
- <LayoutGrid className="w-4 h-4" />
704
- </button>
705
- </div>
706
- </div>
707
-
708
- {/* Type tabs */}
709
- <div className="flex items-center gap-1 px-6 pb-2">
710
- {([
711
- { key: 'all', label: 'All', count: allActors.length },
712
- { key: 'human', label: 'Humans', count: humanCount, icon: Users },
713
- { key: 'agent', label: 'Agents', count: agentCount, icon: Bot },
714
- ] as const).map(t => (
715
- <button
716
- key={t.key}
717
- onClick={() => setTab(t.key)}
718
- className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ${
719
- tab === t.key ? 'bg-primary/15 text-primary' : 'bg-muted/50 text-muted-foreground hover:text-foreground'
720
- }`}
721
- >
722
- {'icon' in t && t.icon && <t.icon className="w-3 h-3" />}
723
- {t.label}
724
- <span className="text-[10px] opacity-60">{t.count}</span>
725
- </button>
726
- ))}
727
- </div>
728
-
729
- {/* Toolbar */}
730
- <ListToolbar
731
- searchValue={search} onSearchChange={setSearch} searchPlaceholder="Search actors..."
732
- filters={filterConfigs} activeFilters={activeFilters} onFilterChange={handleFilterChange}
733
- onClearFilters={() => setActiveFilters({})} sortOptions={sortOptions} currentSort={sort}
734
- onSortChange={handleSortChange}
735
- onAdd={() => setShowCreate(tab === 'agent' ? 'agent' : 'human')}
736
- addLabel={tab === 'agent' ? 'Register Agent' : 'Add Human'}
737
- entityType="actors"
738
- />
739
-
740
- <div className="px-4 md:px-6 pb-8 space-y-3 mt-1">
741
- {/* Create forms */}
742
- <AnimatePresence>
743
- {showCreate === 'human' && (
744
- <motion.div
745
- initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }}
746
- className="p-4 rounded-xl border border-border bg-muted/30 space-y-4"
747
- >
748
- <div className="flex items-center justify-between">
749
- <p className="text-xs font-display font-semibold text-muted-foreground uppercase tracking-wider">New Human Actor</p>
750
- <div className="flex items-center gap-2">
751
- <label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
752
- <input
753
- type="checkbox"
754
- checked={humanForm.createAuthUser}
755
- onChange={e => setHumanForm(f => ({ ...f, createAuthUser: e.target.checked }))}
756
- className="rounded"
757
- />
758
- Create login account
759
- </label>
760
- </div>
761
- </div>
762
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
763
- <div className="space-y-1">
764
- <label className="text-xs font-medium text-muted-foreground">Name <span className="text-destructive">*</span></label>
765
- <input value={humanForm.name} onChange={e => setHumanForm(f => ({ ...f, name: e.target.value }))}
766
- placeholder="Jane Smith" className={inputCls} />
767
- </div>
768
- <div className="space-y-1">
769
- <label className="text-xs font-medium text-muted-foreground">Email <span className="text-destructive">*</span></label>
770
- <input type="email" value={humanForm.email} onChange={e => setHumanForm(f => ({ ...f, email: e.target.value }))}
771
- placeholder="jane@company.com" className={inputCls} />
772
- </div>
773
- <div className="space-y-1">
774
- <label className="text-xs font-medium text-muted-foreground">Phone</label>
775
- <input value={humanForm.phone} onChange={e => setHumanForm(f => ({ ...f, phone: e.target.value }))}
776
- placeholder="+1 (555) 123-4567" className={inputCls} />
777
- </div>
778
- <div className="space-y-1">
779
- <label className="text-xs font-medium text-muted-foreground">Role</label>
780
- <select value={humanForm.role} onChange={e => setHumanForm(f => ({ ...f, role: e.target.value }))}
781
- className={`${inputCls} cursor-pointer`}>
782
- <option value="member">Member</option>
783
- <option value="admin">Admin</option>
784
- {currentUser?.role === 'owner' && <option value="owner">Owner</option>}
785
- </select>
786
- </div>
787
- </div>
788
- {humanForm.createAuthUser && (
789
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
790
- <div className="space-y-1">
791
- <label className="text-xs font-medium text-muted-foreground">Password <span className="text-destructive">*</span></label>
792
- <input type="password" value={humanForm.password} onChange={e => setHumanForm(f => ({ ...f, password: e.target.value }))}
793
- placeholder="Min. 8 characters" className={inputCls} />
794
- </div>
795
- </div>
796
- )}
797
- <ContactChannelsEditor
798
- channels={humanForm.channels}
799
- onChange={channels => setHumanForm(f => ({ ...f, channels }))}
800
- />
801
- <div className="flex gap-2 pt-1">
802
- <button onClick={handleCreateHuman} disabled={createActor.isPending || createUser.isPending}
803
- className="px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 disabled:opacity-40 transition-colors">
804
- {createActor.isPending || createUser.isPending ? 'Creating...' : 'Create Human Actor'}
805
- </button>
806
- <button onClick={() => { setShowCreate(null); setHumanForm(initHumanForm()); }}
807
- className="px-3 py-1.5 rounded-lg bg-muted text-muted-foreground text-xs font-semibold hover:bg-muted/80 transition-colors">
808
- Cancel
809
- </button>
810
- </div>
811
- </motion.div>
812
- )}
813
-
814
- {showCreate === 'agent' && (
815
- <motion.div
816
- initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }}
817
- className="p-4 rounded-xl border border-border bg-muted/30 space-y-4"
818
- >
819
- <p className="text-xs font-display font-semibold text-muted-foreground uppercase tracking-wider">Register Agent</p>
820
- <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
821
- <div className="space-y-1">
822
- <label className="text-xs font-medium text-muted-foreground">Display Name <span className="text-destructive">*</span></label>
823
- <input value={agentForm.display_name} onChange={e => setAgentForm(f => ({ ...f, display_name: e.target.value }))}
824
- placeholder="Outreach Bot" className={inputCls} />
825
- </div>
826
- <div className="space-y-1">
827
- <label className="text-xs font-medium text-muted-foreground">Identifier</label>
828
- <input value={agentForm.agent_identifier} onChange={e => setAgentForm(f => ({ ...f, agent_identifier: e.target.value }))}
829
- placeholder="custom/outreach-v1" className={inputCls} />
830
- </div>
831
- <div className="space-y-1">
832
- <label className="text-xs font-medium text-muted-foreground">Model</label>
833
- <input value={agentForm.agent_model} onChange={e => setAgentForm(f => ({ ...f, agent_model: e.target.value }))}
834
- placeholder="claude-sonnet-4-20250514" className={inputCls} />
835
- </div>
836
- </div>
837
- <div className="flex gap-2 pt-1">
838
- <button onClick={handleCreateAgent} disabled={createActor.isPending}
839
- className="px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 disabled:opacity-40 transition-colors">
840
- {createActor.isPending ? 'Registering...' : 'Register Agent'}
841
- </button>
842
- <button onClick={() => { setShowCreate(null); setAgentForm(initAgentForm()); }}
843
- className="px-3 py-1.5 rounded-lg bg-muted text-muted-foreground text-xs font-semibold hover:bg-muted/80 transition-colors">
844
- Cancel
845
- </button>
846
- </div>
847
- </motion.div>
848
- )}
849
- </AnimatePresence>
850
-
851
- {/* Content */}
852
- {isLoading ? (
853
- <div className="space-y-2">
854
- {[...Array(5)].map((_, i) => <div key={i} className="h-14 bg-muted/50 rounded-xl animate-pulse" />)}
855
- </div>
856
- ) : filtered.length === 0 ? (
857
- <div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
858
- <Users className="w-8 h-8 mb-3 opacity-30" />
859
- <p className="text-sm">No actors found.</p>
860
- {(search || Object.keys(activeFilters).length > 0 || tab !== 'all') && (
861
- <button onClick={() => { setSearch(''); setActiveFilters({}); setTab('all'); }}
862
- className="mt-2 text-xs text-primary font-semibold hover:underline">
863
- Clear filters
864
- </button>
865
- )}
866
- </div>
867
- ) : effectiveView === 'table' ? (
868
- /* ── Table view ── */
869
- <div className="bg-card border border-border rounded-2xl overflow-hidden shadow-sm">
870
- <div className="overflow-x-auto">
871
- <table className="w-full text-sm">
872
- <thead>
873
- <tr className="border-b border-border bg-surface-sunken/50">
874
- <SortHeader label="Name" sortKey="display_name" />
875
- <th className="text-left px-4 py-3 text-xs font-display font-semibold text-muted-foreground">Type</th>
876
- <SortHeader label="Email" sortKey="email" />
877
- <th className="text-left px-4 py-3 text-xs font-display font-semibold text-muted-foreground">Contact</th>
878
- <th className="text-left px-4 py-3 text-xs font-display font-semibold text-muted-foreground">Role / Model</th>
879
- <th className="text-left px-4 py-3 text-xs font-display font-semibold text-muted-foreground">Status</th>
880
- <th className="px-2 py-3 w-20" />
881
- </tr>
882
- </thead>
883
- <tbody>
884
- {paginated.map((actor, i) => (
885
- <React.Fragment key={actor.id}>
886
- {editingId === actor.id ? (
887
- <tr>
888
- <td colSpan={7} className="p-4 bg-muted/20 border-b border-border last:border-0">
889
- <p className="text-xs font-display font-semibold text-muted-foreground uppercase tracking-wider mb-3">Edit Actor</p>
890
- <div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-3">
891
- <div className="space-y-1">
892
- <label className="text-xs font-medium text-muted-foreground">Name</label>
893
- <input value={editName} onChange={e => setEditName(e.target.value)} className={inputCls} />
894
- </div>
895
- {actor.actor_type === 'human' && (
896
- <>
897
- <div className="space-y-1">
898
- <label className="text-xs font-medium text-muted-foreground">Phone</label>
899
- <input value={editPhone} onChange={e => setEditPhone(e.target.value)} placeholder="+1 (555) 123-4567" className={inputCls} />
900
- </div>
901
- <div className="space-y-1">
902
- <label className="text-xs font-medium text-muted-foreground">Role</label>
903
- <select value={editRole} onChange={e => setEditRole(e.target.value)} className={`${inputCls} cursor-pointer`}>
904
- <option value="member">Member</option>
905
- <option value="admin">Admin</option>
906
- {currentUser?.role === 'owner' && <option value="owner">Owner</option>}
907
- </select>
908
- </div>
909
- </>
910
- )}
911
- </div>
912
- {actor.actor_type === 'human' && (
913
- <ContactChannelsEditor channels={editChannels} onChange={setEditChannels} />
914
- )}
915
- <div className="flex gap-2 pt-3">
916
- <button onClick={handleUpdate} disabled={updateActor.isPending}
917
- className="px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 disabled:opacity-40 transition-colors">
918
- {updateActor.isPending ? 'Saving...' : 'Save Changes'}
919
- </button>
920
- <button onClick={() => setEditingId(null)}
921
- className="px-3 py-1.5 rounded-lg bg-muted text-muted-foreground text-xs font-semibold hover:bg-muted/80 transition-colors">
922
- Cancel
923
- </button>
924
- </div>
925
- </td>
926
- </tr>
927
- ) : (
928
- <>
929
- <tr className={`border-b border-border last:border-0 hover:bg-primary/5 transition-colors group cursor-pointer ${i % 2 === 1 ? 'bg-surface-sunken/30' : ''}`}
930
- onClick={() => toggleExpand(actor.id)}>
931
- <td className="px-4 py-3">
932
- <div className="flex items-center gap-3">
933
- <ActorAvatar actor={actor} />
934
- <div>
935
- <span className="font-semibold text-foreground">{actor.display_name}</span>
936
- {actor.user_id && (
937
- <span className="ml-1.5 text-[10px] font-mono bg-muted text-muted-foreground px-1 py-0.5 rounded">auth</span>
938
- )}
939
- </div>
940
- </div>
941
- </td>
942
- <td className="px-4 py-3">
943
- <span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded border capitalize ${typePillCls[actor.actor_type]}`}>
944
- {actor.actor_type}
945
- </span>
946
- </td>
947
- <td className="px-4 py-3 text-muted-foreground text-xs">{actor.email || '—'}</td>
948
- <td className="px-4 py-3">
949
- <div className="flex flex-col gap-0.5">
950
- {actor.phone && (
951
- <span className="text-xs text-muted-foreground flex items-center gap-1">
952
- <Phone className="w-3 h-3" /> {actor.phone}
953
- </span>
954
- )}
955
- {getContactChannels(actor.metadata).slice(0, 2).map((ch, ci) => (
956
- <span key={ci} className="text-xs text-muted-foreground flex items-center gap-1">
957
- <MessageSquare className="w-3 h-3" /> {ch.channel_type}: {ch.handle}
958
- </span>
959
- ))}
960
- {!actor.phone && getContactChannels(actor.metadata).length === 0 && (
961
- <span className="text-xs text-muted-foreground/40">—</span>
962
- )}
963
- </div>
964
- </td>
965
- <td className="px-4 py-3">
966
- {actor.actor_type === 'human' ? (
967
- actor.role ? (
968
- <span className={`text-[11px] font-semibold px-2 py-0.5 rounded-full border ${rolePillCls[actor.role] ?? rolePillCls.member}`}>
969
- {roleLabels[actor.role] ?? actor.role}
970
- </span>
971
- ) : <span className="text-xs text-muted-foreground/40">—</span>
972
- ) : (
973
- <div className="text-xs text-muted-foreground">
974
- {actor.agent_identifier && <div className="font-mono">{actor.agent_identifier}</div>}
975
- {actor.agent_model && <div className="text-muted-foreground/60">{actor.agent_model}</div>}
976
- {!actor.agent_identifier && !actor.agent_model && '—'}
977
- </div>
978
- )}
979
- </td>
980
- <td className="px-4 py-3">
981
- <span className={`inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded border ${
982
- actor.is_active
983
- ? 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30'
984
- : 'bg-muted text-muted-foreground border-border'
985
- }`}>
986
- <CircleDot className="w-2.5 h-2.5" />
987
- {actor.is_active ? 'Active' : 'Inactive'}
988
- </span>
989
- </td>
990
- <td className="px-2 py-3" onClick={e => e.stopPropagation()}>
991
- <div className="flex items-center gap-1 justify-end">
992
- <button onClick={() => toggleExpand(actor.id)}
993
- className={`p-1.5 rounded-lg transition-colors ${
994
- expandedActorId === actor.id
995
- ? 'text-primary bg-primary/10'
996
- : 'text-muted-foreground hover:text-foreground hover:bg-muted'
997
- }`}
998
- title="Permissions & Keys">
999
- <ChevronRight className={`w-3.5 h-3.5 transition-transform ${expandedActorId === actor.id ? 'rotate-90' : ''}`} />
1000
- </button>
1001
- <button onClick={() => startEdit(actor)}
1002
- className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
1003
- title="Edit">
1004
- <Pencil className="w-3.5 h-3.5" />
1005
- </button>
1006
- <button onClick={() => toggleActive(actor)}
1007
- className={`p-1.5 rounded-lg transition-colors opacity-0 group-hover:opacity-100 ${
1008
- actor.is_active
1009
- ? 'text-muted-foreground hover:text-destructive hover:bg-destructive/10'
1010
- : 'text-muted-foreground hover:text-green-600 hover:bg-green-500/10'
1011
- }`}
1012
- title={actor.is_active ? 'Deactivate' : 'Activate'}>
1013
- {actor.is_active ? <PowerOff className="w-3.5 h-3.5" /> : <Power className="w-3.5 h-3.5" />}
1014
- </button>
1015
- </div>
1016
- </td>
1017
- </tr>
1018
- {/* Detail panel (permissions + API keys) */}
1019
- <AnimatePresence>
1020
- {expandedActorId === actor.id && (
1021
- <tr>
1022
- <td colSpan={7}>
1023
- <ActorDetailPanel actor={actor} onClose={() => setExpandedActorId(null)} />
1024
- </td>
1025
- </tr>
1026
- )}
1027
- </AnimatePresence>
1028
- </>
1029
- )}
1030
- </React.Fragment>
1031
- ))}
1032
- </tbody>
1033
- </table>
1034
- </div>
1035
- <PaginationBar page={page} pageSize={pageSize} total={filtered.length} onPageChange={setPage} onPageSizeChange={setPageSize} className="px-4" />
1036
- </div>
1037
- ) : (
1038
- /* ── Card view ── */
1039
- <>
1040
- <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
1041
- {paginated.map((actor, i) => (
1042
- editingId === actor.id ? (
1043
- <motion.div
1044
- key={actor.id} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}
1045
- className="col-span-full bg-card border border-border rounded-2xl p-4 space-y-4"
1046
- >
1047
- <p className="text-xs font-display font-semibold text-muted-foreground uppercase tracking-wider">Edit Actor</p>
1048
- <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
1049
- <div className="space-y-1">
1050
- <label className="text-xs font-medium text-muted-foreground">Name</label>
1051
- <input value={editName} onChange={e => setEditName(e.target.value)} className={inputCls} />
1052
- </div>
1053
- {actor.actor_type === 'human' && (
1054
- <>
1055
- <div className="space-y-1">
1056
- <label className="text-xs font-medium text-muted-foreground">Phone</label>
1057
- <input value={editPhone} onChange={e => setEditPhone(e.target.value)} className={inputCls} />
1058
- </div>
1059
- <div className="space-y-1">
1060
- <label className="text-xs font-medium text-muted-foreground">Role</label>
1061
- <select value={editRole} onChange={e => setEditRole(e.target.value)} className={`${inputCls} cursor-pointer`}>
1062
- <option value="member">Member</option>
1063
- <option value="admin">Admin</option>
1064
- {currentUser?.role === 'owner' && <option value="owner">Owner</option>}
1065
- </select>
1066
- </div>
1067
- </>
1068
- )}
1069
- </div>
1070
- {actor.actor_type === 'human' && (
1071
- <ContactChannelsEditor channels={editChannels} onChange={setEditChannels} />
1072
- )}
1073
- <div className="flex gap-2 pt-1">
1074
- <button onClick={handleUpdate} disabled={updateActor.isPending}
1075
- className="px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 disabled:opacity-40 transition-colors">
1076
- {updateActor.isPending ? 'Saving...' : 'Save Changes'}
1077
- </button>
1078
- <button onClick={() => setEditingId(null)}
1079
- className="px-3 py-1.5 rounded-lg bg-muted text-muted-foreground text-xs font-semibold hover:bg-muted/80 transition-colors">
1080
- Cancel
1081
- </button>
1082
- </div>
1083
- </motion.div>
1084
- ) : (
1085
- <motion.div
1086
- key={actor.id} initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.02 }}
1087
- className={`bg-card border rounded-2xl p-4 hover:shadow-md transition-all group relative ${
1088
- actor.is_active ? 'border-border hover:border-primary/20' : 'border-border/50 opacity-60'
1089
- }`}
1090
- >
1091
- <div className="flex items-start justify-between gap-2">
1092
- <div className="flex items-center gap-3">
1093
- <ActorAvatar actor={actor} size="lg" />
1094
- <div>
1095
- <div className="flex items-center gap-1.5 flex-wrap">
1096
- <p className="font-display font-bold text-foreground">{actor.display_name}</p>
1097
- <span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded border capitalize ${typePillCls[actor.actor_type]}`}>
1098
- {actor.actor_type}
1099
- </span>
1100
- {actor.user_id && (
1101
- <span className="text-[10px] font-mono bg-muted text-muted-foreground px-1 py-0.5 rounded">auth</span>
1102
- )}
1103
- </div>
1104
- <p className="text-xs text-muted-foreground">{actor.email || (actor.agent_identifier ?? 'No email')}</p>
1105
- </div>
1106
- </div>
1107
- <div className="flex items-center gap-1 flex-shrink-0">
1108
- <button onClick={() => toggleExpand(actor.id)}
1109
- className={`p-1.5 rounded-lg transition-colors ${
1110
- expandedActorId === actor.id
1111
- ? 'text-primary bg-primary/10'
1112
- : 'text-muted-foreground hover:text-foreground hover:bg-muted'
1113
- }`}
1114
- title="Permissions & Keys">
1115
- <ChevronRight className={`w-3.5 h-3.5 transition-transform ${expandedActorId === actor.id ? 'rotate-90' : ''}`} />
1116
- </button>
1117
- <button onClick={() => startEdit(actor)}
1118
- className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors md:opacity-0 md:group-hover:opacity-100">
1119
- <Pencil className="w-3.5 h-3.5" />
1120
- </button>
1121
- <button onClick={() => toggleActive(actor)}
1122
- className={`p-1.5 rounded-lg transition-colors md:opacity-0 md:group-hover:opacity-100 ${
1123
- actor.is_active ? 'text-muted-foreground hover:text-destructive hover:bg-destructive/10' : 'text-muted-foreground hover:text-green-600 hover:bg-green-500/10'
1124
- }`}>
1125
- {actor.is_active ? <PowerOff className="w-3.5 h-3.5" /> : <Power className="w-3.5 h-3.5" />}
1126
- </button>
1127
- </div>
1128
- </div>
1129
-
1130
- <div className="mt-3 flex items-center justify-between flex-wrap gap-2">
1131
- <div className="flex items-center gap-2">
1132
- {actor.actor_type === 'human' && actor.role && (
1133
- <span className={`text-[11px] font-semibold px-2 py-0.5 rounded-full border ${rolePillCls[actor.role] ?? rolePillCls.member}`}>
1134
- {roleLabels[actor.role] ?? actor.role}
1135
- </span>
1136
- )}
1137
- {actor.actor_type === 'agent' && actor.agent_model && (
1138
- <span className="text-[10px] font-mono text-muted-foreground">{actor.agent_model}</span>
1139
- )}
1140
- <span className={`inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded border ${
1141
- actor.is_active
1142
- ? 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30'
1143
- : 'bg-muted text-muted-foreground border-border'
1144
- }`}>
1145
- <CircleDot className="w-2.5 h-2.5" />
1146
- {actor.is_active ? 'Active' : 'Inactive'}
1147
- </span>
1148
- </div>
1149
- {actor.created_at && (
1150
- <span className="text-[10px] text-muted-foreground">
1151
- {new Date(actor.created_at).toLocaleDateString()}
1152
- </span>
1153
- )}
1154
- </div>
1155
-
1156
- {/* Contact info */}
1157
- {(actor.phone || getContactChannels(actor.metadata).length > 0) && (
1158
- <div className="mt-2 pt-2 border-t border-border flex flex-wrap gap-x-3 gap-y-1">
1159
- {actor.phone && (
1160
- <span className="text-xs text-muted-foreground flex items-center gap-1">
1161
- <Phone className="w-3 h-3" /> {actor.phone}
1162
- </span>
1163
- )}
1164
- {getContactChannels(actor.metadata).map((ch, ci) => (
1165
- <span key={ci} className="text-xs text-muted-foreground flex items-center gap-1">
1166
- <MessageSquare className="w-3 h-3" /> {ch.channel_type}: {ch.handle}
1167
- </span>
1168
- ))}
1169
- </div>
1170
- )}
1171
-
1172
- {/* Detail panel (permissions + API keys) */}
1173
- <AnimatePresence>
1174
- {expandedActorId === actor.id && (
1175
- <div className="mt-3 -mx-4 -mb-4">
1176
- <ActorDetailPanel actor={actor} onClose={() => setExpandedActorId(null)} />
1177
- </div>
1178
- )}
1179
- </AnimatePresence>
1180
- </motion.div>
1181
- )
1182
- ))}
1183
- </div>
1184
- <PaginationBar page={page} pageSize={pageSize} total={filtered.length} onPageChange={setPage} onPageSizeChange={setPageSize} />
1185
- </>
1186
- )}
1187
- </div>
1188
- </div>
1189
- );
1190
- }