@lego-box/shell 1.0.5 → 1.0.7
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/.krasrc +13 -0
- package/dist/emulator/lego-box-shell-1.0.7.tgz +0 -0
- package/package.json +6 -3
- package/postcss.config.js +6 -0
- package/src/auth/auth-store.ts +33 -0
- package/src/auth/auth.ts +176 -0
- package/src/components/ProtectedPage.tsx +48 -0
- package/src/config/env.node.ts +38 -0
- package/src/config/env.ts +105 -0
- package/src/context/AbilityContext.tsx +213 -0
- package/src/context/PiralInstanceContext.tsx +17 -0
- package/src/hooks/index.ts +11 -0
- package/src/hooks/useAuditLogs.ts +190 -0
- package/src/hooks/useDebounce.ts +34 -0
- package/src/hooks/usePermissionGuard.tsx +39 -0
- package/src/hooks/usePermissions.ts +190 -0
- package/src/hooks/useRoles.ts +233 -0
- package/src/hooks/useTickets.ts +214 -0
- package/src/hooks/useUserLogins.ts +39 -0
- package/src/hooks/useUsers.ts +252 -0
- package/src/index.html +16 -0
- package/src/index.tsx +296 -0
- package/src/layout.tsx +246 -0
- package/src/migrations/config.ts +62 -0
- package/src/migrations/dev-migrations.ts +75 -0
- package/src/migrations/index.ts +13 -0
- package/src/migrations/run-migrations.ts +187 -0
- package/src/migrations/runner.ts +925 -0
- package/src/migrations/types.ts +207 -0
- package/src/migrations/utils.ts +264 -0
- package/src/pages/AuditLogsPage.tsx +378 -0
- package/src/pages/ContactSupportPage.tsx +610 -0
- package/src/pages/LandingPage.tsx +221 -0
- package/src/pages/LoginPage.tsx +217 -0
- package/src/pages/MigrationsPage.tsx +1364 -0
- package/src/pages/ProfilePage.tsx +335 -0
- package/src/pages/SettingsPage.tsx +101 -0
- package/src/pages/SystemHealthCheckPage.tsx +144 -0
- package/src/pages/UserManagementPage.tsx +1010 -0
- package/src/piral/api.ts +39 -0
- package/src/piral/auth-casl.ts +56 -0
- package/src/piral/menu.ts +102 -0
- package/src/piral/piral.json +4 -0
- package/src/services/telemetry.ts +84 -0
- package/src/styles/globals.css +1351 -0
- package/src/utils/auditLogger.ts +68 -0
- package/tailwind.config.js +86 -0
- package/webpack.config.js +89 -0
- package/dist/emulator/lego-box-shell-1.0.5.tgz +0 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
QuickActionCard,
|
|
4
|
+
TicketStatsCard,
|
|
5
|
+
TicketFormDialog,
|
|
6
|
+
TicketFormData,
|
|
7
|
+
DataTable,
|
|
8
|
+
DataTableColumn,
|
|
9
|
+
SearchInput,
|
|
10
|
+
FilterChip,
|
|
11
|
+
Button,
|
|
12
|
+
DeleteConfirmationDialog,
|
|
13
|
+
Pagination,
|
|
14
|
+
Badge,
|
|
15
|
+
} from '@lego-box/ui-kit';
|
|
16
|
+
import { useTickets } from '../hooks/useTickets';
|
|
17
|
+
import { useDebounce } from '../hooks';
|
|
18
|
+
import { Ticket, TicketCategory, TicketPriority, TicketStatus } from '../types';
|
|
19
|
+
import {
|
|
20
|
+
Bug,
|
|
21
|
+
Lightbulb,
|
|
22
|
+
MessageCircle,
|
|
23
|
+
Ticket as TicketIcon,
|
|
24
|
+
Timer,
|
|
25
|
+
CircleCheck,
|
|
26
|
+
Clock,
|
|
27
|
+
TrendingUp,
|
|
28
|
+
AlertCircle,
|
|
29
|
+
Check,
|
|
30
|
+
Zap,
|
|
31
|
+
Plus,
|
|
32
|
+
Search,
|
|
33
|
+
Loader2
|
|
34
|
+
} from 'lucide-react';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get badge variant based on ticket status
|
|
38
|
+
*/
|
|
39
|
+
function getStatusBadgeVariant(status: TicketStatus) {
|
|
40
|
+
switch (status) {
|
|
41
|
+
case 'open':
|
|
42
|
+
return 'default';
|
|
43
|
+
case 'in_progress':
|
|
44
|
+
return 'warning';
|
|
45
|
+
case 'resolved':
|
|
46
|
+
return 'success';
|
|
47
|
+
case 'closed':
|
|
48
|
+
return 'secondary';
|
|
49
|
+
default:
|
|
50
|
+
return 'default';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get badge variant based on ticket priority
|
|
56
|
+
*/
|
|
57
|
+
function getPriorityBadgeVariant(priority: TicketPriority) {
|
|
58
|
+
switch (priority) {
|
|
59
|
+
case 'high':
|
|
60
|
+
return 'destructive';
|
|
61
|
+
case 'medium':
|
|
62
|
+
return 'warning';
|
|
63
|
+
case 'low':
|
|
64
|
+
return 'secondary';
|
|
65
|
+
default:
|
|
66
|
+
return 'default';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get badge variant based on ticket category
|
|
72
|
+
*/
|
|
73
|
+
function getCategoryBadgeVariant(category: TicketCategory) {
|
|
74
|
+
switch (category) {
|
|
75
|
+
case 'bug':
|
|
76
|
+
return 'destructive';
|
|
77
|
+
case 'feature':
|
|
78
|
+
return 'info';
|
|
79
|
+
case 'inquiry':
|
|
80
|
+
return 'secondary';
|
|
81
|
+
default:
|
|
82
|
+
return 'default';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Format date to human-readable format
|
|
88
|
+
*/
|
|
89
|
+
function formatDate(dateString: string): string {
|
|
90
|
+
const date = new Date(dateString);
|
|
91
|
+
return date.toLocaleDateString('en-US', {
|
|
92
|
+
month: 'short',
|
|
93
|
+
day: 'numeric',
|
|
94
|
+
year: 'numeric',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Contact Support Page
|
|
100
|
+
*
|
|
101
|
+
* Features:
|
|
102
|
+
* - Quick action cards for creating tickets
|
|
103
|
+
* - Stats overview cards
|
|
104
|
+
* - Search and filter functionality
|
|
105
|
+
* - Data table with ticket information
|
|
106
|
+
* - CRUD operations with shared PocketBase
|
|
107
|
+
*/
|
|
108
|
+
export function ContactSupportPage() {
|
|
109
|
+
// Search and filter state
|
|
110
|
+
const [searchQuery, setSearchQuery] = React.useState('');
|
|
111
|
+
const [statusFilter, setStatusFilter] = React.useState<TicketStatus | 'all'>('all');
|
|
112
|
+
const [categoryFilter, setCategoryFilter] = React.useState<TicketCategory | 'all'>('all');
|
|
113
|
+
const [priorityFilter, setPriorityFilter] = React.useState<TicketPriority | 'all'>('all');
|
|
114
|
+
const [page, setPage] = React.useState(1);
|
|
115
|
+
const [perPage, setPerPage] = React.useState(25);
|
|
116
|
+
|
|
117
|
+
// Dialog state
|
|
118
|
+
const [ticketFormOpen, setTicketFormOpen] = React.useState(false);
|
|
119
|
+
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
|
|
120
|
+
const [selectedTicket, setSelectedTicket] = React.useState<Ticket | null>(null);
|
|
121
|
+
const [initialCategory, setInitialCategory] = React.useState<TicketCategory | undefined>(undefined);
|
|
122
|
+
const [formLoading, setFormLoading] = React.useState(false);
|
|
123
|
+
|
|
124
|
+
// Debounced search query
|
|
125
|
+
const debouncedSearchQuery = useDebounce(searchQuery, 500);
|
|
126
|
+
|
|
127
|
+
// Fetch tickets with filters
|
|
128
|
+
const {
|
|
129
|
+
tickets,
|
|
130
|
+
loading,
|
|
131
|
+
error,
|
|
132
|
+
pagination,
|
|
133
|
+
refetch,
|
|
134
|
+
createTicket,
|
|
135
|
+
updateTicket,
|
|
136
|
+
deleteTicket,
|
|
137
|
+
} = useTickets({
|
|
138
|
+
page,
|
|
139
|
+
perPage,
|
|
140
|
+
searchQuery: debouncedSearchQuery,
|
|
141
|
+
statusFilter,
|
|
142
|
+
categoryFilter,
|
|
143
|
+
priorityFilter,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Calculate stats
|
|
147
|
+
const stats = React.useMemo(() => {
|
|
148
|
+
const total = pagination?.totalItems || 0;
|
|
149
|
+
const open = tickets.filter((t) => t.status === 'open').length;
|
|
150
|
+
const resolved = tickets.filter((t) => t.status === 'resolved').length;
|
|
151
|
+
const inProgress = tickets.filter((t) => t.status === 'in_progress').length;
|
|
152
|
+
|
|
153
|
+
return { total, open, resolved, inProgress };
|
|
154
|
+
}, [tickets, pagination?.totalItems]);
|
|
155
|
+
|
|
156
|
+
// Handle quick action click
|
|
157
|
+
const handleQuickActionClick = (category: TicketCategory) => {
|
|
158
|
+
setInitialCategory(category);
|
|
159
|
+
setSelectedTicket(null);
|
|
160
|
+
setTicketFormOpen(true);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Handle create ticket
|
|
164
|
+
const handleCreateTicket = async (formData: TicketFormData) => {
|
|
165
|
+
setFormLoading(true);
|
|
166
|
+
try {
|
|
167
|
+
await createTicket({
|
|
168
|
+
title: formData.title,
|
|
169
|
+
description: formData.description,
|
|
170
|
+
category: formData.category,
|
|
171
|
+
priority: formData.priority,
|
|
172
|
+
status: formData.status,
|
|
173
|
+
});
|
|
174
|
+
setTicketFormOpen(false);
|
|
175
|
+
refetch();
|
|
176
|
+
} catch (err: any) {
|
|
177
|
+
throw err;
|
|
178
|
+
} finally {
|
|
179
|
+
setFormLoading(false);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// Handle update ticket
|
|
184
|
+
const handleUpdateTicket = async (formData: TicketFormData) => {
|
|
185
|
+
if (!selectedTicket) return;
|
|
186
|
+
setFormLoading(true);
|
|
187
|
+
try {
|
|
188
|
+
await updateTicket(selectedTicket.id, {
|
|
189
|
+
title: formData.title,
|
|
190
|
+
description: formData.description,
|
|
191
|
+
category: formData.category,
|
|
192
|
+
priority: formData.priority,
|
|
193
|
+
status: formData.status,
|
|
194
|
+
});
|
|
195
|
+
setTicketFormOpen(false);
|
|
196
|
+
setSelectedTicket(null);
|
|
197
|
+
refetch();
|
|
198
|
+
} catch (err: any) {
|
|
199
|
+
throw err;
|
|
200
|
+
} finally {
|
|
201
|
+
setFormLoading(false);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Handle delete ticket
|
|
206
|
+
const handleDeleteTicket = async () => {
|
|
207
|
+
if (!selectedTicket) return;
|
|
208
|
+
setFormLoading(true);
|
|
209
|
+
try {
|
|
210
|
+
await deleteTicket(selectedTicket.id);
|
|
211
|
+
setDeleteDialogOpen(false);
|
|
212
|
+
setSelectedTicket(null);
|
|
213
|
+
refetch();
|
|
214
|
+
} catch (err: any) {
|
|
215
|
+
throw err;
|
|
216
|
+
} finally {
|
|
217
|
+
setFormLoading(false);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// Handle edit click
|
|
222
|
+
const handleEditClick = (ticket: Ticket) => {
|
|
223
|
+
setSelectedTicket(ticket);
|
|
224
|
+
setInitialCategory(undefined);
|
|
225
|
+
setTicketFormOpen(true);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// Handle delete click
|
|
229
|
+
const handleDeleteClick = (ticket: Ticket) => {
|
|
230
|
+
setSelectedTicket(ticket);
|
|
231
|
+
setDeleteDialogOpen(true);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// Ticket table columns - matching Pencil design
|
|
235
|
+
const ticketColumns: DataTableColumn<Ticket>[] = [
|
|
236
|
+
{
|
|
237
|
+
key: 'id',
|
|
238
|
+
header: 'ID',
|
|
239
|
+
width: '8%',
|
|
240
|
+
cell: (ticket: Ticket) => (
|
|
241
|
+
<span className="text-xs font-medium text-blue-600">#{ticket.id.slice(-6).toUpperCase()}</span>
|
|
242
|
+
),
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
key: 'subject',
|
|
246
|
+
header: 'Subject',
|
|
247
|
+
width: '32%',
|
|
248
|
+
cell: (ticket: Ticket) => (
|
|
249
|
+
<div className="flex items-center gap-2">
|
|
250
|
+
{ticket.category === 'bug' && <Bug className="w-4 h-4 text-red-500" />}
|
|
251
|
+
{ticket.category === 'feature' && <Lightbulb className="w-4 h-4 text-blue-500" />}
|
|
252
|
+
{ticket.category === 'inquiry' && <MessageCircle className="w-4 h-4 text-purple-500" />}
|
|
253
|
+
<span className="text-sm text-foreground truncate">{ticket.title}</span>
|
|
254
|
+
</div>
|
|
255
|
+
),
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
key: 'category',
|
|
259
|
+
header: 'Category',
|
|
260
|
+
width: '12%',
|
|
261
|
+
cell: (ticket: Ticket) => (
|
|
262
|
+
<Badge variant={getCategoryBadgeVariant(ticket.category)}>
|
|
263
|
+
{ticket.category.charAt(0).toUpperCase() + ticket.category.slice(1)}
|
|
264
|
+
</Badge>
|
|
265
|
+
),
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
key: 'status',
|
|
269
|
+
header: 'Status',
|
|
270
|
+
width: '12%',
|
|
271
|
+
cell: (ticket: Ticket) => (
|
|
272
|
+
<Badge variant={getStatusBadgeVariant(ticket.status)}>
|
|
273
|
+
{ticket.status.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
|
|
274
|
+
</Badge>
|
|
275
|
+
),
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
key: 'priority',
|
|
279
|
+
header: 'Priority',
|
|
280
|
+
width: '10%',
|
|
281
|
+
cell: (ticket: Ticket) => (
|
|
282
|
+
<Badge variant={getPriorityBadgeVariant(ticket.priority)}>
|
|
283
|
+
{ticket.priority.charAt(0).toUpperCase() + ticket.priority.slice(1)}
|
|
284
|
+
</Badge>
|
|
285
|
+
),
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
key: 'created',
|
|
289
|
+
header: 'Created',
|
|
290
|
+
width: '14%',
|
|
291
|
+
cell: (ticket: Ticket) => (
|
|
292
|
+
<span className="text-sm text-muted-foreground">{formatDate(ticket.created)}</span>
|
|
293
|
+
),
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
key: 'actions',
|
|
297
|
+
header: 'Actions',
|
|
298
|
+
width: '12%',
|
|
299
|
+
cell: (ticket: Ticket) => (
|
|
300
|
+
<div className="flex items-center gap-3">
|
|
301
|
+
<button
|
|
302
|
+
onClick={() => handleEditClick(ticket)}
|
|
303
|
+
className="text-sm font-medium text-blue-600 hover:text-blue-700 transition-colors"
|
|
304
|
+
>
|
|
305
|
+
Edit
|
|
306
|
+
</button>
|
|
307
|
+
<button
|
|
308
|
+
onClick={() => handleDeleteClick(ticket)}
|
|
309
|
+
className="text-sm font-medium text-red-500 hover:text-red-600 transition-colors"
|
|
310
|
+
>
|
|
311
|
+
Delete
|
|
312
|
+
</button>
|
|
313
|
+
</div>
|
|
314
|
+
),
|
|
315
|
+
},
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<div className="min-h-screen bg-background">
|
|
320
|
+
<div className="p-6 space-y-6">
|
|
321
|
+
{/* Page Header */}
|
|
322
|
+
<div className="flex items-center justify-between">
|
|
323
|
+
<div>
|
|
324
|
+
<h1 className="text-2xl font-bold text-foreground">Contact Support</h1>
|
|
325
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
326
|
+
Get help with bug reports, feature requests, and general inquiries
|
|
327
|
+
</p>
|
|
328
|
+
</div>
|
|
329
|
+
<div className="flex items-center gap-3">
|
|
330
|
+
<div className="relative">
|
|
331
|
+
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
|
332
|
+
<input
|
|
333
|
+
type="text"
|
|
334
|
+
placeholder="Search Tickets"
|
|
335
|
+
value={searchQuery}
|
|
336
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
337
|
+
className="h-10 pl-10 pr-4 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 w-48"
|
|
338
|
+
/>
|
|
339
|
+
</div>
|
|
340
|
+
<Button
|
|
341
|
+
className="gap-2 bg-blue-600 hover:bg-blue-700"
|
|
342
|
+
onClick={() => {
|
|
343
|
+
setInitialCategory(undefined);
|
|
344
|
+
setSelectedTicket(null);
|
|
345
|
+
setTicketFormOpen(true);
|
|
346
|
+
}}
|
|
347
|
+
>
|
|
348
|
+
<Plus className="w-4 h-4" />
|
|
349
|
+
New Ticket
|
|
350
|
+
</Button>
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
|
|
354
|
+
{/* Quick Actions - Horizontal layout matching Pencil */}
|
|
355
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
356
|
+
<QuickActionCard
|
|
357
|
+
title="Report a Bug"
|
|
358
|
+
description="Found an issue? Let us know."
|
|
359
|
+
icon={<Bug className="w-full h-full" />}
|
|
360
|
+
variant="bug"
|
|
361
|
+
onClick={() => handleQuickActionClick('bug')}
|
|
362
|
+
/>
|
|
363
|
+
<QuickActionCard
|
|
364
|
+
title="Request a Feature"
|
|
365
|
+
description="Share your ideas with us."
|
|
366
|
+
icon={<Lightbulb className="w-full h-full" />}
|
|
367
|
+
variant="feature"
|
|
368
|
+
onClick={() => handleQuickActionClick('feature')}
|
|
369
|
+
/>
|
|
370
|
+
<QuickActionCard
|
|
371
|
+
title="General Inquiry"
|
|
372
|
+
description="Have questions? We're here."
|
|
373
|
+
icon={<MessageCircle className="w-full h-full" />}
|
|
374
|
+
variant="inquiry"
|
|
375
|
+
onClick={() => handleQuickActionClick('inquiry')}
|
|
376
|
+
/>
|
|
377
|
+
</div>
|
|
378
|
+
|
|
379
|
+
{/* Stats Grid - Compact horizontal layout matching Pencil */}
|
|
380
|
+
<div className="grid" style={{ gridTemplateColumns: 'repeat(4, 1fr)', gap: '1rem' }}>
|
|
381
|
+
<TicketStatsCard
|
|
382
|
+
value={stats.total}
|
|
383
|
+
label="Total Tickets"
|
|
384
|
+
icon={<TicketIcon className="w-full h-full" />}
|
|
385
|
+
iconBgColor="bg-blue-50"
|
|
386
|
+
iconColor="text-blue-600"
|
|
387
|
+
badge={{
|
|
388
|
+
icon: <TrendingUp className="w-3 h-3" />,
|
|
389
|
+
text: '+8%',
|
|
390
|
+
bgColor: 'bg-green-50',
|
|
391
|
+
textColor: 'text-green-600',
|
|
392
|
+
}}
|
|
393
|
+
/>
|
|
394
|
+
<TicketStatsCard
|
|
395
|
+
value={stats.open}
|
|
396
|
+
label="Open Tickets"
|
|
397
|
+
icon={<Timer className="w-full h-full" />}
|
|
398
|
+
iconBgColor="bg-amber-50"
|
|
399
|
+
iconColor="text-amber-600"
|
|
400
|
+
badge={{
|
|
401
|
+
icon: <AlertCircle className="w-3 h-3" />,
|
|
402
|
+
text: `${stats.inProgress} in progress`,
|
|
403
|
+
bgColor: 'bg-amber-50',
|
|
404
|
+
textColor: 'text-amber-600',
|
|
405
|
+
}}
|
|
406
|
+
/>
|
|
407
|
+
<TicketStatsCard
|
|
408
|
+
value={stats.resolved}
|
|
409
|
+
label="Resolved"
|
|
410
|
+
icon={<CircleCheck className="w-full h-full" />}
|
|
411
|
+
iconBgColor="bg-green-50"
|
|
412
|
+
iconColor="text-green-600"
|
|
413
|
+
badge={{
|
|
414
|
+
icon: <Check className="w-3 h-3" />,
|
|
415
|
+
text: '94% rate',
|
|
416
|
+
bgColor: 'bg-green-50',
|
|
417
|
+
textColor: 'text-green-600',
|
|
418
|
+
}}
|
|
419
|
+
/>
|
|
420
|
+
<TicketStatsCard
|
|
421
|
+
value="4h 32m"
|
|
422
|
+
label="Avg Response Time"
|
|
423
|
+
icon={<Clock className="w-full h-full" />}
|
|
424
|
+
iconBgColor="bg-purple-50"
|
|
425
|
+
iconColor="text-purple-600"
|
|
426
|
+
badge={{
|
|
427
|
+
icon: <Zap className="w-3 h-3" />,
|
|
428
|
+
text: 'Fast',
|
|
429
|
+
bgColor: 'bg-purple-50',
|
|
430
|
+
textColor: 'text-purple-600',
|
|
431
|
+
}}
|
|
432
|
+
/>
|
|
433
|
+
</div>
|
|
434
|
+
|
|
435
|
+
{/* Filters and Search - Dropdown style matching Pencil */}
|
|
436
|
+
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
|
437
|
+
<div className="flex flex-wrap gap-3">
|
|
438
|
+
{/* Status Filter Dropdown */}
|
|
439
|
+
<div>
|
|
440
|
+
<label className="text-xs text-muted-foreground mb-1 block">Status:</label>
|
|
441
|
+
<div className="relative">
|
|
442
|
+
<select
|
|
443
|
+
value={statusFilter}
|
|
444
|
+
onChange={(e) => setStatusFilter(e.target.value as TicketStatus | 'all')}
|
|
445
|
+
className="h-9 px-3 pr-8 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 appearance-none cursor-pointer min-w-[120px] w-full"
|
|
446
|
+
>
|
|
447
|
+
<option value="all">All Tickets</option>
|
|
448
|
+
<option value="open">Open</option>
|
|
449
|
+
<option value="in_progress">In Progress</option>
|
|
450
|
+
<option value="resolved">Resolved</option>
|
|
451
|
+
<option value="closed">Closed</option>
|
|
452
|
+
</select>
|
|
453
|
+
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none">
|
|
454
|
+
<svg className="w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
455
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
456
|
+
</svg>
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
|
|
461
|
+
{/* Category Filter Dropdown */}
|
|
462
|
+
<div>
|
|
463
|
+
<label className="text-xs text-muted-foreground mb-1 block">Category:</label>
|
|
464
|
+
<div className="relative">
|
|
465
|
+
<select
|
|
466
|
+
value={categoryFilter}
|
|
467
|
+
onChange={(e) => setCategoryFilter(e.target.value as TicketCategory | 'all')}
|
|
468
|
+
className="h-9 px-3 pr-8 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 appearance-none cursor-pointer min-w-[120px] w-full"
|
|
469
|
+
>
|
|
470
|
+
<option value="all">All Categories</option>
|
|
471
|
+
<option value="bug">Bug Report</option>
|
|
472
|
+
<option value="feature">Feature Request</option>
|
|
473
|
+
<option value="inquiry">General Inquiry</option>
|
|
474
|
+
</select>
|
|
475
|
+
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none">
|
|
476
|
+
<svg className="w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
477
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
478
|
+
</svg>
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
</div>
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
|
|
485
|
+
{/* Tickets Table */}
|
|
486
|
+
<div className="bg-card rounded-lg border border-border shadow-sm">
|
|
487
|
+
{loading ? (
|
|
488
|
+
<div className="flex items-center justify-center p-12">
|
|
489
|
+
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
|
490
|
+
<span className="ml-2 text-muted-foreground">Loading tickets...</span>
|
|
491
|
+
</div>
|
|
492
|
+
) : tickets.length === 0 ? (
|
|
493
|
+
<div className="flex flex-col items-center justify-center min-h-[360px] py-24 px-16 text-center">
|
|
494
|
+
<TicketIcon className="w-12 h-12 text-muted-foreground mb-6" />
|
|
495
|
+
<h3 className="text-lg font-medium text-foreground mb-3">No tickets found</h3>
|
|
496
|
+
<p className="text-sm text-muted-foreground mb-6 max-w-sm">
|
|
497
|
+
Create your first ticket to get started with support.
|
|
498
|
+
</p>
|
|
499
|
+
<Button
|
|
500
|
+
onClick={() => {
|
|
501
|
+
setInitialCategory(undefined);
|
|
502
|
+
setSelectedTicket(null);
|
|
503
|
+
setTicketFormOpen(true);
|
|
504
|
+
}}
|
|
505
|
+
>
|
|
506
|
+
<Plus className="w-4 h-4 mr-2" />
|
|
507
|
+
Create Ticket
|
|
508
|
+
</Button>
|
|
509
|
+
</div>
|
|
510
|
+
) : (
|
|
511
|
+
<>
|
|
512
|
+
<DataTable
|
|
513
|
+
data={tickets}
|
|
514
|
+
columns={ticketColumns}
|
|
515
|
+
keyExtractor={(ticket) => ticket.id}
|
|
516
|
+
variant="inline"
|
|
517
|
+
/>
|
|
518
|
+
|
|
519
|
+
{/* Pagination - Matching Pencil design */}
|
|
520
|
+
{pagination && (
|
|
521
|
+
<div className="p-4 border-t flex items-center justify-between">
|
|
522
|
+
<span className="text-sm text-muted-foreground">
|
|
523
|
+
Showing {((pagination.page - 1) * pagination.perPage) + 1}-{Math.min(pagination.page * pagination.perPage, pagination.totalItems)} of {pagination.totalItems} tickets
|
|
524
|
+
</span>
|
|
525
|
+
|
|
526
|
+
{pagination.totalPages > 1 && (
|
|
527
|
+
<div className="flex items-center gap-1">
|
|
528
|
+
{/* Page size selector */}
|
|
529
|
+
<select
|
|
530
|
+
value={perPage}
|
|
531
|
+
onChange={(e) => setPerPage(Number(e.target.value))}
|
|
532
|
+
className="h-8 px-2 rounded border border-border text-sm mr-4"
|
|
533
|
+
>
|
|
534
|
+
<option value={10}>10 / page</option>
|
|
535
|
+
<option value={25}>25 / page</option>
|
|
536
|
+
<option value={50}>50 / page</option>
|
|
537
|
+
</select>
|
|
538
|
+
|
|
539
|
+
{/* Page numbers */}
|
|
540
|
+
{Array.from({ length: Math.min(5, pagination.totalPages) }, (_, i) => {
|
|
541
|
+
const pageNum = i + 1;
|
|
542
|
+
return (
|
|
543
|
+
<button
|
|
544
|
+
key={pageNum}
|
|
545
|
+
onClick={() => setPage(pageNum)}
|
|
546
|
+
className={`w-8 h-8 rounded text-sm font-medium transition-colors ${
|
|
547
|
+
page === pageNum
|
|
548
|
+
? 'bg-blue-600 text-white'
|
|
549
|
+
: 'text-muted-foreground hover:bg-muted'
|
|
550
|
+
}`}
|
|
551
|
+
>
|
|
552
|
+
{pageNum}
|
|
553
|
+
</button>
|
|
554
|
+
);
|
|
555
|
+
})}
|
|
556
|
+
|
|
557
|
+
{pagination.totalPages > 5 && (
|
|
558
|
+
<>
|
|
559
|
+
<span className="text-muted-foreground px-1">...</span>
|
|
560
|
+
<button
|
|
561
|
+
onClick={() => setPage(pagination.totalPages)}
|
|
562
|
+
className={`w-8 h-8 rounded text-sm font-medium transition-colors ${
|
|
563
|
+
page === pagination.totalPages
|
|
564
|
+
? 'bg-blue-600 text-white'
|
|
565
|
+
: 'text-muted-foreground hover:bg-muted'
|
|
566
|
+
}`}
|
|
567
|
+
>
|
|
568
|
+
{pagination.totalPages}
|
|
569
|
+
</button>
|
|
570
|
+
</>
|
|
571
|
+
)}
|
|
572
|
+
</div>
|
|
573
|
+
)}
|
|
574
|
+
</div>
|
|
575
|
+
)}
|
|
576
|
+
</>
|
|
577
|
+
)}
|
|
578
|
+
</div>
|
|
579
|
+
|
|
580
|
+
{/* Ticket Form Dialog */}
|
|
581
|
+
<TicketFormDialog
|
|
582
|
+
isOpen={ticketFormOpen}
|
|
583
|
+
onClose={() => {
|
|
584
|
+
setTicketFormOpen(false);
|
|
585
|
+
setSelectedTicket(null);
|
|
586
|
+
setInitialCategory(undefined);
|
|
587
|
+
}}
|
|
588
|
+
onSubmit={selectedTicket ? handleUpdateTicket : handleCreateTicket}
|
|
589
|
+
ticket={selectedTicket}
|
|
590
|
+
loading={formLoading}
|
|
591
|
+
initialCategory={initialCategory}
|
|
592
|
+
/>
|
|
593
|
+
|
|
594
|
+
{/* Delete Confirmation Dialog */}
|
|
595
|
+
<DeleteConfirmationDialog
|
|
596
|
+
isOpen={deleteDialogOpen}
|
|
597
|
+
onClose={() => {
|
|
598
|
+
setDeleteDialogOpen(false);
|
|
599
|
+
setSelectedTicket(null);
|
|
600
|
+
}}
|
|
601
|
+
onConfirm={handleDeleteTicket}
|
|
602
|
+
title="Delete Ticket"
|
|
603
|
+
description={`Are you sure you want to delete the ticket "${selectedTicket?.title}"? This action cannot be undone.`}
|
|
604
|
+
itemName={selectedTicket?.title || ''}
|
|
605
|
+
loading={formLoading}
|
|
606
|
+
/>
|
|
607
|
+
</div>
|
|
608
|
+
</div>
|
|
609
|
+
);
|
|
610
|
+
}
|