@superdangerous/app-framework 4.9.2 → 4.15.0

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 (54) hide show
  1. package/README.md +8 -2
  2. package/dist/api/logsRouter.d.ts +4 -1
  3. package/dist/api/logsRouter.d.ts.map +1 -1
  4. package/dist/api/logsRouter.js +100 -118
  5. package/dist/api/logsRouter.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/middleware/validation.d.ts +48 -43
  11. package/dist/middleware/validation.d.ts.map +1 -1
  12. package/dist/middleware/validation.js +48 -43
  13. package/dist/middleware/validation.js.map +1 -1
  14. package/dist/services/emailService.d.ts +146 -0
  15. package/dist/services/emailService.d.ts.map +1 -0
  16. package/dist/services/emailService.js +649 -0
  17. package/dist/services/emailService.js.map +1 -0
  18. package/dist/services/index.d.ts +2 -0
  19. package/dist/services/index.d.ts.map +1 -1
  20. package/dist/services/index.js +2 -0
  21. package/dist/services/index.js.map +1 -1
  22. package/dist/services/websocketServer.d.ts +7 -4
  23. package/dist/services/websocketServer.d.ts.map +1 -1
  24. package/dist/services/websocketServer.js +22 -16
  25. package/dist/services/websocketServer.js.map +1 -1
  26. package/dist/types/index.d.ts +7 -8
  27. package/dist/types/index.d.ts.map +1 -1
  28. package/package.json +11 -2
  29. package/src/api/logsRouter.ts +119 -138
  30. package/src/index.ts +14 -0
  31. package/src/middleware/validation.ts +82 -90
  32. package/src/services/emailService.ts +812 -0
  33. package/src/services/index.ts +14 -0
  34. package/src/services/websocketServer.ts +37 -23
  35. package/src/types/index.ts +7 -8
  36. package/ui/data-table/components/BatchActionsBar.tsx +53 -0
  37. package/ui/data-table/components/ColumnVisibility.tsx +111 -0
  38. package/ui/data-table/components/DataTablePage.tsx +238 -0
  39. package/ui/data-table/components/Pagination.tsx +203 -0
  40. package/ui/data-table/components/PaginationControls.tsx +122 -0
  41. package/ui/data-table/components/TableFilters.tsx +139 -0
  42. package/ui/data-table/components/index.ts +27 -0
  43. package/ui/data-table/hooks/index.ts +17 -0
  44. package/ui/data-table/hooks/useColumnOrder.ts +233 -0
  45. package/ui/data-table/hooks/useColumnVisibility.ts +128 -0
  46. package/ui/data-table/hooks/usePagination.ts +160 -0
  47. package/ui/data-table/hooks/useResizableColumns.ts +280 -0
  48. package/ui/data-table/index.ts +74 -0
  49. package/ui/dist/index.d.mts +207 -5
  50. package/ui/dist/index.d.ts +207 -5
  51. package/ui/dist/index.js +36 -43
  52. package/ui/dist/index.js.map +1 -1
  53. package/ui/dist/index.mjs +36 -43
  54. package/ui/dist/index.mjs.map +1 -1
@@ -46,3 +46,17 @@ export type {
46
46
  // Queue Service
47
47
  export { default as QueueService } from "./queueService.js";
48
48
  export type { QueueJob, QueueConfig, JobHandler } from "./queueService.js";
49
+
50
+ // Email Service
51
+ export {
52
+ EmailService,
53
+ getEmailService,
54
+ createEmailService,
55
+ } from "./emailService.js";
56
+ export type {
57
+ EmailConfig,
58
+ EmailOptions,
59
+ EmailServiceStatus,
60
+ NotificationEvent,
61
+ NotificationEventType,
62
+ } from "./emailService.js";
@@ -13,9 +13,10 @@ import {
13
13
  DataUpdateMessage,
14
14
  Simulator,
15
15
  Template,
16
+ Logger,
16
17
  } from "../types/index.js";
17
18
 
18
- let logger: any; // Will be initialized when needed
19
+ let logger: Logger | null = null;
19
20
 
20
21
  interface ClientInfo {
21
22
  id: string;
@@ -26,7 +27,10 @@ interface ClientInfo {
26
27
  interface BroadcastData {
27
28
  simulatorId?: string;
28
29
  templateId?: string;
29
- [key: string]: any;
30
+ address?: string;
31
+ value?: unknown;
32
+ values?: Record<string, unknown>;
33
+ [key: string]: unknown;
30
34
  }
31
35
 
32
36
  class WebSocketServer {
@@ -34,7 +38,7 @@ class WebSocketServer {
34
38
  private httpServer: HTTPServer;
35
39
  private clients: Map<string, ClientInfo>;
36
40
  private simulatorSubscriptions: Map<string, Set<string>>; // simulatorId -> Set of socket IDs
37
- private broadcastHook?: (event: string, data: any) => void;
41
+ private broadcastHook?: (event: string, data: BroadcastData) => void;
38
42
 
39
43
  constructor(httpServer: HTTPServer) {
40
44
  // Initialize logger if not already done
@@ -48,12 +52,12 @@ class WebSocketServer {
48
52
  }
49
53
 
50
54
  initialize(opts?: {
51
- broadcastHook?: (event: string, data: any) => void;
55
+ broadcastHook?: (event: string, data: BroadcastData) => void;
52
56
  }): void {
53
57
  if (this.io) {
54
58
  if (opts?.broadcastHook) {
55
59
  this.broadcastHook = opts.broadcastHook;
56
- logger.debug("WebSocket broadcast hook updated");
60
+ logger?.debug("WebSocket broadcast hook updated");
57
61
  }
58
62
  return;
59
63
  }
@@ -71,7 +75,7 @@ class WebSocketServer {
71
75
  }
72
76
 
73
77
  this.setupEventHandlers();
74
- logger.info("WebSocket server initialized");
78
+ logger?.info("WebSocket server initialized");
75
79
  }
76
80
 
77
81
  /**
@@ -87,7 +91,7 @@ class WebSocketServer {
87
91
  this.io.on("connection", (socket: Socket) => {
88
92
  // Only log in debug mode to avoid spam
89
93
  if (process.env.LOG_LEVEL?.toLowerCase() === "debug") {
90
- logger.debug(`Client connected: ${socket.id}`);
94
+ logger?.debug(`Client connected: ${socket.id}`);
91
95
  }
92
96
  this.clients.set(socket.id, {
93
97
  id: socket.id,
@@ -114,14 +118,14 @@ class WebSocketServer {
114
118
  socket.on("disconnect", () => {
115
119
  // Only log in debug mode to avoid spam
116
120
  if (process.env.LOG_LEVEL?.toLowerCase() === "debug") {
117
- logger.debug(`Client disconnected: ${socket.id}`);
121
+ logger?.debug(`Client disconnected: ${socket.id}`);
118
122
  }
119
123
  this.handleDisconnect(socket);
120
124
  });
121
125
 
122
126
  // Handle errors
123
- socket.on("error", (_error: Error) => {
124
- logger.error(`Socket error for ${socket.id}:`, _error);
127
+ socket.on("error", (error: Error) => {
128
+ logger?.error(`Socket error for ${socket.id}:`, error);
125
129
  });
126
130
  });
127
131
  }
@@ -145,7 +149,7 @@ class WebSocketServer {
145
149
  // Join socket.io room for efficient broadcasting
146
150
  socket.join(`simulator:${simulatorId}`);
147
151
 
148
- logger.debug(`Client ${socket.id} subscribed to simulator ${simulatorId}`);
152
+ logger?.debug(`Client ${socket.id} subscribed to simulator ${simulatorId}`);
149
153
  }
150
154
 
151
155
  private unsubscribeFromSimulator(socket: Socket, simulatorId: string): void {
@@ -167,7 +171,7 @@ class WebSocketServer {
167
171
  // Leave socket.io room
168
172
  socket.leave(`simulator:${simulatorId}`);
169
173
 
170
- logger.debug(
174
+ logger?.debug(
171
175
  `Client ${socket.id} unsubscribed from simulator ${simulatorId}`,
172
176
  );
173
177
  }
@@ -240,7 +244,9 @@ class WebSocketServer {
240
244
  type: "data:update",
241
245
  data: {
242
246
  simulatorId: data.simulatorId!,
243
- values: data.values || { [data.address]: data.value },
247
+ values: (data.values as Record<string, unknown>) || {
248
+ [data.address as string]: data.value,
249
+ },
244
250
  },
245
251
  } as DataUpdateMessage;
246
252
  break;
@@ -252,21 +258,29 @@ class WebSocketServer {
252
258
  } as WebSocketMessage;
253
259
  }
254
260
 
255
- // Broadcast to all clients
256
- this.io.emit(event, message.data);
257
-
258
- // If simulator-specific, also send to room
261
+ // Broadcast: if simulator-specific, send only to room; otherwise broadcast globally
262
+ // This prevents duplicate events for subscribed clients
259
263
  if (data.simulatorId) {
260
264
  this.io.to(`simulator:${data.simulatorId}`).emit(event, message.data);
265
+ const subscriberCount =
266
+ this.simulatorSubscriptions.get(data.simulatorId)?.size || 0;
267
+ logger?.debug(
268
+ `Broadcast ${event} to ${subscriberCount} subscribers of simulator ${data.simulatorId}`,
269
+ );
270
+ } else {
271
+ this.io.emit(event, message.data);
272
+ logger?.debug(`Broadcast ${event} to ${this.clients.size} clients`);
261
273
  }
262
-
263
- logger.debug(`Broadcast ${event} to ${this.clients.size} clients`);
264
274
  }
265
275
 
266
276
  /**
267
277
  * Send event to specific simulator subscribers
268
278
  */
269
- broadcastToSimulator(simulatorId: string, event: string, data: any): void {
279
+ broadcastToSimulator(
280
+ simulatorId: string,
281
+ event: string,
282
+ data: Record<string, unknown>,
283
+ ): void {
270
284
  if (!this.io) return;
271
285
 
272
286
  const message: DataUpdateMessage = {
@@ -281,7 +295,7 @@ class WebSocketServer {
281
295
 
282
296
  const subscriberCount =
283
297
  this.simulatorSubscriptions.get(simulatorId)?.size || 0;
284
- logger.debug(
298
+ logger?.debug(
285
299
  `Broadcast ${event} to ${subscriberCount} subscribers of simulator ${simulatorId}`,
286
300
  );
287
301
  }
@@ -338,7 +352,7 @@ class WebSocketServer {
338
352
  // Close the server
339
353
  await new Promise<void>((resolve) => {
340
354
  this.io!.close(() => {
341
- logger.info("WebSocket server shut down");
355
+ logger?.info("WebSocket server shut down");
342
356
  resolve();
343
357
  });
344
358
  });
@@ -362,7 +376,7 @@ export function createWebSocketServer(
362
376
  httpServer: HTTPServer,
363
377
  opts?: {
364
378
  reset?: boolean;
365
- broadcastHook?: (event: string, data: any) => void;
379
+ broadcastHook?: (event: string, data: BroadcastData) => void;
366
380
  },
367
381
  ): WebSocketServer {
368
382
  // Initialize logger if not already done
@@ -92,7 +92,6 @@ export interface UpdateCheckResult {
92
92
  export interface ClientInfo {
93
93
  id: string;
94
94
  connectedAt: Date;
95
- lastActivity: Date;
96
95
  subscriptions: Set<string>;
97
96
  }
98
97
 
@@ -109,9 +108,9 @@ export interface Logger {
109
108
  /**
110
109
  * WebSocket message types
111
110
  */
112
- export interface WebSocketMessage {
111
+ export interface WebSocketMessage<T = unknown> {
113
112
  type: string;
114
- data: any;
113
+ data: T;
115
114
  }
116
115
 
117
116
  export interface SimulatorUpdateMessage extends WebSocketMessage {
@@ -128,7 +127,7 @@ export interface DataUpdateMessage extends WebSocketMessage {
128
127
  type: "data:update";
129
128
  data: {
130
129
  simulatorId: string;
131
- values: Record<string, any>;
130
+ values: Record<string, unknown>;
132
131
  };
133
132
  }
134
133
 
@@ -136,19 +135,19 @@ export interface Simulator {
136
135
  id: string;
137
136
  name: string;
138
137
  status: string;
139
- [key: string]: any;
138
+ [key: string]: unknown;
140
139
  }
141
140
 
142
141
  export interface Template {
143
142
  id: string;
144
143
  name: string;
145
- [key: string]: any;
144
+ [key: string]: unknown;
146
145
  }
147
146
 
148
147
  export interface AppConfig {
149
- [key: string]: any;
148
+ [key: string]: unknown;
150
149
  }
151
150
 
152
151
  export interface ValidationError extends Error {
153
- details?: any[];
152
+ details?: unknown[];
154
153
  }
@@ -0,0 +1,53 @@
1
+ import { type ReactNode } from 'react';
2
+ import { X } from 'lucide-react';
3
+ import { Card } from '../../components/base/card';
4
+ import { Button } from '../../components/base/button';
5
+ import { cn } from '../../src/utils/cn';
6
+
7
+ export interface BatchActionsBarProps {
8
+ /** Number of selected items */
9
+ selectedCount: number;
10
+ /** Callback to clear selection */
11
+ onClear: () => void;
12
+ /** Action buttons to display on the right side */
13
+ children: ReactNode;
14
+ /** Label for the selected items (default: "item"/"items") */
15
+ itemLabel?: string;
16
+ /** Additional CSS classes */
17
+ className?: string;
18
+ }
19
+
20
+ /**
21
+ * A horizontal bar that appears when items are selected,
22
+ * showing the count and providing batch action buttons.
23
+ */
24
+ export function BatchActionsBar({
25
+ selectedCount,
26
+ onClear,
27
+ children,
28
+ itemLabel,
29
+ className,
30
+ }: BatchActionsBarProps) {
31
+ if (selectedCount === 0) return null;
32
+
33
+ const label = itemLabel ?? (selectedCount === 1 ? 'item' : 'items');
34
+
35
+ return (
36
+ <Card className={cn("p-3 bg-primary/5 border-primary/20", className)}>
37
+ <div className="flex items-center justify-between">
38
+ <div className="flex items-center gap-3">
39
+ <span className="font-medium text-sm" role="status" aria-live="polite">
40
+ {selectedCount} {label} selected
41
+ </span>
42
+ <Button variant="ghost" size="sm" onClick={onClear} aria-label="Clear selection">
43
+ <X className="h-4 w-4 mr-1" aria-hidden="true" />
44
+ Clear
45
+ </Button>
46
+ </div>
47
+ <div className="flex items-center gap-2" role="group" aria-label="Batch actions">
48
+ {children}
49
+ </div>
50
+ </div>
51
+ </Card>
52
+ );
53
+ }
@@ -0,0 +1,111 @@
1
+ import { Columns3, Check, Eye, EyeOff } from 'lucide-react';
2
+ import { Button } from '../../components/base/button';
3
+ import {
4
+ DropdownMenu,
5
+ DropdownMenuContent,
6
+ DropdownMenuItem,
7
+ DropdownMenuLabel,
8
+ DropdownMenuSeparator,
9
+ DropdownMenuTrigger,
10
+ } from '../../components/base/dropdown-menu';
11
+ import { cn } from '../../src/utils/cn';
12
+ import type { ColumnConfig } from '../hooks/useColumnVisibility';
13
+
14
+ interface ColumnVisibilityProps {
15
+ columns: ColumnConfig[];
16
+ isColumnVisible: (columnId: string) => boolean;
17
+ toggleColumn: (columnId: string) => void;
18
+ showAllColumns: () => void;
19
+ hideAllColumns: () => void;
20
+ }
21
+
22
+ export function ColumnVisibility({
23
+ columns,
24
+ isColumnVisible,
25
+ toggleColumn,
26
+ showAllColumns,
27
+ hideAllColumns,
28
+ }: ColumnVisibilityProps) {
29
+ const visibleCount = columns.filter(c => isColumnVisible(c.id)).length;
30
+ const toggleableColumns = columns.filter(c => !c.locked);
31
+
32
+ return (
33
+ <DropdownMenu>
34
+ <DropdownMenuTrigger asChild>
35
+ <Button variant="outline" size="sm" className="gap-2">
36
+ <Columns3 className="h-4 w-4" />
37
+ Columns
38
+ <span className="text-muted-foreground text-xs">
39
+ ({visibleCount}/{columns.length})
40
+ </span>
41
+ </Button>
42
+ </DropdownMenuTrigger>
43
+ <DropdownMenuContent align="end" className="w-48">
44
+ <DropdownMenuLabel className="font-normal text-xs text-muted-foreground">
45
+ Toggle columns
46
+ </DropdownMenuLabel>
47
+ <DropdownMenuSeparator />
48
+
49
+ {columns.map(column => {
50
+ const visible = isColumnVisible(column.id);
51
+ const isLocked = column.locked === true;
52
+
53
+ return (
54
+ <DropdownMenuItem
55
+ key={column.id}
56
+ onClick={(e) => {
57
+ e.preventDefault();
58
+ if (!isLocked) {
59
+ toggleColumn(column.id);
60
+ }
61
+ }}
62
+ className={cn(
63
+ 'gap-2 cursor-pointer',
64
+ isLocked && 'opacity-50 cursor-not-allowed'
65
+ )}
66
+ disabled={isLocked}
67
+ >
68
+ <div className="w-4 h-4 flex items-center justify-center">
69
+ {visible ? (
70
+ <Check className="h-3.5 w-3.5 text-primary" />
71
+ ) : (
72
+ <div className="h-3.5 w-3.5" />
73
+ )}
74
+ </div>
75
+ <span className="flex-1">{column.label}</span>
76
+ {isLocked && (
77
+ <span className="text-xs text-muted-foreground">Required</span>
78
+ )}
79
+ </DropdownMenuItem>
80
+ );
81
+ })}
82
+
83
+ {toggleableColumns.length > 1 && (
84
+ <>
85
+ <DropdownMenuSeparator />
86
+ <DropdownMenuItem
87
+ onClick={(e) => {
88
+ e.preventDefault();
89
+ showAllColumns();
90
+ }}
91
+ className="gap-2 cursor-pointer"
92
+ >
93
+ <Eye className="h-4 w-4" />
94
+ Show All
95
+ </DropdownMenuItem>
96
+ <DropdownMenuItem
97
+ onClick={(e) => {
98
+ e.preventDefault();
99
+ hideAllColumns();
100
+ }}
101
+ className="gap-2 cursor-pointer"
102
+ >
103
+ <EyeOff className="h-4 w-4" />
104
+ Hide Optional
105
+ </DropdownMenuItem>
106
+ </>
107
+ )}
108
+ </DropdownMenuContent>
109
+ </DropdownMenu>
110
+ );
111
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * DataTablePage - Full-height data table layout with integrated header
3
+ *
4
+ * This component provides a desktop-app style layout where:
5
+ * - The header contains search, filters, action buttons, AND pagination controls
6
+ * - The data table fills the available vertical space between header and footer
7
+ * - Pagination controls appear inline in the header (right side)
8
+ *
9
+ * Usage:
10
+ * ```tsx
11
+ * <DataTablePage
12
+ * title="Issues"
13
+ * description="Review and manage detected code issues"
14
+ * search={searchTerm}
15
+ * onSearchChange={setSearchTerm}
16
+ * searchPlaceholder="Search issues..."
17
+ * filters={filterOptions}
18
+ * activeFilterCount={countActiveFilters}
19
+ * onClearFilters={clearFilters}
20
+ * pagination={pagination}
21
+ * actions={<>
22
+ * <Button>Refresh</Button>
23
+ * <Button>Export</Button>
24
+ * </>}
25
+ * >
26
+ * <DataTable ... hidePagination />
27
+ * </DataTablePage>
28
+ * ```
29
+ */
30
+
31
+ import React from 'react';
32
+ import { Search, Filter, X } from 'lucide-react';
33
+ import { Input } from '../../components/base/input';
34
+ import { Button } from '../../components/base/button';
35
+ import { Popover, PopoverContent, PopoverTrigger } from '../../components/base/popover';
36
+ import { cn } from '../../src/utils/cn';
37
+ import { PaginationControls, type PaginationControlsProps } from './PaginationControls';
38
+
39
+ export interface FilterOption {
40
+ id: string;
41
+ label: string;
42
+ render: () => React.ReactNode;
43
+ }
44
+
45
+ export interface DataTablePageProps {
46
+ /** Page title */
47
+ title: string;
48
+ /** Page description */
49
+ description?: string;
50
+ /** Search term */
51
+ search?: string;
52
+ /** Search change handler */
53
+ onSearchChange?: (value: string) => void;
54
+ /** Search placeholder text */
55
+ searchPlaceholder?: string;
56
+ /** Filter options for popover */
57
+ filters?: FilterOption[];
58
+ /** Number of active filters */
59
+ activeFilterCount?: number;
60
+ /** Clear all filters handler */
61
+ onClearFilters?: () => void;
62
+ /** Pagination props from usePagination hook */
63
+ pagination?: PaginationControlsProps;
64
+ /** Action buttons to show in the header */
65
+ actions?: React.ReactNode;
66
+ /** Content before the table (e.g., BatchActionsBar) */
67
+ beforeTable?: React.ReactNode;
68
+ /** The DataTable component */
69
+ children: React.ReactNode;
70
+ /** Additional class for the container */
71
+ className?: string;
72
+ /** Whether to show a loading state */
73
+ loading?: boolean;
74
+ /** Loading component to show */
75
+ loadingComponent?: React.ReactNode;
76
+ }
77
+
78
+ export function DataTablePage({
79
+ title,
80
+ description,
81
+ search,
82
+ onSearchChange,
83
+ searchPlaceholder = 'Search...',
84
+ filters,
85
+ activeFilterCount = 0,
86
+ onClearFilters,
87
+ pagination,
88
+ actions,
89
+ beforeTable,
90
+ children,
91
+ className,
92
+ loading,
93
+ loadingComponent,
94
+ }: DataTablePageProps) {
95
+ // Always show pagination controls when pagination is provided (for row count selector)
96
+ const showPagination = pagination && pagination.totalItems > 0;
97
+
98
+ return (
99
+ <div className={cn('flex flex-col h-full', className)}>
100
+ {/* Page Header - has horizontal padding */}
101
+ <div className="data-table-page-header flex-shrink-0 space-y-4 pb-4">
102
+ {/* Title */}
103
+ <div>
104
+ <h1 className="text-3xl font-bold">{title}</h1>
105
+ {description && (
106
+ <p className="text-muted-foreground">{description}</p>
107
+ )}
108
+ </div>
109
+
110
+ {/* Controls Row: Search | Filters | Pagination | Spacer | Actions */}
111
+ <div className="flex items-center gap-3 flex-wrap">
112
+ {/* Search Input - responsive width */}
113
+ {onSearchChange !== undefined && (
114
+ <div className="relative w-full sm:w-auto sm:min-w-[200px] sm:max-w-xs">
115
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none z-10" />
116
+ <Input
117
+ placeholder={searchPlaceholder}
118
+ value={search || ''}
119
+ onChange={(e) => onSearchChange(e.target.value)}
120
+ className="pl-9 h-9"
121
+ />
122
+ {search && (
123
+ <button
124
+ type="button"
125
+ onClick={() => onSearchChange('')}
126
+ className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 rounded-sm hover:bg-muted"
127
+ >
128
+ <X className="h-3 w-3 text-muted-foreground" />
129
+ </button>
130
+ )}
131
+ </div>
132
+ )}
133
+
134
+ {/* Filters Popover */}
135
+ {filters && filters.length > 0 && (
136
+ <Popover>
137
+ <PopoverTrigger asChild>
138
+ <Button variant="outline" size="sm" className="gap-2 h-9">
139
+ <Filter className="h-4 w-4" />
140
+ <span className="hidden sm:inline">Filters</span>
141
+ {activeFilterCount > 0 && (
142
+ <span className="rounded-full bg-primary text-primary-foreground px-2 py-0.5 text-xs font-medium">
143
+ {activeFilterCount}
144
+ </span>
145
+ )}
146
+ </Button>
147
+ </PopoverTrigger>
148
+ <PopoverContent
149
+ className="w-80 overflow-y-auto"
150
+ align="start"
151
+ collisionPadding={16}
152
+ style={{ maxHeight: 'var(--radix-popover-content-available-height)' }}
153
+ >
154
+ <div className="space-y-4">
155
+ <div className="flex items-center justify-between">
156
+ <h4 className="font-medium text-sm">Filters</h4>
157
+ {activeFilterCount > 0 && onClearFilters && (
158
+ <Button
159
+ variant="ghost"
160
+ size="sm"
161
+ onClick={onClearFilters}
162
+ className="h-auto p-0 text-xs text-destructive hover:text-destructive"
163
+ >
164
+ Clear all
165
+ </Button>
166
+ )}
167
+ </div>
168
+
169
+ <div className="space-y-3">
170
+ {filters.map((filter) => (
171
+ <div key={filter.id} className="space-y-1.5">
172
+ <label className="text-xs font-medium text-muted-foreground">
173
+ {filter.label}
174
+ </label>
175
+ {filter.render()}
176
+ </div>
177
+ ))}
178
+ </div>
179
+ </div>
180
+ </PopoverContent>
181
+ </Popover>
182
+ )}
183
+
184
+ {/* Clear filters button (visible when filters are active) */}
185
+ {activeFilterCount > 0 && onClearFilters && (
186
+ <Button
187
+ variant="ghost"
188
+ size="sm"
189
+ onClick={onClearFilters}
190
+ className="h-9 gap-1.5 text-muted-foreground hover:text-foreground"
191
+ title="Clear filters"
192
+ >
193
+ <X className="h-4 w-4" />
194
+ <span className="hidden sm:inline">Clear</span>
195
+ </Button>
196
+ )}
197
+
198
+ {/* Pagination Controls (after filters) */}
199
+ {showPagination && (
200
+ <PaginationControls {...pagination} />
201
+ )}
202
+
203
+ {/* Spacer */}
204
+ <div className="flex-1" />
205
+
206
+ {/* Action buttons (right side) - never wrap */}
207
+ {actions && (
208
+ <div className="flex items-center gap-2 flex-shrink-0">
209
+ {actions}
210
+ </div>
211
+ )}
212
+ </div>
213
+ </div>
214
+
215
+ {/* Before Table Content (e.g., BatchActionsBar) - with padding */}
216
+ {beforeTable && (
217
+ <div className="px-6 pb-2">
218
+ {beforeTable}
219
+ </div>
220
+ )}
221
+
222
+ {/* Table Container - edge to edge, scrolls both directions */}
223
+ <div className="relative flex-1 min-h-0">
224
+ <div className="data-table-scroll-container h-full">
225
+ {loading ? (
226
+ loadingComponent || (
227
+ <div className="flex items-center justify-center h-full">
228
+ <div className="text-muted-foreground">Loading...</div>
229
+ </div>
230
+ )
231
+ ) : (
232
+ children
233
+ )}
234
+ </div>
235
+ </div>
236
+ </div>
237
+ );
238
+ }