@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,750 +0,0 @@
|
|
|
1
|
-
// Copyright 2026 CRMy Contributors
|
|
2
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
-
|
|
4
|
-
import { useState, useRef, useEffect, useMemo } from 'react';
|
|
5
|
-
import { TopBar } from '@/components/layout/TopBar';
|
|
6
|
-
import { PaginationBar } from '@/components/crm/PaginationBar';
|
|
7
|
-
import {
|
|
8
|
-
useAssignments,
|
|
9
|
-
useWhoAmI,
|
|
10
|
-
useAcceptAssignment,
|
|
11
|
-
useStartAssignment,
|
|
12
|
-
useCompleteAssignment,
|
|
13
|
-
useDeclineAssignment,
|
|
14
|
-
useBlockAssignment,
|
|
15
|
-
useCancelAssignment,
|
|
16
|
-
} from '@/api/hooks';
|
|
17
|
-
import { useAppStore } from '@/store/appStore';
|
|
18
|
-
import {
|
|
19
|
-
ClipboardList, Play, CheckCircle2, XCircle, Ban, AlertOctagon,
|
|
20
|
-
Search, Filter, ArrowUpDown, X, ChevronDown, Calendar, Plus,
|
|
21
|
-
} from 'lucide-react';
|
|
22
|
-
import { toast } from '@/components/ui/use-toast';
|
|
23
|
-
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
24
|
-
import { Checkbox } from '@/components/ui/checkbox';
|
|
25
|
-
import { DatePicker } from '@/components/ui/date-picker';
|
|
26
|
-
|
|
27
|
-
type Tab = 'mine' | 'delegated' | 'all';
|
|
28
|
-
type DrawerType = 'contact' | 'opportunity' | 'use-case' | 'account' | 'assignment';
|
|
29
|
-
type SortKey = 'created_at' | 'due_at' | 'priority' | 'status' | 'title';
|
|
30
|
-
type SortDir = 'asc' | 'desc';
|
|
31
|
-
type DatePreset = 'today' | 'this_week' | 'this_month' | 'this_quarter' | 'overdue' | 'no_due_date' | 'custom' | '';
|
|
32
|
-
|
|
33
|
-
const SUBJECT_TYPE_DRAWER: Record<string, DrawerType> = {
|
|
34
|
-
contact: 'contact',
|
|
35
|
-
account: 'account',
|
|
36
|
-
opportunity: 'opportunity',
|
|
37
|
-
use_case: 'use-case',
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const STATUS_COLORS: Record<string, string> = {
|
|
41
|
-
pending: '#f59e0b',
|
|
42
|
-
accepted: '#3b82f6',
|
|
43
|
-
in_progress: '#8b5cf6',
|
|
44
|
-
blocked: '#ef4444',
|
|
45
|
-
completed: '#22c55e',
|
|
46
|
-
declined: '#94a3b8',
|
|
47
|
-
cancelled: '#94a3b8',
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const PRIORITY_ORDER: Record<string, number> = { urgent: 0, high: 1, normal: 2, low: 3 };
|
|
51
|
-
const PRIORITY_COLORS: Record<string, string> = {
|
|
52
|
-
urgent: '#ef4444',
|
|
53
|
-
high: '#f97316',
|
|
54
|
-
normal: '#3b82f6',
|
|
55
|
-
low: '#94a3b8',
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
interface Assignment {
|
|
59
|
-
id: string;
|
|
60
|
-
title: string;
|
|
61
|
-
description?: string;
|
|
62
|
-
assignment_type: string;
|
|
63
|
-
status: string;
|
|
64
|
-
priority: string;
|
|
65
|
-
subject_type: string;
|
|
66
|
-
subject_id: string;
|
|
67
|
-
assigned_to: string;
|
|
68
|
-
assigned_by: string;
|
|
69
|
-
context?: string;
|
|
70
|
-
created_at: string;
|
|
71
|
-
due_at?: string;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const STATUS_OPTIONS = [
|
|
75
|
-
{ value: 'open', label: 'Open (all active)' },
|
|
76
|
-
{ value: 'pending', label: 'Pending' },
|
|
77
|
-
{ value: 'accepted', label: 'Accepted' },
|
|
78
|
-
{ value: 'in_progress', label: 'In Progress' },
|
|
79
|
-
{ value: 'blocked', label: 'Blocked' },
|
|
80
|
-
{ value: 'completed', label: 'Completed' },
|
|
81
|
-
{ value: 'declined', label: 'Declined' },
|
|
82
|
-
{ value: 'cancelled', label: 'Cancelled' },
|
|
83
|
-
];
|
|
84
|
-
|
|
85
|
-
const PRIORITY_OPTIONS = [
|
|
86
|
-
{ value: 'urgent', label: 'Urgent' },
|
|
87
|
-
{ value: 'high', label: 'High' },
|
|
88
|
-
{ value: 'normal', label: 'Normal' },
|
|
89
|
-
{ value: 'low', label: 'Low' },
|
|
90
|
-
];
|
|
91
|
-
|
|
92
|
-
const TYPE_OPTIONS = [
|
|
93
|
-
{ value: 'call', label: 'Call' },
|
|
94
|
-
{ value: 'draft', label: 'Draft' },
|
|
95
|
-
{ value: 'email', label: 'Email' },
|
|
96
|
-
{ value: 'follow_up', label: 'Follow Up' },
|
|
97
|
-
{ value: 'research', label: 'Research' },
|
|
98
|
-
{ value: 'review', label: 'Review' },
|
|
99
|
-
{ value: 'send', label: 'Send' },
|
|
100
|
-
];
|
|
101
|
-
|
|
102
|
-
const DUE_DATE_PRESETS: { value: DatePreset; label: string }[] = [
|
|
103
|
-
{ value: 'overdue', label: 'Overdue' },
|
|
104
|
-
{ value: 'today', label: 'Due Today' },
|
|
105
|
-
{ value: 'this_week', label: 'Due This Week' },
|
|
106
|
-
{ value: 'this_month', label: 'Due This Month' },
|
|
107
|
-
{ value: 'no_due_date', label: 'No Due Date' },
|
|
108
|
-
{ value: 'custom', label: 'Custom Range…' },
|
|
109
|
-
];
|
|
110
|
-
|
|
111
|
-
const CREATED_DATE_PRESETS: { value: DatePreset; label: string }[] = [
|
|
112
|
-
{ value: 'today', label: 'Today' },
|
|
113
|
-
{ value: 'this_week', label: 'This Week' },
|
|
114
|
-
{ value: 'this_month', label: 'This Month' },
|
|
115
|
-
{ value: 'this_quarter', label: 'This Quarter' },
|
|
116
|
-
{ value: 'custom', label: 'Custom Range…' },
|
|
117
|
-
];
|
|
118
|
-
|
|
119
|
-
const SORT_OPTIONS: { key: SortKey; label: string }[] = [
|
|
120
|
-
{ key: 'created_at', label: 'Date Created' },
|
|
121
|
-
{ key: 'due_at', label: 'Due Date' },
|
|
122
|
-
{ key: 'priority', label: 'Priority' },
|
|
123
|
-
{ key: 'status', label: 'Status' },
|
|
124
|
-
{ key: 'title', label: 'Title' },
|
|
125
|
-
];
|
|
126
|
-
|
|
127
|
-
function getPresetRange(preset: DatePreset): { start: Date; end: Date } | null {
|
|
128
|
-
const now = new Date();
|
|
129
|
-
const endOfDay = new Date(now); endOfDay.setHours(23, 59, 59, 999);
|
|
130
|
-
if (preset === 'today') {
|
|
131
|
-
const start = new Date(now); start.setHours(0, 0, 0, 0);
|
|
132
|
-
return { start, end: endOfDay };
|
|
133
|
-
}
|
|
134
|
-
if (preset === 'this_week') {
|
|
135
|
-
const start = new Date(now);
|
|
136
|
-
const day = start.getDay();
|
|
137
|
-
start.setDate(start.getDate() - (day === 0 ? 6 : day - 1));
|
|
138
|
-
start.setHours(0, 0, 0, 0);
|
|
139
|
-
const end = new Date(start); end.setDate(end.getDate() + 6); end.setHours(23, 59, 59, 999);
|
|
140
|
-
return { start, end };
|
|
141
|
-
}
|
|
142
|
-
if (preset === 'this_month') {
|
|
143
|
-
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
144
|
-
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
|
|
145
|
-
return { start, end };
|
|
146
|
-
}
|
|
147
|
-
if (preset === 'this_quarter') {
|
|
148
|
-
const q = Math.floor(now.getMonth() / 3);
|
|
149
|
-
const start = new Date(now.getFullYear(), q * 3, 1);
|
|
150
|
-
const end = new Date(now.getFullYear(), q * 3 + 3, 0, 23, 59, 59, 999);
|
|
151
|
-
return { start, end };
|
|
152
|
-
}
|
|
153
|
-
if (preset === 'overdue') {
|
|
154
|
-
return { start: new Date(0), end: new Date(now.getTime() - 1) };
|
|
155
|
-
}
|
|
156
|
-
return null;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
export default function AssignmentsPage() {
|
|
160
|
-
const [tab, setTab] = useState<Tab>('mine');
|
|
161
|
-
const [page, setPage] = useState(1);
|
|
162
|
-
const PAGE_SIZE = 25;
|
|
163
|
-
const [search, setSearch] = useState('');
|
|
164
|
-
const [activeFilters, setActiveFilters] = useState<Record<string, string[]>>({ status: ['open'] });
|
|
165
|
-
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>({ key: 'created_at', dir: 'desc' });
|
|
166
|
-
|
|
167
|
-
// Due date range
|
|
168
|
-
const [dueDatePreset, setDueDatePreset] = useState<DatePreset>('');
|
|
169
|
-
const [dueFrom, setDueFrom] = useState('');
|
|
170
|
-
const [dueTo, setDueTo] = useState('');
|
|
171
|
-
|
|
172
|
-
// Created date range
|
|
173
|
-
const [createdPreset, setCreatedPreset] = useState<DatePreset>('');
|
|
174
|
-
const [createdFrom, setCreatedFrom] = useState('');
|
|
175
|
-
const [createdTo, setCreatedTo] = useState('');
|
|
176
|
-
|
|
177
|
-
const searchRef = useRef<HTMLInputElement>(null);
|
|
178
|
-
|
|
179
|
-
useEffect(() => {
|
|
180
|
-
const handler = (e: KeyboardEvent) => {
|
|
181
|
-
const target = e.target as HTMLElement;
|
|
182
|
-
const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
|
183
|
-
if (e.key === '/' && !isInput) { e.preventDefault(); searchRef.current?.focus(); }
|
|
184
|
-
};
|
|
185
|
-
window.addEventListener('keydown', handler);
|
|
186
|
-
return () => window.removeEventListener('keydown', handler);
|
|
187
|
-
}, []);
|
|
188
|
-
|
|
189
|
-
const { openQuickAdd } = useAppStore();
|
|
190
|
-
|
|
191
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
192
|
-
const { data: whoami } = useWhoAmI() as any;
|
|
193
|
-
const myActorId = whoami?.actor_id;
|
|
194
|
-
|
|
195
|
-
const apiParams: Record<string, string | number | boolean | undefined> = { limit: 200 };
|
|
196
|
-
if (tab === 'mine' && myActorId) apiParams.assigned_to = myActorId;
|
|
197
|
-
if (tab === 'delegated' && myActorId) apiParams.assigned_by = myActorId;
|
|
198
|
-
const statusFilters = activeFilters.status ?? [];
|
|
199
|
-
if (statusFilters.length === 1) {
|
|
200
|
-
const s = statusFilters[0];
|
|
201
|
-
apiParams.status = s === 'open' ? 'pending,accepted,in_progress,blocked' : s;
|
|
202
|
-
} else if (statusFilters.length > 1) {
|
|
203
|
-
apiParams.status = statusFilters.join(',');
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
207
|
-
const { data, isLoading } = useAssignments(apiParams) as any;
|
|
208
|
-
const raw: Assignment[] = data?.assignments ?? [];
|
|
209
|
-
|
|
210
|
-
const assignments = useMemo(() => {
|
|
211
|
-
let list = raw;
|
|
212
|
-
|
|
213
|
-
// Search
|
|
214
|
-
if (search.trim()) {
|
|
215
|
-
const q = search.toLowerCase();
|
|
216
|
-
list = list.filter(a =>
|
|
217
|
-
a.title.toLowerCase().includes(q) ||
|
|
218
|
-
a.description?.toLowerCase().includes(q) ||
|
|
219
|
-
a.context?.toLowerCase().includes(q) ||
|
|
220
|
-
a.assignment_type.toLowerCase().includes(q),
|
|
221
|
-
);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Priority / type filters
|
|
225
|
-
const pf = activeFilters.priority ?? [];
|
|
226
|
-
if (pf.length > 0) list = list.filter(a => pf.includes(a.priority));
|
|
227
|
-
const tf = activeFilters.type ?? [];
|
|
228
|
-
if (tf.length > 0) list = list.filter(a => tf.includes(a.assignment_type));
|
|
229
|
-
|
|
230
|
-
// Due date filter
|
|
231
|
-
if (dueDatePreset === 'no_due_date') {
|
|
232
|
-
list = list.filter(a => !a.due_at);
|
|
233
|
-
} else if (dueDatePreset === 'overdue') {
|
|
234
|
-
const now = new Date();
|
|
235
|
-
list = list.filter(a => a.due_at && new Date(a.due_at) < now);
|
|
236
|
-
} else if (dueDatePreset === 'custom') {
|
|
237
|
-
const start = dueFrom ? new Date(dueFrom + 'T00:00:00') : null;
|
|
238
|
-
const end = dueTo ? new Date(dueTo + 'T23:59:59') : null;
|
|
239
|
-
list = list.filter(a => {
|
|
240
|
-
if (!a.due_at) return false;
|
|
241
|
-
const d = new Date(a.due_at);
|
|
242
|
-
return (!start || d >= start) && (!end || d <= end);
|
|
243
|
-
});
|
|
244
|
-
} else if (dueDatePreset) {
|
|
245
|
-
const range = getPresetRange(dueDatePreset);
|
|
246
|
-
if (range) list = list.filter(a => {
|
|
247
|
-
if (!a.due_at) return false;
|
|
248
|
-
const d = new Date(a.due_at);
|
|
249
|
-
return d >= range.start && d <= range.end;
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// Created date filter
|
|
254
|
-
if (createdPreset === 'custom') {
|
|
255
|
-
const start = createdFrom ? new Date(createdFrom + 'T00:00:00') : null;
|
|
256
|
-
const end = createdTo ? new Date(createdTo + 'T23:59:59') : null;
|
|
257
|
-
list = list.filter(a => {
|
|
258
|
-
const d = new Date(a.created_at);
|
|
259
|
-
return (!start || d >= start) && (!end || d <= end);
|
|
260
|
-
});
|
|
261
|
-
} else if (createdPreset) {
|
|
262
|
-
const range = getPresetRange(createdPreset);
|
|
263
|
-
if (range) list = list.filter(a => {
|
|
264
|
-
const d = new Date(a.created_at);
|
|
265
|
-
return d >= range.start && d <= range.end;
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// Sort
|
|
270
|
-
list = [...list].sort((a, b) => {
|
|
271
|
-
let cmp = 0;
|
|
272
|
-
switch (sort.key) {
|
|
273
|
-
case 'title': cmp = a.title.localeCompare(b.title); break;
|
|
274
|
-
case 'status': cmp = a.status.localeCompare(b.status); break;
|
|
275
|
-
case 'priority': cmp = (PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99); break;
|
|
276
|
-
case 'due_at': {
|
|
277
|
-
const ad = a.due_at ? new Date(a.due_at).getTime() : Infinity;
|
|
278
|
-
const bd = b.due_at ? new Date(b.due_at).getTime() : Infinity;
|
|
279
|
-
cmp = ad - bd;
|
|
280
|
-
break;
|
|
281
|
-
}
|
|
282
|
-
default: cmp = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
|
283
|
-
}
|
|
284
|
-
return sort.dir === 'asc' ? cmp : -cmp;
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
return list;
|
|
288
|
-
}, [raw, search, activeFilters, sort, dueDatePreset, dueFrom, dueTo, createdPreset, createdFrom, createdTo]);
|
|
289
|
-
|
|
290
|
-
useEffect(() => { setPage(1); }, [tab, search, activeFilters, sort, dueDatePreset, dueFrom, dueTo, createdPreset, createdFrom, createdTo]);
|
|
291
|
-
const paginated = assignments.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
|
292
|
-
|
|
293
|
-
const handleFilterChange = (key: string, values: string[]) => setActiveFilters(prev => ({ ...prev, [key]: values }));
|
|
294
|
-
const handleClearFilters = () => {
|
|
295
|
-
setActiveFilters({ status: ['open'] });
|
|
296
|
-
setDueDatePreset(''); setDueFrom(''); setDueTo('');
|
|
297
|
-
setCreatedPreset(''); setCreatedFrom(''); setCreatedTo('');
|
|
298
|
-
};
|
|
299
|
-
const handleSortChange = (key: SortKey) => {
|
|
300
|
-
setSort(prev => prev.key === key ? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'desc' });
|
|
301
|
-
};
|
|
302
|
-
|
|
303
|
-
const activeFilterCount = Object.entries(activeFilters).reduce((sum, [k, vals]) => {
|
|
304
|
-
if (k === 'status' && vals.length === 1 && vals[0] === 'open') return sum;
|
|
305
|
-
return sum + vals.length;
|
|
306
|
-
}, 0) + (dueDatePreset ? 1 : 0) + (createdPreset ? 1 : 0);
|
|
307
|
-
|
|
308
|
-
const acceptMutation = useAcceptAssignment();
|
|
309
|
-
const startMutation = useStartAssignment();
|
|
310
|
-
const completeMutation = useCompleteAssignment();
|
|
311
|
-
const declineMutation = useDeclineAssignment();
|
|
312
|
-
const blockMutation = useBlockAssignment();
|
|
313
|
-
const cancelMutation = useCancelAssignment();
|
|
314
|
-
const { openDrawer } = useAppStore();
|
|
315
|
-
|
|
316
|
-
const handleAction = async (action: string, id: string) => {
|
|
317
|
-
try {
|
|
318
|
-
switch (action) {
|
|
319
|
-
case 'accept': await acceptMutation.mutateAsync(id); break;
|
|
320
|
-
case 'start': await startMutation.mutateAsync(id); break;
|
|
321
|
-
case 'complete': await completeMutation.mutateAsync({ id }); break;
|
|
322
|
-
case 'decline': await declineMutation.mutateAsync({ id }); break;
|
|
323
|
-
case 'block': await blockMutation.mutateAsync({ id }); break;
|
|
324
|
-
case 'cancel': await cancelMutation.mutateAsync({ id }); break;
|
|
325
|
-
}
|
|
326
|
-
toast({ title: `Assignment ${action}ed` });
|
|
327
|
-
} catch (err) {
|
|
328
|
-
toast({ title: `Failed to ${action} assignment`, description: err instanceof Error ? err.message : 'Please try again.', variant: 'destructive' });
|
|
329
|
-
}
|
|
330
|
-
};
|
|
331
|
-
|
|
332
|
-
const getActions = (a: Assignment) => {
|
|
333
|
-
const actions: { label: string; action: string; icon: React.ReactNode; variant?: string }[] = [];
|
|
334
|
-
switch (a.status) {
|
|
335
|
-
case 'pending':
|
|
336
|
-
actions.push({ label: 'Accept', action: 'accept', icon: <CheckCircle2 className="w-3 h-3" /> });
|
|
337
|
-
actions.push({ label: 'Decline', action: 'decline', icon: <XCircle className="w-3 h-3" />, variant: 'muted' });
|
|
338
|
-
break;
|
|
339
|
-
case 'accepted':
|
|
340
|
-
actions.push({ label: 'Start', action: 'start', icon: <Play className="w-3 h-3" /> });
|
|
341
|
-
actions.push({ label: 'Block', action: 'block', icon: <AlertOctagon className="w-3 h-3" />, variant: 'warning' });
|
|
342
|
-
break;
|
|
343
|
-
case 'in_progress':
|
|
344
|
-
actions.push({ label: 'Complete', action: 'complete', icon: <CheckCircle2 className="w-3 h-3" /> });
|
|
345
|
-
actions.push({ label: 'Block', action: 'block', icon: <AlertOctagon className="w-3 h-3" />, variant: 'warning' });
|
|
346
|
-
break;
|
|
347
|
-
case 'blocked':
|
|
348
|
-
actions.push({ label: 'Start', action: 'start', icon: <Play className="w-3 h-3" /> });
|
|
349
|
-
actions.push({ label: 'Cancel', action: 'cancel', icon: <Ban className="w-3 h-3" />, variant: 'destructive' });
|
|
350
|
-
break;
|
|
351
|
-
}
|
|
352
|
-
if (!['completed', 'declined', 'cancelled', 'blocked'].includes(a.status)) {
|
|
353
|
-
actions.push({ label: 'Cancel', action: 'cancel', icon: <Ban className="w-3 h-3" />, variant: 'destructive' });
|
|
354
|
-
}
|
|
355
|
-
return actions;
|
|
356
|
-
};
|
|
357
|
-
|
|
358
|
-
// Build active filter pills including date ranges
|
|
359
|
-
const filterPills: { key: string; label: string; onRemove: () => void }[] = [];
|
|
360
|
-
Object.entries(activeFilters).forEach(([key, values]) => {
|
|
361
|
-
if (key === 'status' && values.length === 1 && values[0] === 'open') return;
|
|
362
|
-
const allOpts = { status: STATUS_OPTIONS, priority: PRIORITY_OPTIONS, type: TYPE_OPTIONS } as Record<string, { value: string; label: string }[]>;
|
|
363
|
-
values.forEach(val => {
|
|
364
|
-
const label = allOpts[key]?.find(o => o.value === val)?.label ?? val;
|
|
365
|
-
filterPills.push({
|
|
366
|
-
key: `${key}-${val}`,
|
|
367
|
-
label: `${key}: ${label}`,
|
|
368
|
-
onRemove: () => handleFilterChange(key, values.filter(v => v !== val)),
|
|
369
|
-
});
|
|
370
|
-
});
|
|
371
|
-
});
|
|
372
|
-
if (dueDatePreset) {
|
|
373
|
-
const label = dueDatePreset === 'custom'
|
|
374
|
-
? `Due: ${dueFrom || '…'} → ${dueTo || '…'}`
|
|
375
|
-
: `Due: ${DUE_DATE_PRESETS.find(p => p.value === dueDatePreset)?.label ?? dueDatePreset}`;
|
|
376
|
-
filterPills.push({ key: 'due_date', label, onRemove: () => { setDueDatePreset(''); setDueFrom(''); setDueTo(''); } });
|
|
377
|
-
}
|
|
378
|
-
if (createdPreset) {
|
|
379
|
-
const label = createdPreset === 'custom'
|
|
380
|
-
? `Created: ${createdFrom || '…'} → ${createdTo || '…'}`
|
|
381
|
-
: `Created: ${CREATED_DATE_PRESETS.find(p => p.value === createdPreset)?.label ?? createdPreset}`;
|
|
382
|
-
filterPills.push({ key: 'created_date', label, onRemove: () => { setCreatedPreset(''); setCreatedFrom(''); setCreatedTo(''); } });
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
return (
|
|
386
|
-
<>
|
|
387
|
-
<TopBar title="Assignments" />
|
|
388
|
-
|
|
389
|
-
{/* Tabs */}
|
|
390
|
-
<div className="flex items-center gap-3 px-4 md:px-6 pt-4 pb-1">
|
|
391
|
-
<div className="flex items-center gap-0.5 bg-muted rounded-lg p-0.5">
|
|
392
|
-
{(['mine', 'delegated', 'all'] as Tab[]).map(t => (
|
|
393
|
-
<button
|
|
394
|
-
key={t}
|
|
395
|
-
onClick={() => setTab(t)}
|
|
396
|
-
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${tab === t ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
|
|
397
|
-
>
|
|
398
|
-
{t === 'mine' ? 'My Queue' : t === 'delegated' ? 'Delegated' : 'All'}
|
|
399
|
-
</button>
|
|
400
|
-
))}
|
|
401
|
-
</div>
|
|
402
|
-
<span className="text-xs text-muted-foreground">{assignments.length} assignment{assignments.length !== 1 ? 's' : ''}</span>
|
|
403
|
-
</div>
|
|
404
|
-
|
|
405
|
-
{/* Toolbar */}
|
|
406
|
-
<div className="flex flex-col gap-2 px-4 md:px-6 py-2">
|
|
407
|
-
<div className="flex items-center gap-2">
|
|
408
|
-
{/* Search */}
|
|
409
|
-
<div className="relative flex-1 min-w-0 max-w-sm">
|
|
410
|
-
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
411
|
-
<input
|
|
412
|
-
ref={searchRef}
|
|
413
|
-
value={search}
|
|
414
|
-
onChange={e => setSearch(e.target.value)}
|
|
415
|
-
placeholder="Search assignments..."
|
|
416
|
-
className="w-full h-9 pl-9 pr-8 rounded-xl border border-border bg-card text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-2 focus:ring-primary/30 transition-all"
|
|
417
|
-
/>
|
|
418
|
-
{search ? (
|
|
419
|
-
<button onClick={() => setSearch('')} className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1">
|
|
420
|
-
<X className="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
|
|
421
|
-
</button>
|
|
422
|
-
) : (
|
|
423
|
-
<kbd className="absolute right-2.5 top-1/2 -translate-y-1/2 hidden md:inline-flex items-center px-1.5 py-0.5 rounded-md text-[10px] font-mono text-muted-foreground/50 bg-muted border border-border">
|
|
424
|
-
/
|
|
425
|
-
</kbd>
|
|
426
|
-
)}
|
|
427
|
-
</div>
|
|
428
|
-
|
|
429
|
-
{/* Filter */}
|
|
430
|
-
<Popover>
|
|
431
|
-
<PopoverTrigger asChild>
|
|
432
|
-
<button className="h-9 px-3 flex items-center gap-1.5 rounded-xl border border-border bg-card text-sm text-muted-foreground hover:text-foreground hover:border-primary/30 transition-all flex-shrink-0">
|
|
433
|
-
<Filter className="w-3.5 h-3.5" />
|
|
434
|
-
<span className="hidden sm:inline">Filter</span>
|
|
435
|
-
{activeFilterCount > 0 && (
|
|
436
|
-
<span className="ml-0.5 px-1.5 py-0.5 rounded-full bg-primary text-primary-foreground text-[10px] font-semibold">
|
|
437
|
-
{activeFilterCount}
|
|
438
|
-
</span>
|
|
439
|
-
)}
|
|
440
|
-
</button>
|
|
441
|
-
</PopoverTrigger>
|
|
442
|
-
<PopoverContent className="w-96 p-0 rounded-xl" align="start">
|
|
443
|
-
<div className="p-3 border-b border-border flex items-center justify-between">
|
|
444
|
-
<span className="text-sm font-display font-bold text-foreground">Filters</span>
|
|
445
|
-
{activeFilterCount > 0 && (
|
|
446
|
-
<button onClick={handleClearFilters} className="text-xs text-muted-foreground hover:text-foreground">
|
|
447
|
-
Reset all
|
|
448
|
-
</button>
|
|
449
|
-
)}
|
|
450
|
-
</div>
|
|
451
|
-
<div className="p-2 max-h-[480px] overflow-y-auto space-y-1">
|
|
452
|
-
<FilterSection
|
|
453
|
-
label="Status" filterKey="status"
|
|
454
|
-
options={STATUS_OPTIONS} selected={activeFilters.status ?? []}
|
|
455
|
-
onChange={v => handleFilterChange('status', v)}
|
|
456
|
-
/>
|
|
457
|
-
<FilterSection
|
|
458
|
-
label="Priority" filterKey="priority"
|
|
459
|
-
options={PRIORITY_OPTIONS} selected={activeFilters.priority ?? []}
|
|
460
|
-
onChange={v => handleFilterChange('priority', v)}
|
|
461
|
-
/>
|
|
462
|
-
<FilterSection
|
|
463
|
-
label="Type" filterKey="type"
|
|
464
|
-
options={TYPE_OPTIONS} selected={activeFilters.type ?? []}
|
|
465
|
-
onChange={v => handleFilterChange('type', v)}
|
|
466
|
-
/>
|
|
467
|
-
<DateRangeSection
|
|
468
|
-
label="Due Date"
|
|
469
|
-
icon={<Calendar className="w-3 h-3" />}
|
|
470
|
-
presets={DUE_DATE_PRESETS}
|
|
471
|
-
selectedPreset={dueDatePreset}
|
|
472
|
-
onPresetChange={p => { setDueDatePreset(p); if (p !== 'custom') { setDueFrom(''); setDueTo(''); } }}
|
|
473
|
-
customFrom={dueFrom}
|
|
474
|
-
customTo={dueTo}
|
|
475
|
-
onFromChange={setDueFrom}
|
|
476
|
-
onToChange={setDueTo}
|
|
477
|
-
/>
|
|
478
|
-
<DateRangeSection
|
|
479
|
-
label="Created Date"
|
|
480
|
-
icon={<Calendar className="w-3 h-3" />}
|
|
481
|
-
presets={CREATED_DATE_PRESETS}
|
|
482
|
-
selectedPreset={createdPreset}
|
|
483
|
-
onPresetChange={p => { setCreatedPreset(p); if (p !== 'custom') { setCreatedFrom(''); setCreatedTo(''); } }}
|
|
484
|
-
customFrom={createdFrom}
|
|
485
|
-
customTo={createdTo}
|
|
486
|
-
onFromChange={setCreatedFrom}
|
|
487
|
-
onToChange={setCreatedTo}
|
|
488
|
-
/>
|
|
489
|
-
</div>
|
|
490
|
-
</PopoverContent>
|
|
491
|
-
</Popover>
|
|
492
|
-
|
|
493
|
-
{/* Sort */}
|
|
494
|
-
<Popover>
|
|
495
|
-
<PopoverTrigger asChild>
|
|
496
|
-
<button className="h-9 px-3 flex items-center gap-1.5 rounded-xl border border-border bg-card text-sm text-muted-foreground hover:text-foreground hover:border-primary/30 transition-all flex-shrink-0">
|
|
497
|
-
<ArrowUpDown className="w-3.5 h-3.5" />
|
|
498
|
-
<span className="hidden sm:inline">{SORT_OPTIONS.find(s => s.key === sort.key)?.label ?? 'Sort'}</span>
|
|
499
|
-
<span className="text-[10px] font-mono">{sort.dir === 'asc' ? '↑' : '↓'}</span>
|
|
500
|
-
</button>
|
|
501
|
-
</PopoverTrigger>
|
|
502
|
-
<PopoverContent className="w-48 p-1 rounded-xl" align="start">
|
|
503
|
-
{SORT_OPTIONS.map(opt => (
|
|
504
|
-
<button
|
|
505
|
-
key={opt.key}
|
|
506
|
-
onClick={() => handleSortChange(opt.key)}
|
|
507
|
-
className={`w-full text-left px-3 py-2.5 text-sm rounded-lg transition-colors ${sort.key === opt.key ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}`}
|
|
508
|
-
>
|
|
509
|
-
{opt.label}
|
|
510
|
-
{sort.key === opt.key && (
|
|
511
|
-
<span className="ml-auto float-right text-[10px] font-mono">{sort.dir === 'asc' ? '↑' : '↓'}</span>
|
|
512
|
-
)}
|
|
513
|
-
</button>
|
|
514
|
-
))}
|
|
515
|
-
</PopoverContent>
|
|
516
|
-
</Popover>
|
|
517
|
-
|
|
518
|
-
<button
|
|
519
|
-
onClick={() => openQuickAdd('assignment')}
|
|
520
|
-
className="h-9 px-4 flex items-center gap-1.5 rounded-xl bg-gradient-to-r from-destructive to-destructive/80 text-destructive-foreground text-sm font-semibold hover:shadow-md transition-all flex-shrink-0 press-scale"
|
|
521
|
-
>
|
|
522
|
-
<Plus className="w-4 h-4" />
|
|
523
|
-
<span className="hidden sm:inline">New Assignment</span>
|
|
524
|
-
</button>
|
|
525
|
-
</div>
|
|
526
|
-
|
|
527
|
-
{/* Active filter pills */}
|
|
528
|
-
{filterPills.length > 0 && (
|
|
529
|
-
<div className="flex items-center gap-1.5 flex-wrap">
|
|
530
|
-
{filterPills.map(pill => (
|
|
531
|
-
<span key={pill.key} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-muted text-xs text-foreground">
|
|
532
|
-
{pill.label}
|
|
533
|
-
<button onClick={pill.onRemove} className="ml-0.5 hover:text-destructive p-0.5">
|
|
534
|
-
<X className="w-3 h-3" />
|
|
535
|
-
</button>
|
|
536
|
-
</span>
|
|
537
|
-
))}
|
|
538
|
-
{filterPills.length > 1 && (
|
|
539
|
-
<button onClick={() => { setSearch(''); handleClearFilters(); }} className="text-xs text-muted-foreground hover:text-foreground px-1">
|
|
540
|
-
Clear all
|
|
541
|
-
</button>
|
|
542
|
-
)}
|
|
543
|
-
</div>
|
|
544
|
-
)}
|
|
545
|
-
</div>
|
|
546
|
-
|
|
547
|
-
{/* Assignment list */}
|
|
548
|
-
<div className="flex-1 overflow-y-auto px-4 md:px-6 pb-6 space-y-2">
|
|
549
|
-
{isLoading ? (
|
|
550
|
-
<div className="space-y-3">
|
|
551
|
-
{[...Array(5)].map((_, i) => (
|
|
552
|
-
<div key={i} className="h-20 bg-muted rounded-xl animate-pulse" />
|
|
553
|
-
))}
|
|
554
|
-
</div>
|
|
555
|
-
) : assignments.length === 0 ? (
|
|
556
|
-
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
557
|
-
<ClipboardList className="w-10 h-10 text-muted-foreground/30 mb-3" />
|
|
558
|
-
<p className="text-sm text-muted-foreground">
|
|
559
|
-
{search ? `No assignments match "${search}"` : 'No assignments found'}
|
|
560
|
-
</p>
|
|
561
|
-
{(search || activeFilterCount > 0) && (
|
|
562
|
-
<button
|
|
563
|
-
onClick={() => { setSearch(''); handleClearFilters(); }}
|
|
564
|
-
className="mt-2 text-xs text-primary hover:underline"
|
|
565
|
-
>
|
|
566
|
-
Clear search & filters
|
|
567
|
-
</button>
|
|
568
|
-
)}
|
|
569
|
-
</div>
|
|
570
|
-
) : (
|
|
571
|
-
<>
|
|
572
|
-
{paginated.map(a => {
|
|
573
|
-
const statusColor = STATUS_COLORS[a.status] ?? '#94a3b8';
|
|
574
|
-
const priorityColor = PRIORITY_COLORS[a.priority] ?? '#94a3b8';
|
|
575
|
-
const actions = getActions(a);
|
|
576
|
-
const canOpenSubject = SUBJECT_TYPE_DRAWER[a.subject_type];
|
|
577
|
-
const isOverdue = a.due_at && new Date(a.due_at) < new Date() && !['completed', 'declined', 'cancelled'].includes(a.status);
|
|
578
|
-
|
|
579
|
-
return (
|
|
580
|
-
<div key={a.id} onClick={() => openDrawer('assignment', a.id)} className="bg-card border border-border rounded-xl p-4 hover:shadow-md transition-shadow cursor-pointer">
|
|
581
|
-
<div className="flex items-start gap-3">
|
|
582
|
-
<div className="flex-1 min-w-0">
|
|
583
|
-
<div className="flex items-center gap-2 mb-1">
|
|
584
|
-
<span
|
|
585
|
-
className="px-1.5 py-0.5 rounded text-xs font-semibold capitalize"
|
|
586
|
-
style={{ backgroundColor: statusColor + '18', color: statusColor }}
|
|
587
|
-
>
|
|
588
|
-
{a.status.replace(/_/g, ' ')}
|
|
589
|
-
</span>
|
|
590
|
-
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: priorityColor }} title={a.priority} />
|
|
591
|
-
<span className="text-xs text-muted-foreground capitalize">{a.priority}</span>
|
|
592
|
-
<span className="text-xs text-muted-foreground ml-auto">
|
|
593
|
-
{new Date(a.created_at).toLocaleDateString()}
|
|
594
|
-
</span>
|
|
595
|
-
</div>
|
|
596
|
-
<h4 className="text-base font-medium text-foreground truncate">{a.title}</h4>
|
|
597
|
-
{a.description && (
|
|
598
|
-
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-1">{a.description}</p>
|
|
599
|
-
)}
|
|
600
|
-
<div className="flex items-center gap-2 mt-2">
|
|
601
|
-
<span className="text-xs text-muted-foreground capitalize px-1.5 py-0.5 rounded bg-muted">
|
|
602
|
-
{a.assignment_type.replace(/_/g, ' ')}
|
|
603
|
-
</span>
|
|
604
|
-
{canOpenSubject && (
|
|
605
|
-
<button onClick={e => { e.stopPropagation(); openDrawer(canOpenSubject, a.subject_id); }} className="text-xs text-primary hover:underline">
|
|
606
|
-
View {a.subject_type.replace(/_/g, ' ')}
|
|
607
|
-
</button>
|
|
608
|
-
)}
|
|
609
|
-
{a.due_at && (
|
|
610
|
-
<span className={`text-xs ml-auto flex items-center gap-1 ${isOverdue ? 'text-destructive font-medium' : 'text-muted-foreground'}`}>
|
|
611
|
-
{isOverdue && <AlertOctagon className="w-3 h-3" />}
|
|
612
|
-
Due: {new Date(a.due_at).toLocaleDateString()}
|
|
613
|
-
</span>
|
|
614
|
-
)}
|
|
615
|
-
</div>
|
|
616
|
-
{a.context && (
|
|
617
|
-
<p className="text-xs text-muted-foreground/80 mt-1.5 italic line-clamp-2">{a.context}</p>
|
|
618
|
-
)}
|
|
619
|
-
</div>
|
|
620
|
-
</div>
|
|
621
|
-
|
|
622
|
-
{actions.length > 0 && (
|
|
623
|
-
<div className="flex items-center gap-1.5 mt-3 pt-3 border-t border-border">
|
|
624
|
-
{actions.map(act => (
|
|
625
|
-
<button
|
|
626
|
-
key={act.action}
|
|
627
|
-
onClick={e => { e.stopPropagation(); handleAction(act.action, a.id); }}
|
|
628
|
-
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
|
629
|
-
act.variant === 'destructive' ? 'bg-destructive/10 text-destructive hover:bg-destructive/20'
|
|
630
|
-
: act.variant === 'warning' ? 'text-warning hover:bg-warning/10'
|
|
631
|
-
: act.variant === 'muted' ? 'text-muted-foreground hover:bg-muted'
|
|
632
|
-
: 'bg-primary/10 text-primary hover:bg-primary/20'
|
|
633
|
-
}`}
|
|
634
|
-
>
|
|
635
|
-
{act.icon} {act.label}
|
|
636
|
-
</button>
|
|
637
|
-
))}
|
|
638
|
-
</div>
|
|
639
|
-
)}
|
|
640
|
-
</div>
|
|
641
|
-
);
|
|
642
|
-
})}
|
|
643
|
-
<PaginationBar page={page} pageSize={PAGE_SIZE} total={assignments.length} onPageChange={setPage} />
|
|
644
|
-
</>
|
|
645
|
-
)}
|
|
646
|
-
</div>
|
|
647
|
-
</>
|
|
648
|
-
);
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
// ── Sub-components ────────────────────────────────────────────────────────────
|
|
652
|
-
|
|
653
|
-
function FilterSection({
|
|
654
|
-
label, options, selected, onChange,
|
|
655
|
-
}: {
|
|
656
|
-
label: string; filterKey: string;
|
|
657
|
-
options: { value: string; label: string }[];
|
|
658
|
-
selected: string[];
|
|
659
|
-
onChange: (values: string[]) => void;
|
|
660
|
-
}) {
|
|
661
|
-
const [open, setOpen] = useState(true);
|
|
662
|
-
return (
|
|
663
|
-
<div className="rounded-lg">
|
|
664
|
-
<button
|
|
665
|
-
onClick={() => setOpen(!open)}
|
|
666
|
-
className="w-full flex items-center justify-between px-2 py-2 text-xs font-display font-semibold text-muted-foreground hover:text-foreground"
|
|
667
|
-
>
|
|
668
|
-
{label}
|
|
669
|
-
{selected.length > 0 && <span className="text-primary font-sans">{selected.length}</span>}
|
|
670
|
-
<ChevronDown className={`w-3 h-3 transition-transform ${open ? '' : '-rotate-90'}`} />
|
|
671
|
-
</button>
|
|
672
|
-
{open && (
|
|
673
|
-
<div className="space-y-0.5 pl-1 pb-1">
|
|
674
|
-
{options.map(opt => {
|
|
675
|
-
const checked = selected.includes(opt.value);
|
|
676
|
-
return (
|
|
677
|
-
<label key={opt.value} className="flex items-center gap-2 px-2 py-2 text-sm text-foreground hover:bg-muted/50 rounded-lg cursor-pointer min-h-[40px]">
|
|
678
|
-
<Checkbox checked={checked} onCheckedChange={() => onChange(checked ? selected.filter(v => v !== opt.value) : [...selected, opt.value])} className="w-4 h-4" />
|
|
679
|
-
{opt.label}
|
|
680
|
-
</label>
|
|
681
|
-
);
|
|
682
|
-
})}
|
|
683
|
-
</div>
|
|
684
|
-
)}
|
|
685
|
-
</div>
|
|
686
|
-
);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
function DateRangeSection({
|
|
690
|
-
label, icon, presets, selectedPreset, onPresetChange,
|
|
691
|
-
customFrom, customTo, onFromChange, onToChange,
|
|
692
|
-
}: {
|
|
693
|
-
label: string;
|
|
694
|
-
icon: React.ReactNode;
|
|
695
|
-
presets: { value: DatePreset; label: string }[];
|
|
696
|
-
selectedPreset: DatePreset;
|
|
697
|
-
onPresetChange: (p: DatePreset) => void;
|
|
698
|
-
customFrom: string;
|
|
699
|
-
customTo: string;
|
|
700
|
-
onFromChange: (v: string) => void;
|
|
701
|
-
onToChange: (v: string) => void;
|
|
702
|
-
}) {
|
|
703
|
-
const [open, setOpen] = useState(true);
|
|
704
|
-
return (
|
|
705
|
-
<div className="rounded-lg">
|
|
706
|
-
<button
|
|
707
|
-
onClick={() => setOpen(!open)}
|
|
708
|
-
className="w-full flex items-center justify-between px-2 py-2 text-xs font-display font-semibold text-muted-foreground hover:text-foreground"
|
|
709
|
-
>
|
|
710
|
-
<span className="flex items-center gap-1.5">{icon}{label}</span>
|
|
711
|
-
{selectedPreset && <span className="text-primary font-sans text-[10px]">1</span>}
|
|
712
|
-
<ChevronDown className={`w-3 h-3 transition-transform ${open ? '' : '-rotate-90'}`} />
|
|
713
|
-
</button>
|
|
714
|
-
{open && (
|
|
715
|
-
<div className="pl-1 pb-1 space-y-0.5">
|
|
716
|
-
{/* Clear option */}
|
|
717
|
-
{selectedPreset && (
|
|
718
|
-
<button
|
|
719
|
-
onClick={() => onPresetChange('')}
|
|
720
|
-
className="w-full text-left px-2 py-1.5 text-xs text-muted-foreground hover:text-foreground rounded-lg hover:bg-muted/50"
|
|
721
|
-
>
|
|
722
|
-
— Any date
|
|
723
|
-
</button>
|
|
724
|
-
)}
|
|
725
|
-
{presets.map(p => (
|
|
726
|
-
<button
|
|
727
|
-
key={p.value}
|
|
728
|
-
onClick={() => onPresetChange(selectedPreset === p.value ? '' : p.value)}
|
|
729
|
-
className={`w-full text-left px-2 py-2 text-sm rounded-lg transition-colors flex items-center gap-2 ${
|
|
730
|
-
selectedPreset === p.value ? 'bg-primary/10 text-primary font-medium' : 'text-foreground hover:bg-muted/50'
|
|
731
|
-
}`}
|
|
732
|
-
>
|
|
733
|
-
{selectedPreset === p.value && <span className="w-1.5 h-1.5 rounded-full bg-primary flex-shrink-0" />}
|
|
734
|
-
{p.label}
|
|
735
|
-
</button>
|
|
736
|
-
))}
|
|
737
|
-
{selectedPreset === 'custom' && (
|
|
738
|
-
<div className="px-2 pt-1 pb-2 space-y-2">
|
|
739
|
-
<div className="flex items-center gap-2">
|
|
740
|
-
<DatePicker value={customFrom} onChange={onFromChange} size="sm" placeholder="From" className="flex-1" />
|
|
741
|
-
<span className="text-xs text-muted-foreground">→</span>
|
|
742
|
-
<DatePicker value={customTo} onChange={onToChange} size="sm" placeholder="To" className="flex-1" />
|
|
743
|
-
</div>
|
|
744
|
-
</div>
|
|
745
|
-
)}
|
|
746
|
-
</div>
|
|
747
|
-
)}
|
|
748
|
-
</div>
|
|
749
|
-
);
|
|
750
|
-
}
|