@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.
- package/dist/assets/index-CskfWp8E.js +560 -0
- package/dist/assets/index-D763l57m.css +1 -0
- package/{index.html → dist/index.html} +2 -1
- package/package.json +4 -1
- package/postcss.config.js +0 -6
- package/src/App.tsx +0 -158
- package/src/api/client.ts +0 -82
- package/src/api/hooks.ts +0 -689
- package/src/components/CustomFields.tsx +0 -240
- package/src/components/NavLink.tsx +0 -28
- package/src/components/crm/AIFab.tsx +0 -37
- package/src/components/crm/AccountDrawer.tsx +0 -372
- package/src/components/crm/ActivityTimeline.tsx +0 -115
- package/src/components/crm/AssignmentDrawer.tsx +0 -396
- package/src/components/crm/BriefingPanel.tsx +0 -217
- package/src/components/crm/CommandPalette.tsx +0 -254
- package/src/components/crm/ContactAvatar.tsx +0 -49
- package/src/components/crm/ContactDrawer.tsx +0 -438
- package/src/components/crm/ContextPanel.tsx +0 -200
- package/src/components/crm/CrmWidgets.tsx +0 -417
- package/src/components/crm/DrawerShell.tsx +0 -77
- package/src/components/crm/ListToolbar.tsx +0 -252
- package/src/components/crm/OpportunityDrawer.tsx +0 -372
- package/src/components/crm/PaginationBar.tsx +0 -111
- package/src/components/crm/QuickAddDrawer.tsx +0 -652
- package/src/components/crm/ShortcutsOverlay.tsx +0 -65
- package/src/components/crm/UseCaseDrawer.tsx +0 -454
- package/src/components/layout/MobileNav.tsx +0 -49
- package/src/components/layout/Sidebar.tsx +0 -157
- package/src/components/layout/TopBar.tsx +0 -54
- package/src/components/settings/ActorsSettings.tsx +0 -1190
- package/src/components/ui/accordion.tsx +0 -52
- package/src/components/ui/alert-dialog.tsx +0 -104
- package/src/components/ui/alert.tsx +0 -43
- package/src/components/ui/aspect-ratio.tsx +0 -5
- package/src/components/ui/avatar.tsx +0 -38
- package/src/components/ui/badge.tsx +0 -29
- package/src/components/ui/breadcrumb.tsx +0 -90
- package/src/components/ui/button.tsx +0 -47
- package/src/components/ui/calendar.tsx +0 -54
- package/src/components/ui/card.tsx +0 -43
- package/src/components/ui/carousel.tsx +0 -224
- package/src/components/ui/chart.tsx +0 -303
- package/src/components/ui/checkbox.tsx +0 -26
- package/src/components/ui/collapsible.tsx +0 -9
- package/src/components/ui/command.tsx +0 -132
- package/src/components/ui/context-menu.tsx +0 -178
- package/src/components/ui/date-picker.tsx +0 -313
- package/src/components/ui/dialog.tsx +0 -95
- package/src/components/ui/drawer.tsx +0 -87
- package/src/components/ui/dropdown-menu.tsx +0 -179
- package/src/components/ui/form.tsx +0 -129
- package/src/components/ui/hover-card.tsx +0 -27
- package/src/components/ui/input-otp.tsx +0 -61
- package/src/components/ui/input.tsx +0 -22
- package/src/components/ui/label.tsx +0 -17
- package/src/components/ui/menubar.tsx +0 -207
- package/src/components/ui/navigation-menu.tsx +0 -120
- package/src/components/ui/pagination.tsx +0 -81
- package/src/components/ui/popover.tsx +0 -29
- package/src/components/ui/progress.tsx +0 -23
- package/src/components/ui/radio-group.tsx +0 -36
- package/src/components/ui/resizable.tsx +0 -37
- package/src/components/ui/scroll-area.tsx +0 -38
- package/src/components/ui/select.tsx +0 -143
- package/src/components/ui/separator.tsx +0 -20
- package/src/components/ui/sheet.tsx +0 -107
- package/src/components/ui/sidebar.tsx +0 -637
- package/src/components/ui/skeleton.tsx +0 -7
- package/src/components/ui/slider.tsx +0 -23
- package/src/components/ui/sonner.tsx +0 -24
- package/src/components/ui/switch.tsx +0 -27
- package/src/components/ui/table.tsx +0 -72
- package/src/components/ui/tabs.tsx +0 -53
- package/src/components/ui/textarea.tsx +0 -21
- package/src/components/ui/toast.tsx +0 -111
- package/src/components/ui/toaster.tsx +0 -24
- package/src/components/ui/toggle-group.tsx +0 -49
- package/src/components/ui/toggle.tsx +0 -37
- package/src/components/ui/tooltip.tsx +0 -28
- package/src/components/ui/use-toast.ts +0 -1
- package/src/components/ui/utils.ts +0 -9
- package/src/contexts/AgentSettingsContext.tsx +0 -24
- package/src/hooks/use-mobile.tsx +0 -19
- package/src/hooks/use-toast.ts +0 -186
- package/src/hooks/useKeyboardShortcuts.ts +0 -95
- package/src/hooks/useTheme.ts +0 -24
- package/src/index.css +0 -245
- package/src/lib/entityColors.ts +0 -18
- package/src/lib/stageConfig.ts +0 -32
- package/src/lib/utils.ts +0 -6
- package/src/main.tsx +0 -25
- package/src/pages/Accounts.tsx +0 -205
- package/src/pages/Activities.tsx +0 -251
- package/src/pages/Agent.tsx +0 -237
- package/src/pages/AgentSettings.tsx +0 -544
- package/src/pages/Assignments.tsx +0 -750
- package/src/pages/Contacts.tsx +0 -200
- package/src/pages/Dashboard.tsx +0 -143
- package/src/pages/Inbox.tsx +0 -615
- package/src/pages/NotFound.tsx +0 -24
- package/src/pages/Opportunities.tsx +0 -386
- package/src/pages/SearchResults.tsx +0 -49
- package/src/pages/Settings.tsx +0 -1884
- package/src/pages/UseCases.tsx +0 -396
- package/src/pages/auth/Login.tsx +0 -261
- package/src/pages/hitl/HITL.tsx +0 -101
- package/src/store/appStore.ts +0 -103
- package/src/vite-env.d.ts +0 -14
- package/tailwind.config.js +0 -121
- package/tsconfig.json +0 -24
- package/vite.config.ts +0 -27
- /package/{public → dist}/android-chrome-192x192.png +0 -0
- /package/{public → dist}/android-chrome-512x512.png +0 -0
- /package/{public → dist}/apple-touch-icon.png +0 -0
- /package/{src/assets/crmy-logo.png → dist/assets/crmy-logo-DWN0xBPW.png} +0 -0
- /package/{public → dist}/favicon-16x16.png +0 -0
- /package/{public → dist}/favicon-32x32.png +0 -0
- /package/{public → dist}/favicon.ico +0 -0
- /package/{public → dist}/favicon.svg +0 -0
- /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
|
-
}
|