@gravito/zenith 0.1.0-beta.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 (63) hide show
  1. package/ARCHITECTURE.md +88 -0
  2. package/BATCH_OPERATIONS_IMPLEMENTATION.md +159 -0
  3. package/DEMO.md +156 -0
  4. package/DEPLOYMENT.md +157 -0
  5. package/DOCS_INTERNAL.md +73 -0
  6. package/Dockerfile +46 -0
  7. package/Dockerfile.demo-worker +29 -0
  8. package/EVOLUTION_BLUEPRINT.md +112 -0
  9. package/JOBINSPECTOR_SCROLL_FIX.md +152 -0
  10. package/PULSE_IMPLEMENTATION_PLAN.md +111 -0
  11. package/QUICK_TEST_GUIDE.md +72 -0
  12. package/README.md +33 -0
  13. package/ROADMAP.md +85 -0
  14. package/TESTING_BATCH_OPERATIONS.md +252 -0
  15. package/bin/flux-console.ts +2 -0
  16. package/dist/bin.js +108196 -0
  17. package/dist/client/assets/index-DGYEwTDL.css +1 -0
  18. package/dist/client/assets/index-oyTdySX0.js +421 -0
  19. package/dist/client/index.html +13 -0
  20. package/dist/server/index.js +108191 -0
  21. package/docker-compose.yml +40 -0
  22. package/docs/integrations/LARAVEL.md +207 -0
  23. package/package.json +50 -0
  24. package/postcss.config.js +6 -0
  25. package/scripts/flood-logs.ts +21 -0
  26. package/scripts/seed.ts +213 -0
  27. package/scripts/verify-throttle.ts +45 -0
  28. package/scripts/worker.ts +123 -0
  29. package/src/bin.ts +6 -0
  30. package/src/client/App.tsx +70 -0
  31. package/src/client/Layout.tsx +644 -0
  32. package/src/client/Sidebar.tsx +102 -0
  33. package/src/client/ThroughputChart.tsx +135 -0
  34. package/src/client/WorkerStatus.tsx +170 -0
  35. package/src/client/components/ConfirmDialog.tsx +103 -0
  36. package/src/client/components/JobInspector.tsx +524 -0
  37. package/src/client/components/LogArchiveModal.tsx +383 -0
  38. package/src/client/components/NotificationBell.tsx +203 -0
  39. package/src/client/components/Toaster.tsx +80 -0
  40. package/src/client/components/UserProfileDropdown.tsx +177 -0
  41. package/src/client/contexts/AuthContext.tsx +93 -0
  42. package/src/client/contexts/NotificationContext.tsx +103 -0
  43. package/src/client/index.css +174 -0
  44. package/src/client/index.html +12 -0
  45. package/src/client/main.tsx +15 -0
  46. package/src/client/pages/LoginPage.tsx +153 -0
  47. package/src/client/pages/MetricsPage.tsx +408 -0
  48. package/src/client/pages/OverviewPage.tsx +511 -0
  49. package/src/client/pages/QueuesPage.tsx +372 -0
  50. package/src/client/pages/SchedulesPage.tsx +531 -0
  51. package/src/client/pages/SettingsPage.tsx +449 -0
  52. package/src/client/pages/WorkersPage.tsx +316 -0
  53. package/src/client/pages/index.ts +7 -0
  54. package/src/client/utils.ts +6 -0
  55. package/src/server/index.ts +556 -0
  56. package/src/server/middleware/auth.ts +127 -0
  57. package/src/server/services/AlertService.ts +160 -0
  58. package/src/server/services/QueueService.ts +828 -0
  59. package/tailwind.config.js +73 -0
  60. package/tests/placeholder.test.ts +7 -0
  61. package/tsconfig.json +38 -0
  62. package/tsconfig.node.json +12 -0
  63. package/vite.config.ts +27 -0
@@ -0,0 +1,524 @@
1
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
2
+ import { motion } from 'framer-motion'
3
+ import { AlertCircle, ArrowRight, CheckCircle2, Clock, Search } from 'lucide-react'
4
+ import React from 'react'
5
+ import { createPortal } from 'react-dom'
6
+ import { cn } from '../utils'
7
+ import { ConfirmDialog } from './ConfirmDialog'
8
+
9
+ interface Job {
10
+ id: string
11
+ name?: string
12
+ data?: any
13
+ status?: string
14
+ timestamp?: number
15
+ scheduledAt?: string
16
+ error?: string
17
+ failedAt?: number
18
+ _raw?: string
19
+ _archived?: boolean
20
+ _status?: 'completed' | 'failed'
21
+ _archivedAt?: string
22
+ }
23
+
24
+ export interface JobInspectorProps {
25
+ queueName: string
26
+ onClose: () => void
27
+ }
28
+
29
+ export function JobInspector({ queueName, onClose }: JobInspectorProps) {
30
+ const [view, setView] = React.useState<'waiting' | 'delayed' | 'failed' | 'archive'>('waiting')
31
+ const [page, setPage] = React.useState(1)
32
+ const [selectedIndices, setSelectedIndices] = React.useState<Set<number>>(new Set())
33
+ const [totalCount, setTotalCount] = React.useState<number>(0)
34
+ const [isProcessing, setIsProcessing] = React.useState(false)
35
+ const [confirmDialog, setConfirmDialog] = React.useState<{
36
+ open: boolean
37
+ title: string
38
+ message: string
39
+ action: () => void
40
+ variant?: 'danger' | 'warning' | 'info'
41
+ } | null>(null)
42
+
43
+ const queryClient = useQueryClient()
44
+
45
+ const { isPending, error, data } = useQuery<{ jobs: Job[]; total?: number }>({
46
+ queryKey: ['jobs', queueName, view, page],
47
+ queryFn: () => {
48
+ const url =
49
+ view === 'archive'
50
+ ? `/api/queues/${queueName}/archive?page=${page}&limit=50`
51
+ : `/api/queues/${queueName}/jobs?type=${view}`
52
+ return fetch(url).then((res) => res.json())
53
+ },
54
+ })
55
+
56
+ // Fetch total count for non-archive views
57
+ React.useEffect(() => {
58
+ if (view !== 'archive') {
59
+ fetch(`/api/queues/${queueName}/jobs/count?type=${view}`)
60
+ .then((res) => res.json())
61
+ .then((data) => setTotalCount(data.count))
62
+ .catch(() => setTotalCount(0))
63
+ } else {
64
+ setTotalCount(data?.total || 0)
65
+ }
66
+ }, [queueName, view, data?.total])
67
+
68
+ // Reset selection when view changes
69
+ // biome-ignore lint/correctness/useExhaustiveDependencies: We want to reset when view changes
70
+ React.useEffect(() => {
71
+ setSelectedIndices(new Set())
72
+ setPage(1)
73
+ }, [view])
74
+
75
+ const toggleSelection = (index: number) => {
76
+ const next = new Set(selectedIndices)
77
+ if (next.has(index)) {
78
+ next.delete(index)
79
+ } else {
80
+ next.add(index)
81
+ }
82
+ setSelectedIndices(next)
83
+ }
84
+
85
+ const toggleSelectAll = React.useCallback(() => {
86
+ if (!data?.jobs) return
87
+ const availableCount = data.jobs.filter((j) => j._raw && !j._archived).length
88
+ if (selectedIndices.size === availableCount && availableCount > 0) {
89
+ setSelectedIndices(new Set())
90
+ } else {
91
+ const indices = new Set<number>()
92
+ data.jobs.forEach((j, i) => {
93
+ if (j._raw && !j._archived) indices.add(i)
94
+ })
95
+ setSelectedIndices(indices)
96
+ }
97
+ }, [data?.jobs, selectedIndices])
98
+
99
+ // Keyboard shortcuts
100
+ React.useEffect(() => {
101
+ const handleKeyDown = (e: KeyboardEvent) => {
102
+ if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
103
+ e.preventDefault()
104
+ toggleSelectAll()
105
+ }
106
+ if (e.key === 'Escape') {
107
+ if (confirmDialog?.open) {
108
+ setConfirmDialog(null)
109
+ } else if (selectedIndices.size > 0) {
110
+ setSelectedIndices(new Set())
111
+ } else {
112
+ onClose()
113
+ }
114
+ }
115
+ }
116
+
117
+ window.addEventListener('keydown', handleKeyDown)
118
+ return () => window.removeEventListener('keydown', handleKeyDown)
119
+ }, [selectedIndices, confirmDialog, toggleSelectAll, onClose])
120
+
121
+ // Lock body scroll when modal opens
122
+ React.useEffect(() => {
123
+ const originalOverflow = document.body.style.overflow
124
+ const originalPaddingRight = document.body.style.paddingRight
125
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
126
+
127
+ document.body.style.overflow = 'hidden'
128
+ document.body.style.paddingRight = `${scrollbarWidth}px`
129
+
130
+ return () => {
131
+ document.body.style.overflow = originalOverflow
132
+ document.body.style.paddingRight = originalPaddingRight
133
+ }
134
+ }, [])
135
+
136
+ const handleAction = async (action: 'delete' | 'retry', job: Job) => {
137
+ if (!job._raw) return
138
+ const endpoint = action === 'delete' ? 'delete' : 'retry'
139
+ const body: any = { raw: job._raw }
140
+ if (action === 'delete') body.type = view
141
+
142
+ await fetch(`/api/queues/${queueName}/jobs/${endpoint}`, {
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json' },
145
+ body: JSON.stringify(body),
146
+ })
147
+
148
+ queryClient.invalidateQueries({ queryKey: ['jobs', queueName] })
149
+ queryClient.invalidateQueries({ queryKey: ['queues'] })
150
+ }
151
+
152
+ const handleBulkAction = async (action: 'delete' | 'retry') => {
153
+ if (selectedIndices.size === 0 || !data?.jobs) return
154
+
155
+ const count = selectedIndices.size
156
+ setConfirmDialog({
157
+ open: true,
158
+ title: `${action === 'delete' ? 'Delete' : 'Retry'} ${count} Jobs?`,
159
+ message: `Are you sure you want to ${action} ${count} selected ${view} jobs in "${queueName}"?\n\nThis action cannot be undone.`,
160
+ variant: action === 'delete' ? 'danger' : 'warning',
161
+ action: async () => {
162
+ setIsProcessing(true)
163
+ try {
164
+ const endpoint = action === 'delete' ? 'bulk-delete' : 'bulk-retry'
165
+ const raws = Array.from(selectedIndices)
166
+ .map((i) => data?.jobs[i]?._raw)
167
+ .filter(Boolean) as string[]
168
+
169
+ await fetch(`/api/queues/${queueName}/jobs/${endpoint}`, {
170
+ method: 'POST',
171
+ headers: { 'Content-Type': 'application/json' },
172
+ body: JSON.stringify({ type: view, raws }),
173
+ })
174
+
175
+ setSelectedIndices(new Set())
176
+ queryClient.invalidateQueries({ queryKey: ['jobs', queueName] })
177
+ queryClient.invalidateQueries({ queryKey: ['queues'] })
178
+ setConfirmDialog(null)
179
+ } catch (err) {
180
+ console.error(`Failed to ${action} jobs:`, err)
181
+ } finally {
182
+ setIsProcessing(false)
183
+ }
184
+ },
185
+ })
186
+ }
187
+
188
+ const handleBulkActionAll = async (action: 'delete' | 'retry') => {
189
+ if (view === 'archive') return
190
+
191
+ setConfirmDialog({
192
+ open: true,
193
+ title: `${action === 'delete' ? 'Delete' : 'Retry'} ALL ${totalCount} Jobs?`,
194
+ message: `⚠️ WARNING: This will ${action} ALL ${totalCount} ${view} jobs in "${queueName}".\n\nThis is a destructive operation that cannot be undone.`,
195
+ variant: 'danger',
196
+ action: async () => {
197
+ setIsProcessing(true)
198
+ try {
199
+ const endpoint = action === 'delete' ? 'bulk-delete-all' : 'bulk-retry-all'
200
+ await fetch(`/api/queues/${queueName}/jobs/${endpoint}`, {
201
+ method: 'POST',
202
+ headers: { 'Content-Type': 'application/json' },
203
+ body: JSON.stringify({ type: view }),
204
+ })
205
+
206
+ setSelectedIndices(new Set())
207
+ queryClient.invalidateQueries({ queryKey: ['jobs', queueName] })
208
+ queryClient.invalidateQueries({ queryKey: ['queues'] })
209
+ setConfirmDialog(null)
210
+ } catch (err) {
211
+ console.error(`Failed to ${action} all jobs:`, err)
212
+ } finally {
213
+ setIsProcessing(false)
214
+ }
215
+ },
216
+ })
217
+ }
218
+
219
+ return createPortal(
220
+ <div className="fixed inset-0 z-[1001] flex items-center justify-end p-4 sm:p-6 outline-none pointer-events-none">
221
+ <motion.div
222
+ initial={{ opacity: 0 }}
223
+ animate={{ opacity: 1 }}
224
+ exit={{ opacity: 0 }}
225
+ className="absolute inset-0 bg-black/60 backdrop-blur-md cursor-default pointer-events-auto"
226
+ onClick={onClose}
227
+ />
228
+
229
+ <motion.div
230
+ initial={{ x: '100%', opacity: 0 }}
231
+ animate={{ x: 0, opacity: 1 }}
232
+ exit={{ x: '100%', opacity: 0 }}
233
+ transition={{ type: 'spring', damping: 25, stiffness: 200 }}
234
+ className="bg-card border border-border/50 h-[85vh] w-full max-w-2xl shadow-2xl flex flex-col overflow-hidden rounded-2xl relative z-[1002] pointer-events-auto"
235
+ onClick={(e) => e.stopPropagation()}
236
+ >
237
+ <div className="p-6 border-b flex justify-between items-center bg-muted/20 flex-shrink-0">
238
+ <div>
239
+ <h2 className="text-xl font-bold flex items-center gap-2">
240
+ <Search className="text-primary" size={20} />
241
+ Queue Insight: <span className="text-primary">{queueName}</span>
242
+ </h2>
243
+ <div className="flex items-center gap-4 mt-2">
244
+ {(['waiting', 'delayed', 'failed', 'archive'] as const).map((v) => (
245
+ <button
246
+ type="button"
247
+ key={v}
248
+ onClick={() => setView(v)}
249
+ className={cn(
250
+ 'text-[9px] font-black px-3 py-1 rounded-sm transition-all border shrink-0 uppercase tracking-widest',
251
+ view === v
252
+ ? v === 'failed'
253
+ ? 'bg-red-500 text-white border-red-500 shadow-lg shadow-red-500/20'
254
+ : v === 'delayed'
255
+ ? 'bg-amber-500 text-white border-amber-500 shadow-lg shadow-amber-500/20'
256
+ : v === 'archive'
257
+ ? 'bg-indigo-500 text-white border-indigo-500 shadow-lg shadow-indigo-500/20'
258
+ : 'bg-primary text-primary-foreground border-primary shadow-lg shadow-primary/20'
259
+ : 'bg-muted text-muted-foreground border-transparent hover:bg-muted/80'
260
+ )}
261
+ >
262
+ {v}
263
+ </button>
264
+ ))}
265
+ </div>
266
+ </div>
267
+ <button
268
+ type="button"
269
+ onClick={onClose}
270
+ className="w-10 h-10 rounded-full hover:bg-muted flex items-center justify-center transition-colors"
271
+ >
272
+
273
+ </button>
274
+ </div>
275
+
276
+ <div className="flex-1 overflow-y-auto bg-muted/5 min-h-0">
277
+ {isPending && (
278
+ <div className="p-12 text-center text-muted-foreground font-medium animate-pulse">
279
+ Loading jobs...
280
+ </div>
281
+ )}
282
+ {error && (
283
+ <div className="p-12 text-center text-red-500 font-bold">Error loading jobs</div>
284
+ )}
285
+
286
+ {data?.jobs && data.jobs.length > 0 && (
287
+ <>
288
+ <div className="px-6 py-3 border-b bg-muted/5 flex items-center gap-3">
289
+ <input
290
+ type="checkbox"
291
+ className="w-4 h-4 rounded border-border"
292
+ checked={
293
+ selectedIndices.size ===
294
+ data.jobs.filter((j) => j._raw && !j._archived).length &&
295
+ selectedIndices.size > 0
296
+ }
297
+ onChange={toggleSelectAll}
298
+ />
299
+ <span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">
300
+ Select All (Page)
301
+ </span>
302
+ {view !== 'archive' && totalCount > 0 && (
303
+ <span className="text-[9px] text-muted-foreground/60 ml-2">
304
+ {data.jobs.filter((j) => !j._archived).length} of {totalCount} total
305
+ </span>
306
+ )}
307
+ {selectedIndices.size > 0 && (
308
+ <div className="ml-auto flex items-center gap-2">
309
+ <span className="text-[10px] font-black uppercase text-primary mr-2">
310
+ {selectedIndices.size} items selected
311
+ </span>
312
+ <button
313
+ type="button"
314
+ onClick={() => handleBulkAction('delete')}
315
+ className="px-3 py-1 bg-red-500/10 text-red-500 rounded-md text-[10px] font-black uppercase hover:bg-red-500 hover:text-white transition-all"
316
+ >
317
+ Delete Selected
318
+ </button>
319
+ {(view === 'delayed' || view === 'failed') && (
320
+ <button
321
+ type="button"
322
+ onClick={() => handleBulkAction('retry')}
323
+ className="px-3 py-1 bg-primary/10 text-primary rounded-md text-[10px] font-black uppercase hover:bg-primary hover:text-primary-foreground transition-all"
324
+ >
325
+ Retry Selected
326
+ </button>
327
+ )}
328
+ </div>
329
+ )}
330
+ </div>
331
+ {view !== 'archive' && totalCount > data.jobs.filter((j) => !j._archived).length && (
332
+ <div className="px-6 py-3 border-b bg-amber-500/5 flex items-center justify-between">
333
+ <span className="text-xs font-bold text-amber-600 flex items-center gap-2">
334
+ <AlertCircle size={14} />
335
+ Showing {data.jobs.filter((j) => !j._archived).length} of {totalCount} total{' '}
336
+ {view} jobs
337
+ </span>
338
+ <div className="flex items-center gap-2">
339
+ <button
340
+ type="button"
341
+ onClick={() => handleBulkActionAll('delete')}
342
+ className="px-3 py-1.5 bg-red-500/10 text-red-500 rounded-md text-[10px] font-black uppercase hover:bg-red-500 hover:text-white transition-all"
343
+ >
344
+ Delete All {totalCount}
345
+ </button>
346
+ {(view === 'delayed' || view === 'failed') && (
347
+ <button
348
+ type="button"
349
+ onClick={() => handleBulkActionAll('retry')}
350
+ className="px-3 py-1.5 bg-amber-500/10 text-amber-600 rounded-md text-[10px] font-black uppercase hover:bg-amber-500 hover:text-white transition-all"
351
+ >
352
+ Retry All {totalCount}
353
+ </button>
354
+ )}
355
+ </div>
356
+ </div>
357
+ )}
358
+ </>
359
+ )}
360
+
361
+ {data?.jobs && data.jobs.length === 0 && (
362
+ <div className="p-12 text-center text-muted-foreground flex flex-col items-center gap-4">
363
+ <div className="w-16 h-16 bg-muted/50 rounded-full flex items-center justify-center text-muted-foreground/30">
364
+ <CheckCircle2 size={32} />
365
+ </div>
366
+ <p className="text-lg font-bold">Clear Sky!</p>
367
+ <p className="text-sm opacity-60">No jobs found in this queue.</p>
368
+ </div>
369
+ )}
370
+ {data?.jobs && (
371
+ <div className="p-6 space-y-4">
372
+ {data.jobs.map((job, i) => (
373
+ <div
374
+ key={i}
375
+ className={cn(
376
+ 'bg-card border rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-all group border-border/50',
377
+ selectedIndices.has(i) && 'ring-2 ring-primary border-primary'
378
+ )}
379
+ >
380
+ <div className="p-4 border-b bg-muted/10 flex justify-between items-center text-[10px]">
381
+ <div className="flex items-center gap-3">
382
+ {job._raw && !job._archived && (
383
+ <input
384
+ type="checkbox"
385
+ className="w-4 h-4 rounded border-border"
386
+ checked={selectedIndices.has(i)}
387
+ onChange={() => toggleSelection(i)}
388
+ />
389
+ )}
390
+ <span className="font-mono bg-primary/10 text-primary px-2 py-1 rounded-md font-bold uppercase tracking-wider flex items-center gap-2">
391
+ ID: {job.id || 'N/A'}
392
+ {job._archived && (
393
+ <span
394
+ className={cn(
395
+ 'px-1.5 py-0.5 rounded text-[8px] border',
396
+ job._status === 'completed'
397
+ ? 'bg-green-500/20 text-green-500 border-green-500/20'
398
+ : 'bg-red-500/20 text-red-500 border-red-500/20'
399
+ )}
400
+ >
401
+ Archive: {job._status}
402
+ </span>
403
+ )}
404
+ </span>
405
+ </div>
406
+ <span className="text-muted-foreground font-semibold flex items-center gap-3">
407
+ {view === 'delayed' && job.scheduledAt && (
408
+ <span className="text-amber-500 flex items-center gap-1 font-bold">
409
+ <Clock size={12} /> {new Date(job.scheduledAt).toLocaleString()}
410
+ </span>
411
+ )}
412
+ {view === 'failed' && job.failedAt && (
413
+ <span className="text-red-500 flex items-center gap-1 font-bold">
414
+ <AlertCircle size={12} /> {new Date(job.failedAt).toLocaleString()}
415
+ </span>
416
+ )}
417
+ {job._archivedAt && (
418
+ <span className="text-indigo-400 flex items-center gap-1 font-bold">
419
+ <ArrowRight size={12} /> {new Date(job._archivedAt).toLocaleString()}
420
+ </span>
421
+ )}
422
+ {job.timestamp &&
423
+ !job._archivedAt &&
424
+ new Date(job.timestamp).toLocaleString()}
425
+ </span>
426
+ </div>
427
+ <button
428
+ type="button"
429
+ onClick={() => job._raw && !job._archived && toggleSelection(i)}
430
+ className="w-full text-left cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:ring-inset"
431
+ >
432
+ {job.error && (
433
+ <div className="p-4 bg-red-500/10 text-red-500 text-xs font-semibold border-b border-red-500/10 flex items-start gap-2">
434
+ <AlertCircle size={14} className="mt-0.5 shrink-0" />
435
+ <p>{job.error}</p>
436
+ </div>
437
+ )}
438
+ <pre className="text-[11px] font-mono p-4 overflow-x-auto text-foreground/80 leading-relaxed bg-muted/5">
439
+ {JSON.stringify(job, null, 2)}
440
+ </pre>
441
+ </button>
442
+ <div className="p-3 bg-muted/5 border-t border-border/50 flex justify-end gap-2">
443
+ {!job._archived && (
444
+ <button
445
+ type="button"
446
+ onClick={() => handleAction('delete', job)}
447
+ className="text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-lg hover:bg-red-500/10 text-red-500 transition-colors"
448
+ >
449
+ Terminate
450
+ </button>
451
+ )}
452
+ {!job._archived && (view === 'delayed' || view === 'failed') && (
453
+ <button
454
+ type="button"
455
+ onClick={() => handleAction('retry', job)}
456
+ className={cn(
457
+ 'text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-lg text-white shadow-sm transition-all',
458
+ view === 'delayed'
459
+ ? 'bg-amber-500 hover:bg-amber-600'
460
+ : 'bg-blue-500 hover:bg-blue-600'
461
+ )}
462
+ >
463
+ {view === 'delayed' ? 'Process Now' : 'Retry Job'}
464
+ </button>
465
+ )}
466
+ </div>
467
+ </div>
468
+ ))}
469
+
470
+ {view === 'archive' && data?.total && data.total > 50 && (
471
+ <div className="flex items-center justify-between py-6 border-t border-border/30">
472
+ <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">
473
+ Total {data.total} archived jobs
474
+ </p>
475
+ <div className="flex items-center gap-2">
476
+ <button
477
+ type="button"
478
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
479
+ disabled={page === 1}
480
+ className="p-2 rounded-lg bg-muted text-muted-foreground disabled:opacity-30 hover:bg-primary hover:text-white transition-all"
481
+ >
482
+
483
+ </button>
484
+ <span className="text-xs font-bold px-4">{page}</span>
485
+ <button
486
+ type="button"
487
+ onClick={() => setPage((p) => p + 1)}
488
+ disabled={page * 50 >= (data.total || 0)}
489
+ className="p-2 rounded-lg bg-muted text-muted-foreground disabled:opacity-30 hover:bg-primary hover:text-white transition-all"
490
+ >
491
+
492
+ </button>
493
+ </div>
494
+ </div>
495
+ )}
496
+ </div>
497
+ )}
498
+ </div>
499
+ <div className="p-4 border-t bg-card text-right flex-shrink-0">
500
+ <button
501
+ type="button"
502
+ onClick={onClose}
503
+ className="px-8 py-3 bg-muted text-foreground rounded-xl hover:bg-muted/80 text-sm font-bold transition-all active:scale-95 uppercase tracking-widest"
504
+ >
505
+ Dismiss
506
+ </button>
507
+ </div>
508
+ </motion.div>
509
+
510
+ {confirmDialog && (
511
+ <ConfirmDialog
512
+ open={confirmDialog.open}
513
+ title={confirmDialog.title}
514
+ message={confirmDialog.message}
515
+ variant={confirmDialog.variant}
516
+ isProcessing={isProcessing}
517
+ onConfirm={confirmDialog.action}
518
+ onCancel={() => setConfirmDialog(null)}
519
+ />
520
+ )}
521
+ </div>,
522
+ document.body
523
+ )
524
+ }