@crmy/web 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/index.html +23 -0
  2. package/package.json +76 -0
  3. package/postcss.config.js +6 -0
  4. package/public/android-chrome-192x192.png +0 -0
  5. package/public/android-chrome-512x512.png +0 -0
  6. package/public/apple-touch-icon.png +0 -0
  7. package/public/favicon-16x16.png +0 -0
  8. package/public/favicon-32x32.png +0 -0
  9. package/public/favicon.ico +0 -0
  10. package/public/favicon.svg +13 -0
  11. package/public/site.webmanifest +1 -0
  12. package/src/App.tsx +158 -0
  13. package/src/api/client.ts +82 -0
  14. package/src/api/hooks.ts +689 -0
  15. package/src/assets/crmy-logo.png +0 -0
  16. package/src/components/CustomFields.tsx +240 -0
  17. package/src/components/NavLink.tsx +28 -0
  18. package/src/components/crm/AIFab.tsx +37 -0
  19. package/src/components/crm/AccountDrawer.tsx +372 -0
  20. package/src/components/crm/ActivityTimeline.tsx +115 -0
  21. package/src/components/crm/AssignmentDrawer.tsx +396 -0
  22. package/src/components/crm/BriefingPanel.tsx +217 -0
  23. package/src/components/crm/CommandPalette.tsx +254 -0
  24. package/src/components/crm/ContactAvatar.tsx +49 -0
  25. package/src/components/crm/ContactDrawer.tsx +438 -0
  26. package/src/components/crm/ContextPanel.tsx +200 -0
  27. package/src/components/crm/CrmWidgets.tsx +417 -0
  28. package/src/components/crm/DrawerShell.tsx +77 -0
  29. package/src/components/crm/ListToolbar.tsx +252 -0
  30. package/src/components/crm/OpportunityDrawer.tsx +372 -0
  31. package/src/components/crm/PaginationBar.tsx +111 -0
  32. package/src/components/crm/QuickAddDrawer.tsx +652 -0
  33. package/src/components/crm/ShortcutsOverlay.tsx +65 -0
  34. package/src/components/crm/UseCaseDrawer.tsx +454 -0
  35. package/src/components/layout/MobileNav.tsx +49 -0
  36. package/src/components/layout/Sidebar.tsx +157 -0
  37. package/src/components/layout/TopBar.tsx +54 -0
  38. package/src/components/settings/ActorsSettings.tsx +1190 -0
  39. package/src/components/ui/accordion.tsx +52 -0
  40. package/src/components/ui/alert-dialog.tsx +104 -0
  41. package/src/components/ui/alert.tsx +43 -0
  42. package/src/components/ui/aspect-ratio.tsx +5 -0
  43. package/src/components/ui/avatar.tsx +38 -0
  44. package/src/components/ui/badge.tsx +29 -0
  45. package/src/components/ui/breadcrumb.tsx +90 -0
  46. package/src/components/ui/button.tsx +47 -0
  47. package/src/components/ui/calendar.tsx +54 -0
  48. package/src/components/ui/card.tsx +43 -0
  49. package/src/components/ui/carousel.tsx +224 -0
  50. package/src/components/ui/chart.tsx +303 -0
  51. package/src/components/ui/checkbox.tsx +26 -0
  52. package/src/components/ui/collapsible.tsx +9 -0
  53. package/src/components/ui/command.tsx +132 -0
  54. package/src/components/ui/context-menu.tsx +178 -0
  55. package/src/components/ui/date-picker.tsx +313 -0
  56. package/src/components/ui/dialog.tsx +95 -0
  57. package/src/components/ui/drawer.tsx +87 -0
  58. package/src/components/ui/dropdown-menu.tsx +179 -0
  59. package/src/components/ui/form.tsx +129 -0
  60. package/src/components/ui/hover-card.tsx +27 -0
  61. package/src/components/ui/input-otp.tsx +61 -0
  62. package/src/components/ui/input.tsx +22 -0
  63. package/src/components/ui/label.tsx +17 -0
  64. package/src/components/ui/menubar.tsx +207 -0
  65. package/src/components/ui/navigation-menu.tsx +120 -0
  66. package/src/components/ui/pagination.tsx +81 -0
  67. package/src/components/ui/popover.tsx +29 -0
  68. package/src/components/ui/progress.tsx +23 -0
  69. package/src/components/ui/radio-group.tsx +36 -0
  70. package/src/components/ui/resizable.tsx +37 -0
  71. package/src/components/ui/scroll-area.tsx +38 -0
  72. package/src/components/ui/select.tsx +143 -0
  73. package/src/components/ui/separator.tsx +20 -0
  74. package/src/components/ui/sheet.tsx +107 -0
  75. package/src/components/ui/sidebar.tsx +637 -0
  76. package/src/components/ui/skeleton.tsx +7 -0
  77. package/src/components/ui/slider.tsx +23 -0
  78. package/src/components/ui/sonner.tsx +24 -0
  79. package/src/components/ui/switch.tsx +27 -0
  80. package/src/components/ui/table.tsx +72 -0
  81. package/src/components/ui/tabs.tsx +53 -0
  82. package/src/components/ui/textarea.tsx +21 -0
  83. package/src/components/ui/toast.tsx +111 -0
  84. package/src/components/ui/toaster.tsx +24 -0
  85. package/src/components/ui/toggle-group.tsx +49 -0
  86. package/src/components/ui/toggle.tsx +37 -0
  87. package/src/components/ui/tooltip.tsx +28 -0
  88. package/src/components/ui/use-toast.ts +1 -0
  89. package/src/components/ui/utils.ts +9 -0
  90. package/src/contexts/AgentSettingsContext.tsx +24 -0
  91. package/src/hooks/use-mobile.tsx +19 -0
  92. package/src/hooks/use-toast.ts +186 -0
  93. package/src/hooks/useKeyboardShortcuts.ts +95 -0
  94. package/src/hooks/useTheme.ts +24 -0
  95. package/src/index.css +245 -0
  96. package/src/lib/entityColors.ts +18 -0
  97. package/src/lib/stageConfig.ts +32 -0
  98. package/src/lib/utils.ts +6 -0
  99. package/src/main.tsx +25 -0
  100. package/src/pages/Accounts.tsx +205 -0
  101. package/src/pages/Activities.tsx +251 -0
  102. package/src/pages/Agent.tsx +237 -0
  103. package/src/pages/AgentSettings.tsx +544 -0
  104. package/src/pages/Assignments.tsx +750 -0
  105. package/src/pages/Contacts.tsx +200 -0
  106. package/src/pages/Dashboard.tsx +143 -0
  107. package/src/pages/Inbox.tsx +615 -0
  108. package/src/pages/NotFound.tsx +24 -0
  109. package/src/pages/Opportunities.tsx +386 -0
  110. package/src/pages/SearchResults.tsx +49 -0
  111. package/src/pages/Settings.tsx +1884 -0
  112. package/src/pages/UseCases.tsx +396 -0
  113. package/src/pages/auth/Login.tsx +261 -0
  114. package/src/pages/hitl/HITL.tsx +101 -0
  115. package/src/store/appStore.ts +103 -0
  116. package/src/vite-env.d.ts +14 -0
  117. package/tailwind.config.js +121 -0
  118. package/tsconfig.json +24 -0
  119. package/vite.config.ts +27 -0
@@ -0,0 +1,615 @@
1
+ // Copyright 2026 CRMy Contributors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { useState, useMemo, useEffect } from 'react';
5
+ import { motion, AnimatePresence } from 'framer-motion';
6
+ import { TopBar } from '@/components/layout/TopBar';
7
+ import { useAppStore } from '@/store/appStore';
8
+ import { ListToolbar, type FilterConfig, type SortOption } from '@/components/crm/ListToolbar';
9
+ import { PaginationBar } from '@/components/crm/PaginationBar';
10
+ import {
11
+ useHITLRequests,
12
+ useResolveHITL,
13
+ useAssignments,
14
+ useActors,
15
+ useWhoAmI,
16
+ useAcceptAssignment,
17
+ useStartAssignment,
18
+ useCompleteAssignment,
19
+ useDeclineAssignment,
20
+ useBlockAssignment,
21
+ useCancelAssignment,
22
+ } from '@/api/hooks';
23
+ import { toast } from '@/components/ui/use-toast';
24
+ import {
25
+ Inbox as InboxIcon,
26
+ CheckCircle2,
27
+ XCircle,
28
+ Play,
29
+ Ban,
30
+ AlertOctagon,
31
+ ChevronRight,
32
+ Clock,
33
+ AlertTriangle,
34
+ Bot,
35
+ User,
36
+ Calendar,
37
+ ArrowRight,
38
+ Loader2,
39
+ List,
40
+ LayoutGrid,
41
+ ChevronUp,
42
+ ChevronDown,
43
+ } from 'lucide-react';
44
+ import { formatDistanceToNow, isPast, parseISO } from 'date-fns';
45
+
46
+ type Tab = 'needs_attention' | 'delegated' | 'all';
47
+ type ViewMode = 'card' | 'table';
48
+
49
+ interface HITLRequest {
50
+ id: string;
51
+ action_type: string;
52
+ agent_id?: string;
53
+ created_at: string;
54
+ expires_at?: string;
55
+ action_summary: string;
56
+ action_payload: Record<string, unknown>;
57
+ status: string;
58
+ }
59
+
60
+ interface Assignment {
61
+ id: string;
62
+ title: string;
63
+ description?: string;
64
+ assignment_type: string;
65
+ status: string;
66
+ priority: string;
67
+ subject_type?: string;
68
+ subject_id?: string;
69
+ assigned_to: string;
70
+ assigned_by: string;
71
+ context?: string;
72
+ created_at: string;
73
+ due_at?: string;
74
+ }
75
+
76
+ const PRIORITY_COLORS: Record<string, string> = {
77
+ urgent: 'bg-destructive/15 text-destructive',
78
+ high: 'bg-orange-500/15 text-orange-500',
79
+ normal: 'bg-primary/10 text-primary',
80
+ low: 'bg-muted text-muted-foreground',
81
+ };
82
+
83
+ const STATUS_COLORS: Record<string, string> = {
84
+ pending: 'bg-amber-500/15 text-amber-600',
85
+ accepted: 'bg-blue-500/15 text-blue-600',
86
+ in_progress: 'bg-violet-500/15 text-violet-600',
87
+ blocked: 'bg-destructive/15 text-destructive',
88
+ completed: 'bg-success/15 text-success',
89
+ declined: 'bg-muted text-muted-foreground',
90
+ cancelled: 'bg-muted text-muted-foreground',
91
+ };
92
+
93
+ const ACTIVE_STATUSES = ['pending', 'accepted', 'in_progress', 'blocked'];
94
+
95
+ const TABS: { key: Tab; label: string }[] = [
96
+ { key: 'needs_attention', label: 'Needs Attention' },
97
+ { key: 'delegated', label: 'Delegated' },
98
+ { key: 'all', label: 'All' },
99
+ ];
100
+
101
+ const FILTER_CONFIGS: FilterConfig[] = [
102
+ { key: 'status', label: 'Status', options: [
103
+ { value: 'pending', label: 'Pending' },
104
+ { value: 'accepted', label: 'Accepted' },
105
+ { value: 'in_progress', label: 'In Progress' },
106
+ { value: 'blocked', label: 'Blocked' },
107
+ { value: 'completed', label: 'Completed' },
108
+ { value: 'declined', label: 'Declined' },
109
+ { value: 'cancelled', label: 'Cancelled' },
110
+ ]},
111
+ { key: 'priority', label: 'Priority', options: [
112
+ { value: 'urgent', label: 'Urgent' },
113
+ { value: 'high', label: 'High' },
114
+ { value: 'normal', label: 'Normal' },
115
+ { value: 'low', label: 'Low' },
116
+ ]},
117
+ { key: 'assignment_type', label: 'Type', options: [
118
+ { value: 'call', label: 'Call' },
119
+ { value: 'draft', label: 'Draft' },
120
+ { value: 'email', label: 'Email' },
121
+ { value: 'follow_up', label: 'Follow Up' },
122
+ { value: 'research', label: 'Research' },
123
+ { value: 'review', label: 'Review' },
124
+ { value: 'send', label: 'Send' },
125
+ ]},
126
+ ];
127
+
128
+ const SORT_OPTIONS: SortOption[] = [
129
+ { key: 'created_at', label: 'Created' },
130
+ { key: 'due_at', label: 'Due Date' },
131
+ { key: 'priority', label: 'Priority' },
132
+ { key: 'status', label: 'Status' },
133
+ { key: 'title', label: 'Title' },
134
+ ];
135
+
136
+ const PRIORITY_ORDER: Record<string, number> = { urgent: 0, high: 1, normal: 2, low: 3 };
137
+
138
+ function timeAgo(iso: string) {
139
+ try { return formatDistanceToNow(parseISO(iso), { addSuffix: true }); } catch { return ''; }
140
+ }
141
+
142
+ function expiryLabel(iso?: string) {
143
+ if (!iso) return null;
144
+ try {
145
+ const d = parseISO(iso);
146
+ if (isPast(d)) return { label: 'Expired', urgent: true };
147
+ return { label: `Expires ${formatDistanceToNow(d, { addSuffix: true })}`, urgent: d.getTime() - Date.now() < 30 * 60 * 1000 };
148
+ } catch { return null; }
149
+ }
150
+
151
+ // ─── HITL Approval Card ──────────────────────────────────────────────────────
152
+
153
+ function HITLCard({ req }: { req: HITLRequest }) {
154
+ const resolve = useResolveHITL();
155
+ const [note, setNote] = useState('');
156
+ const [showPayload, setShowPayload] = useState(false);
157
+ const [acting, setActing] = useState<'approve' | 'reject' | null>(null);
158
+ const expiry = expiryLabel(req.expires_at);
159
+
160
+ async function handle(status: 'approved' | 'rejected') {
161
+ setActing(status === 'approved' ? 'approve' : 'reject');
162
+ try {
163
+ await resolve.mutateAsync({ id: req.id, status, note: note.trim() || undefined });
164
+ toast({ title: status === 'approved' ? 'Approved' : 'Rejected', description: req.action_summary });
165
+ } catch {
166
+ toast({ title: 'Error', description: 'Could not resolve request.', variant: 'destructive' });
167
+ } finally { setActing(null); }
168
+ }
169
+
170
+ return (
171
+ <div className="bg-card border border-border rounded-2xl shadow-sm overflow-hidden">
172
+ <div className="flex items-start gap-3 p-4">
173
+ <div className="w-9 h-9 rounded-xl bg-violet-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
174
+ <Bot className="w-4 h-4 text-violet-500" />
175
+ </div>
176
+ <div className="flex-1 min-w-0">
177
+ <div className="flex items-center gap-2 flex-wrap mb-1">
178
+ <span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-violet-500/15 text-violet-600">Approval Request</span>
179
+ <span className="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground font-mono">{req.action_type}</span>
180
+ {expiry && (
181
+ <span className={`text-xs flex items-center gap-1 ${expiry.urgent ? 'text-destructive' : 'text-muted-foreground'}`}>
182
+ <Clock className="w-3 h-3" />{expiry.label}
183
+ </span>
184
+ )}
185
+ </div>
186
+ <p className="text-sm font-medium text-foreground">{req.action_summary}</p>
187
+ <p className="text-xs text-muted-foreground mt-0.5">{timeAgo(req.created_at)}</p>
188
+ </div>
189
+ </div>
190
+ <div className="px-4 pb-2">
191
+ <button onClick={() => setShowPayload(!showPayload)} className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors">
192
+ <ChevronRight className={`w-3.5 h-3.5 transition-transform ${showPayload ? 'rotate-90' : ''}`} />View payload
193
+ </button>
194
+ <AnimatePresence>
195
+ {showPayload && (
196
+ <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.15 }} className="overflow-hidden">
197
+ <pre className="mt-2 p-3 rounded-xl bg-muted/50 text-xs text-muted-foreground overflow-x-auto max-h-48">{JSON.stringify(req.action_payload, null, 2)}</pre>
198
+ </motion.div>
199
+ )}
200
+ </AnimatePresence>
201
+ </div>
202
+ <div className="border-t border-border p-3 flex flex-col sm:flex-row gap-2 items-end bg-surface-sunken/30">
203
+ <input value={note} onChange={e => setNote(e.target.value)} placeholder="Add a note (optional)..."
204
+ className="flex-1 h-8 px-3 rounded-lg border border-border bg-card text-xs text-foreground placeholder:text-muted-foreground outline-none focus:ring-2 focus:ring-primary/30" />
205
+ <div className="flex gap-2 flex-shrink-0">
206
+ <ActionBtn label="Reject" icon={<XCircle className="w-3.5 h-3.5" />} onClick={() => handle('rejected')} loading={acting === 'reject'} color="destructive" />
207
+ <ActionBtn label="Approve" icon={<CheckCircle2 className="w-3.5 h-3.5" />} onClick={() => handle('approved')} loading={acting === 'approve'} color="success" />
208
+ </div>
209
+ </div>
210
+ </div>
211
+ );
212
+ }
213
+
214
+ // ─── Assignment Card ──────────────────────────────────────────────────────────
215
+
216
+ function AssignmentCard({ task, actorMap }: { task: Assignment; actorMap: Map<string, string> }) {
217
+ const accept = useAcceptAssignment();
218
+ const start = useStartAssignment();
219
+ const complete = useCompleteAssignment();
220
+ const decline = useDeclineAssignment();
221
+ const block = useBlockAssignment();
222
+ const cancel = useCancelAssignment();
223
+ const [acting, setActing] = useState<string | null>(null);
224
+
225
+ async function act(action: string) {
226
+ setActing(action);
227
+ try {
228
+ if (action === 'accept') await accept.mutateAsync(task.id);
229
+ else if (action === 'start') await start.mutateAsync(task.id);
230
+ else if (action === 'complete') await complete.mutateAsync({ id: task.id });
231
+ else if (action === 'decline') await decline.mutateAsync({ id: task.id });
232
+ else if (action === 'block') await block.mutateAsync({ id: task.id });
233
+ else if (action === 'cancel') await cancel.mutateAsync({ id: task.id });
234
+ toast({ title: `Assignment ${action}ed` });
235
+ } catch {
236
+ toast({ title: 'Error', description: `Could not ${action} assignment.`, variant: 'destructive' });
237
+ } finally { setActing(null); }
238
+ }
239
+
240
+ const isOverdue = task.due_at && isPast(parseISO(task.due_at)) && !['completed', 'cancelled', 'declined'].includes(task.status);
241
+ const assignedByName = actorMap.get(task.assigned_by) ?? task.assigned_by.slice(0, 8) + '…';
242
+
243
+ return (
244
+ <div className="bg-card border border-border rounded-2xl shadow-sm overflow-hidden">
245
+ <div className="flex items-start gap-3 p-4">
246
+ <div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0 mt-0.5">
247
+ <User className="w-4 h-4 text-primary" />
248
+ </div>
249
+ <div className="flex-1 min-w-0">
250
+ <div className="flex items-center gap-2 flex-wrap mb-1">
251
+ <span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${PRIORITY_COLORS[task.priority] ?? PRIORITY_COLORS.normal}`}>{task.priority}</span>
252
+ <span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[task.status] ?? 'bg-muted text-muted-foreground'}`}>{task.status.replace('_', ' ')}</span>
253
+ <span className="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground font-mono">{task.assignment_type.replace('_', ' ')}</span>
254
+ {isOverdue && <span className="text-xs flex items-center gap-1 text-destructive"><AlertTriangle className="w-3 h-3" />Overdue</span>}
255
+ </div>
256
+ <p className="text-sm font-medium text-foreground leading-snug">{task.title}</p>
257
+ <div className="flex items-center gap-3 mt-1 flex-wrap">
258
+ <span className="text-xs text-muted-foreground flex items-center gap-1">
259
+ <ArrowRight className="w-3 h-3" />from {assignedByName}
260
+ </span>
261
+ {task.due_at && (
262
+ <span className={`text-xs flex items-center gap-1 ${isOverdue ? 'text-destructive' : 'text-muted-foreground'}`}>
263
+ <Calendar className="w-3 h-3" />{timeAgo(task.due_at)}
264
+ </span>
265
+ )}
266
+ </div>
267
+ {task.context && <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{task.context}</p>}
268
+ </div>
269
+ </div>
270
+ {ACTIVE_STATUSES.includes(task.status) && (
271
+ <div className="border-t border-border px-3 py-2 flex gap-2 flex-wrap bg-surface-sunken/30">
272
+ {task.status === 'pending' && <>
273
+ <ActionBtn label="Accept" icon={<CheckCircle2 className="w-3.5 h-3.5" />} onClick={() => act('accept')} loading={acting === 'accept'} color="success" />
274
+ <ActionBtn label="Decline" icon={<XCircle className="w-3.5 h-3.5" />} onClick={() => act('decline')} loading={acting === 'decline'} color="destructive" />
275
+ </>}
276
+ {task.status === 'accepted' && <>
277
+ <ActionBtn label="Start" icon={<Play className="w-3.5 h-3.5" />} onClick={() => act('start')} loading={acting === 'start'} color="primary" />
278
+ <ActionBtn label="Decline" icon={<XCircle className="w-3.5 h-3.5" />} onClick={() => act('decline')} loading={acting === 'decline'} color="ghost" />
279
+ </>}
280
+ {task.status === 'in_progress' && <>
281
+ <ActionBtn label="Complete" icon={<CheckCircle2 className="w-3.5 h-3.5" />} onClick={() => act('complete')} loading={acting === 'complete'} color="success" />
282
+ <ActionBtn label="Block" icon={<Ban className="w-3.5 h-3.5" />} onClick={() => act('block')} loading={acting === 'block'} color="warning" />
283
+ </>}
284
+ {task.status === 'blocked' && <>
285
+ <ActionBtn label="Resume" icon={<Play className="w-3.5 h-3.5" />} onClick={() => act('start')} loading={acting === 'start'} color="primary" />
286
+ <ActionBtn label="Cancel" icon={<AlertOctagon className="w-3.5 h-3.5" />} onClick={() => act('cancel')} loading={acting === 'cancel'} color="ghost" />
287
+ </>}
288
+ </div>
289
+ )}
290
+ </div>
291
+ );
292
+ }
293
+
294
+ function ActionBtn({ label, icon, onClick, loading, color }: { label: string; icon: React.ReactNode; onClick: () => void; loading: boolean; color: string }) {
295
+ const colorMap: Record<string, string> = {
296
+ success: 'bg-success text-white hover:bg-success/90',
297
+ destructive: 'border border-destructive/30 text-destructive hover:bg-destructive/10',
298
+ primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
299
+ warning: 'border border-amber-500/30 text-amber-600 hover:bg-amber-500/10',
300
+ ghost: 'border border-border text-muted-foreground hover:bg-muted/50',
301
+ };
302
+ return (
303
+ <button onClick={onClick} disabled={loading} className={`h-7 px-2.5 flex items-center gap-1.5 rounded-lg text-xs font-semibold transition-all disabled:opacity-50 ${colorMap[color]}`}>
304
+ {loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : icon}{label}
305
+ </button>
306
+ );
307
+ }
308
+
309
+ // ─── Table Row ────────────────────────────────────────────────────────────────
310
+
311
+ function AssignmentTableRow({ task, actorMap, index }: { task: Assignment; actorMap: Map<string, string>; index: number }) {
312
+ const accept = useAcceptAssignment();
313
+ const start = useStartAssignment();
314
+ const complete = useCompleteAssignment();
315
+ const decline = useDeclineAssignment();
316
+ const block = useBlockAssignment();
317
+ const cancel = useCancelAssignment();
318
+ const [acting, setActing] = useState<string | null>(null);
319
+
320
+ async function act(action: string) {
321
+ setActing(action);
322
+ try {
323
+ if (action === 'accept') await accept.mutateAsync(task.id);
324
+ else if (action === 'start') await start.mutateAsync(task.id);
325
+ else if (action === 'complete') await complete.mutateAsync({ id: task.id });
326
+ else if (action === 'decline') await decline.mutateAsync({ id: task.id });
327
+ else if (action === 'block') await block.mutateAsync({ id: task.id });
328
+ else if (action === 'cancel') await cancel.mutateAsync({ id: task.id });
329
+ toast({ title: `Assignment ${action}ed` });
330
+ } catch {
331
+ toast({ title: 'Error', description: `Could not ${action} assignment.`, variant: 'destructive' });
332
+ } finally { setActing(null); }
333
+ }
334
+
335
+ const isOverdue = task.due_at && isPast(parseISO(task.due_at)) && !['completed', 'cancelled', 'declined'].includes(task.status);
336
+ const assignedByName = actorMap.get(task.assigned_by) ?? task.assigned_by.slice(0, 8) + '…';
337
+ const assignedToName = actorMap.get(task.assigned_to) ?? task.assigned_to.slice(0, 8) + '…';
338
+
339
+ return (
340
+ <tr className={`border-b border-border hover:bg-primary/5 transition-colors group ${index % 2 === 1 ? 'bg-surface-sunken/30' : ''}`}>
341
+ <td className="px-4 py-3">
342
+ <span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full ${PRIORITY_COLORS[task.priority] ?? PRIORITY_COLORS.normal}`}>{task.priority}</span>
343
+ </td>
344
+ <td className="px-4 py-3 max-w-xs">
345
+ <p className="text-sm font-medium text-foreground truncate">{task.title}</p>
346
+ {task.context && <p className="text-xs text-muted-foreground truncate">{task.context}</p>}
347
+ </td>
348
+ <td className="px-4 py-3">
349
+ <span className="text-xs px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground font-mono">{task.assignment_type.replace('_', ' ')}</span>
350
+ </td>
351
+ <td className="px-4 py-3">
352
+ <span className={`text-xs px-1.5 py-0.5 rounded-full ${STATUS_COLORS[task.status] ?? 'bg-muted text-muted-foreground'}`}>{task.status.replace('_', ' ')}</span>
353
+ </td>
354
+ <td className="px-4 py-3 text-xs text-muted-foreground">{assignedToName}</td>
355
+ <td className="px-4 py-3 text-xs text-muted-foreground">{assignedByName}</td>
356
+ <td className={`px-4 py-3 text-xs ${isOverdue ? 'text-destructive font-medium' : 'text-muted-foreground'}`}>
357
+ {task.due_at ? timeAgo(task.due_at) : '—'}
358
+ </td>
359
+ <td className="px-4 py-3">
360
+ {ACTIVE_STATUSES.includes(task.status) && (
361
+ <div className="flex gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
362
+ {task.status === 'pending' && <>
363
+ <ActionBtn label="Accept" icon={<CheckCircle2 className="w-3 h-3" />} onClick={() => act('accept')} loading={acting === 'accept'} color="success" />
364
+ <ActionBtn label="Decline" icon={<XCircle className="w-3 h-3" />} onClick={() => act('decline')} loading={acting === 'decline'} color="ghost" />
365
+ </>}
366
+ {task.status === 'accepted' && <ActionBtn label="Start" icon={<Play className="w-3 h-3" />} onClick={() => act('start')} loading={acting === 'start'} color="primary" />}
367
+ {task.status === 'in_progress' && <ActionBtn label="Complete" icon={<CheckCircle2 className="w-3 h-3" />} onClick={() => act('complete')} loading={acting === 'complete'} color="success" />}
368
+ {task.status === 'blocked' && <ActionBtn label="Resume" icon={<Play className="w-3 h-3" />} onClick={() => act('start')} loading={acting === 'start'} color="primary" />}
369
+ </div>
370
+ )}
371
+ </td>
372
+ </tr>
373
+ );
374
+ }
375
+
376
+ // ─── Empty State ──────────────────────────────────────────────────────────────
377
+
378
+ function EmptyState({ tab }: { tab: Tab }) {
379
+ const copy: Record<Tab, { title: string; sub: string }> = {
380
+ needs_attention: { title: 'All clear!', sub: 'No approvals or tasks waiting for your action.' },
381
+ delegated: { title: 'Nothing delegated', sub: 'Assignments you create will appear here.' },
382
+ all: { title: 'Nothing here yet', sub: 'Assignments will appear as they are created.' },
383
+ };
384
+ return (
385
+ <div className="flex flex-col items-center justify-center py-20 text-center">
386
+ <div className="w-14 h-14 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
387
+ <InboxIcon className="w-7 h-7 text-muted-foreground/50" />
388
+ </div>
389
+ <p className="text-base font-semibold text-foreground">{copy[tab].title}</p>
390
+ <p className="text-sm text-muted-foreground mt-1">{copy[tab].sub}</p>
391
+ </div>
392
+ );
393
+ }
394
+
395
+ // ─── Main Page ────────────────────────────────────────────────────────────────
396
+
397
+ export default function InboxPage() {
398
+ const [tab, setTab] = useState<Tab>('needs_attention');
399
+ const [view, setView] = useState<ViewMode>('card');
400
+ const [search, setSearch] = useState('');
401
+ const [activeFilters, setActiveFilters] = useState<Record<string, string[]>>({});
402
+ const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' } | null>({ key: 'created_at', dir: 'desc' });
403
+ const [page, setPage] = useState(1);
404
+ const pageSize = 25;
405
+
406
+ const { openQuickAdd } = useAppStore();
407
+ const { data: whoami } = useWhoAmI() as any;
408
+ const myActorId: string | undefined = whoami?.actor_id;
409
+
410
+ const hitlQ = useHITLRequests();
411
+ const myAssignQ = useAssignments(myActorId ? { assigned_to: myActorId, limit: 200 } : undefined);
412
+ const delegatedQ = useAssignments(myActorId ? { assigned_by: myActorId, limit: 200 } : undefined);
413
+ const allAssignQ = useAssignments({ limit: 200 });
414
+ const actorsQ = useActors({ limit: 100 }) as any;
415
+
416
+ // Build actor name lookup
417
+ const actorMap = useMemo(() => {
418
+ const map = new Map<string, string>();
419
+ (actorsQ.data?.actors ?? actorsQ.data?.data ?? []).forEach((a: any) => {
420
+ map.set(a.id, a.display_name ?? a.name ?? a.email ?? a.id);
421
+ });
422
+ return map;
423
+ }, [actorsQ.data]);
424
+
425
+ const pendingHitl: HITLRequest[] = useMemo(() =>
426
+ ((hitlQ.data as any)?.data ?? []).filter((r: any) => r.status === 'pending'), [hitlQ.data]);
427
+
428
+ const myAssignments: Assignment[] = (myAssignQ.data as any)?.assignments ?? [];
429
+ const delegatedRaw: Assignment[] = (delegatedQ.data as any)?.assignments ?? [];
430
+ const allAssignmentsRaw: Assignment[] = (allAssignQ.data as any)?.assignments ?? [];
431
+
432
+ const activeMyAssignments = useMemo(
433
+ () => myAssignments.filter(a => ACTIVE_STATUSES.includes(a.status)),
434
+ [myAssignments]
435
+ );
436
+ const activeDelegated = useMemo(
437
+ () => delegatedRaw.filter(a => ACTIVE_STATUSES.includes(a.status) && a.assigned_to !== myActorId),
438
+ [delegatedRaw, myActorId]
439
+ );
440
+
441
+ // Source list based on tab
442
+ const sourceList = useMemo(() => {
443
+ if (tab === 'needs_attention') return activeMyAssignments;
444
+ if (tab === 'delegated') return activeDelegated;
445
+ return allAssignmentsRaw;
446
+ }, [tab, activeMyAssignments, activeDelegated, allAssignmentsRaw]);
447
+
448
+ // Filter + search + sort
449
+ const filtered = useMemo(() => {
450
+ let result = [...sourceList];
451
+ if (search.trim()) {
452
+ const q = search.toLowerCase();
453
+ result = result.filter(a =>
454
+ a.title.toLowerCase().includes(q) ||
455
+ (a.context ?? '').toLowerCase().includes(q) ||
456
+ (a.description ?? '').toLowerCase().includes(q)
457
+ );
458
+ }
459
+ if (activeFilters.status?.length) result = result.filter(a => activeFilters.status.includes(a.status));
460
+ if (activeFilters.priority?.length) result = result.filter(a => activeFilters.priority.includes(a.priority));
461
+ if (activeFilters.assignment_type?.length) result = result.filter(a => activeFilters.assignment_type.includes(a.assignment_type));
462
+
463
+ if (sort) {
464
+ result.sort((a, b) => {
465
+ if (sort.key === 'priority') {
466
+ return sort.dir === 'asc'
467
+ ? (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2)
468
+ : (PRIORITY_ORDER[b.priority] ?? 2) - (PRIORITY_ORDER[a.priority] ?? 2);
469
+ }
470
+ const aVal = (a as any)[sort.key] ?? '';
471
+ const bVal = (b as any)[sort.key] ?? '';
472
+ return sort.dir === 'asc' ? String(aVal).localeCompare(String(bVal)) : String(bVal).localeCompare(String(aVal));
473
+ });
474
+ }
475
+ return result;
476
+ }, [sourceList, search, activeFilters, sort]);
477
+
478
+ useEffect(() => { setPage(1); }, [tab, search, activeFilters, sort]);
479
+
480
+ const paginated = filtered.slice((page - 1) * pageSize, page * pageSize);
481
+ const needsAttentionCount = pendingHitl.length + activeMyAssignments.length;
482
+ const isLoading = hitlQ.isLoading || myAssignQ.isLoading || allAssignQ.isLoading;
483
+
484
+ const handleFilterChange = (key: string, values: string[]) => {
485
+ setActiveFilters(prev => { const next = { ...prev }; if (values.length === 0) delete next[key]; else next[key] = values; return next; });
486
+ };
487
+ const handleSortChange = (key: string) => {
488
+ setSort(prev => prev?.key === key ? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' });
489
+ };
490
+
491
+ const SortHeader = ({ label, sortKey }: { label: string; sortKey: string }) => (
492
+ <th onClick={() => handleSortChange(sortKey)} className="text-left px-4 py-3 text-xs font-display font-semibold text-muted-foreground cursor-pointer hover:text-foreground transition-colors select-none">
493
+ <span className="inline-flex items-center gap-1">
494
+ {label}
495
+ {sort?.key === sortKey && (sort.dir === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
496
+ </span>
497
+ </th>
498
+ );
499
+
500
+ return (
501
+ <div className="flex flex-col h-full overflow-hidden">
502
+ <TopBar title="Assignments">
503
+ <div className="hidden md:flex items-center gap-1 bg-muted rounded-xl p-0.5">
504
+ {([{ mode: 'card', icon: LayoutGrid }, { mode: 'table', icon: List }] as const).map(({ mode, icon: Icon }) => (
505
+ <button key={mode} onClick={() => setView(mode)}
506
+ className={`p-1.5 rounded-lg transition-all ${view === mode ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground'}`}>
507
+ <Icon className="w-4 h-4" />
508
+ </button>
509
+ ))}
510
+ </div>
511
+ </TopBar>
512
+
513
+ {/* Tab strip — same pill style as Use Cases prod date */}
514
+ <div className="px-4 md:px-6 pt-3 pb-1 flex flex-wrap items-center gap-2">
515
+ <div className="inline-flex rounded-xl border border-border bg-muted/40 p-0.5 gap-0.5">
516
+ {TABS.map(t => (
517
+ <button key={t.key} onClick={() => setTab(t.key)}
518
+ className={['px-3 py-1.5 text-xs font-medium rounded-lg transition-all flex items-center gap-1.5',
519
+ tab === t.key ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
520
+ ].join(' ')}>
521
+ {t.label}
522
+ {t.key === 'needs_attention' && needsAttentionCount > 0 && (
523
+ <span className={`min-w-[16px] h-4 px-1 rounded-full text-[10px] font-bold flex items-center justify-center ${
524
+ tab === t.key ? 'bg-destructive text-white' : 'bg-muted-foreground/20 text-muted-foreground'
525
+ }`}>{needsAttentionCount}</span>
526
+ )}
527
+ </button>
528
+ ))}
529
+ </div>
530
+ </div>
531
+
532
+ <ListToolbar
533
+ searchValue={search} onSearchChange={setSearch}
534
+ searchPlaceholder="Search assignments..."
535
+ filters={FILTER_CONFIGS} activeFilters={activeFilters}
536
+ onFilterChange={handleFilterChange} onClearFilters={() => setActiveFilters({})}
537
+ sortOptions={SORT_OPTIONS} currentSort={sort} onSortChange={handleSortChange}
538
+ onAdd={() => openQuickAdd('assignment')} addLabel="New Assignment"
539
+ entityType="assignments"
540
+ />
541
+
542
+ <div className="flex-1 overflow-y-auto pb-24 md:pb-6">
543
+ {isLoading ? (
544
+ <div className="flex items-center justify-center py-20">
545
+ <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
546
+ </div>
547
+ ) : (
548
+ <>
549
+ {/* HITL requests — only on Needs Attention tab */}
550
+ {tab === 'needs_attention' && pendingHitl.length > 0 && (
551
+ <div className="px-4 md:px-6 pt-4 pb-2">
552
+ <div className="flex items-center gap-2 mb-3">
553
+ <span className="text-xs font-display font-bold text-muted-foreground uppercase tracking-wide">Approval Requests</span>
554
+ <span className="px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground text-[10px] font-semibold">{pendingHitl.length}</span>
555
+ <div className="flex-1 h-px bg-border" />
556
+ </div>
557
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
558
+ {pendingHitl.map(r => <HITLCard key={r.id} req={r} />)}
559
+ </div>
560
+ </div>
561
+ )}
562
+
563
+ {/* Assignments */}
564
+ {paginated.length === 0 && pendingHitl.length === 0 ? (
565
+ <EmptyState tab={tab} />
566
+ ) : paginated.length === 0 && tab === 'needs_attention' ? null : paginated.length === 0 ? (
567
+ <EmptyState tab={tab} />
568
+ ) : view === 'card' ? (
569
+ <div className="px-4 md:px-6 pt-4 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
570
+ {tab === 'needs_attention' && paginated.length > 0 && (
571
+ <div className="flex items-center gap-2 mb-1">
572
+ <span className="text-xs font-display font-bold text-muted-foreground uppercase tracking-wide">My Tasks</span>
573
+ <span className="px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground text-[10px] font-semibold">{paginated.length}</span>
574
+ <div className="flex-1 h-px bg-border" />
575
+ </div>
576
+ )}
577
+ {paginated.map(a => <AssignmentCard key={a.id} task={a} actorMap={actorMap} />)}
578
+ </div>
579
+ ) : (
580
+ <div className="px-4 md:px-6 pt-4 overflow-x-auto">
581
+ <div className="bg-card border border-border rounded-2xl overflow-hidden shadow-sm">
582
+ <table className="w-full text-sm">
583
+ <thead>
584
+ <tr className="border-b border-border bg-surface-sunken/50">
585
+ <SortHeader label="Priority" sortKey="priority" />
586
+ <SortHeader label="Title" sortKey="title" />
587
+ <th className="text-left px-4 py-3 text-xs font-display font-semibold text-muted-foreground">Type</th>
588
+ <SortHeader label="Status" sortKey="status" />
589
+ <th className="text-left px-4 py-3 text-xs font-display font-semibold text-muted-foreground">Assigned To</th>
590
+ <th className="text-left px-4 py-3 text-xs font-display font-semibold text-muted-foreground">Assigned By</th>
591
+ <SortHeader label="Due" sortKey="due_at" />
592
+ <th className="px-4 py-3" />
593
+ </tr>
594
+ </thead>
595
+ <tbody>
596
+ {paginated.map((a, i) => (
597
+ <AssignmentTableRow key={a.id} task={a} actorMap={actorMap} index={i} />
598
+ ))}
599
+ </tbody>
600
+ </table>
601
+ </div>
602
+ </div>
603
+ )}
604
+
605
+ {filtered.length > pageSize && (
606
+ <div className="px-4 md:px-6 pt-4">
607
+ <PaginationBar page={page} pageSize={pageSize} total={filtered.length} onPageChange={setPage} onPageSizeChange={() => {}} />
608
+ </div>
609
+ )}
610
+ </>
611
+ )}
612
+ </div>
613
+ </div>
614
+ );
615
+ }
@@ -0,0 +1,24 @@
1
+ import { useLocation } from "react-router-dom";
2
+ import { useEffect } from "react";
3
+
4
+ const NotFound = () => {
5
+ const location = useLocation();
6
+
7
+ useEffect(() => {
8
+ console.error("404 Error: User attempted to access non-existent route:", location.pathname);
9
+ }, [location.pathname]);
10
+
11
+ return (
12
+ <div className="flex min-h-screen items-center justify-center bg-muted">
13
+ <div className="text-center">
14
+ <h1 className="mb-4 text-4xl font-bold">404</h1>
15
+ <p className="mb-4 text-xl text-muted-foreground">Oops! Page not found</p>
16
+ <a href="/" className="text-primary underline hover:text-primary/90">
17
+ Return to Home
18
+ </a>
19
+ </div>
20
+ </div>
21
+ );
22
+ };
23
+
24
+ export default NotFound;