@tidecloak/ui-framework 0.0.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 (48) hide show
  1. package/README.md +377 -0
  2. package/dist/index.d.mts +2739 -0
  3. package/dist/index.d.ts +2739 -0
  4. package/dist/index.js +12869 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/index.mjs +12703 -0
  7. package/dist/index.mjs.map +1 -0
  8. package/package.json +54 -0
  9. package/src/components/common/ActionButton.tsx +234 -0
  10. package/src/components/common/EmptyState.tsx +140 -0
  11. package/src/components/common/LoadingSkeleton.tsx +121 -0
  12. package/src/components/common/RefreshButton.tsx +127 -0
  13. package/src/components/common/StatusBadge.tsx +177 -0
  14. package/src/components/common/index.ts +31 -0
  15. package/src/components/data-table/DataTable.tsx +201 -0
  16. package/src/components/data-table/PaginatedTable.tsx +247 -0
  17. package/src/components/data-table/index.ts +2 -0
  18. package/src/components/dialogs/CollapsibleSection.tsx +184 -0
  19. package/src/components/dialogs/ConfirmDialog.tsx +264 -0
  20. package/src/components/dialogs/DetailDialog.tsx +228 -0
  21. package/src/components/dialogs/index.ts +3 -0
  22. package/src/components/index.ts +5 -0
  23. package/src/components/pages/base/ApprovalsPageBase.tsx +680 -0
  24. package/src/components/pages/base/LogsPageBase.tsx +581 -0
  25. package/src/components/pages/base/RolesPageBase.tsx +1470 -0
  26. package/src/components/pages/base/TemplatesPageBase.tsx +761 -0
  27. package/src/components/pages/base/UsersPageBase.tsx +843 -0
  28. package/src/components/pages/base/index.ts +58 -0
  29. package/src/components/pages/connected/ApprovalsPage.tsx +797 -0
  30. package/src/components/pages/connected/LogsPage.tsx +267 -0
  31. package/src/components/pages/connected/RolesPage.tsx +525 -0
  32. package/src/components/pages/connected/TemplatesPage.tsx +181 -0
  33. package/src/components/pages/connected/UsersPage.tsx +237 -0
  34. package/src/components/pages/connected/index.ts +36 -0
  35. package/src/components/pages/index.ts +5 -0
  36. package/src/components/tabs/TabsView.tsx +300 -0
  37. package/src/components/tabs/index.ts +1 -0
  38. package/src/components/ui/index.tsx +1001 -0
  39. package/src/hooks/index.ts +3 -0
  40. package/src/hooks/useAutoRefresh.ts +119 -0
  41. package/src/hooks/usePagination.ts +152 -0
  42. package/src/hooks/useSelection.ts +81 -0
  43. package/src/index.ts +256 -0
  44. package/src/theme.ts +185 -0
  45. package/src/tide/index.ts +19 -0
  46. package/src/tide/tidePolicy.ts +270 -0
  47. package/src/types/index.ts +484 -0
  48. package/src/utils/index.ts +121 -0
@@ -0,0 +1,581 @@
1
+ import React, { useState, useEffect, useRef } from "react";
2
+ import { ScrollText, Activity, Shield, Plus, Check, X, Upload, Ban, Eye } from "lucide-react";
3
+ import { formatLogTimestamp, computeRowsForViewportHeight } from "../../../utils";
4
+ import { useAutoRefresh } from "../../../hooks/useAutoRefresh";
5
+ import { usePagination } from "../../../hooks/usePagination";
6
+ import { RefreshButton } from "../../common/RefreshButton";
7
+ import { StatusBadge } from "../../common/StatusBadge";
8
+ import { ActionButton } from "../../common/ActionButton";
9
+ import { LoadingSkeleton } from "../../common/LoadingSkeleton";
10
+ import { EmptyState } from "../../common/EmptyState";
11
+ import { TabsView } from "../../tabs/TabsView";
12
+ import { DataTable } from "../../data-table/DataTable";
13
+ import { DetailDialog } from "../../dialogs/DetailDialog";
14
+ import { defaultComponents } from "../../ui";
15
+ import type { BaseDataItem, ColumnDef, DetailField, ToastConfig } from "../../../types";
16
+
17
+ export interface BaseLogItem extends BaseDataItem {
18
+ timestamp: number; // Unix timestamp in milliseconds
19
+ type?: string;
20
+ action?: string;
21
+ }
22
+
23
+ export interface AccessLogItem extends BaseLogItem {
24
+ type: string;
25
+ clientId?: string;
26
+ userId?: string;
27
+ ipAddress?: string;
28
+ details?: Record<string, any>;
29
+ }
30
+
31
+ export interface PolicyLogItem extends BaseLogItem {
32
+ policyId: string;
33
+ roleId: string;
34
+ action: string;
35
+ performedBy: string;
36
+ performedByEmail?: string;
37
+ details?: string;
38
+ policyStatus?: string;
39
+ policyThreshold?: number;
40
+ approvalCount?: number;
41
+ rejectionCount?: number;
42
+ }
43
+
44
+ export interface LogsTabConfig<T extends BaseLogItem> {
45
+ key: string;
46
+ label: string;
47
+ icon?: React.ReactNode;
48
+ /** Data fetcher - receives limit and offset */
49
+ fetchData: (limit: number, offset: number) => Promise<T[]>;
50
+ /** Column definitions for the table */
51
+ columns: ColumnDef<T>[];
52
+ /** Empty state configuration */
53
+ emptyState: {
54
+ icon: React.ReactNode;
55
+ title: string;
56
+ description: string;
57
+ };
58
+ /** Optional row click handler */
59
+ onRowClick?: (item: T) => void;
60
+ /** Query keys for cache invalidation */
61
+ queryKeys?: string[];
62
+ }
63
+
64
+ export interface LogsPageBaseProps<
65
+ TAccess extends AccessLogItem = AccessLogItem,
66
+ TPolicy extends PolicyLogItem = PolicyLogItem
67
+ > {
68
+ /** Page title */
69
+ title?: string;
70
+ /** Page description */
71
+ description?: string;
72
+ /** Tab configurations */
73
+ tabs: {
74
+ access?: LogsTabConfig<TAccess>;
75
+ sessions?: {
76
+ key: string;
77
+ label: string;
78
+ icon?: React.ReactNode;
79
+ /** Embedded content component */
80
+ content: React.ReactNode;
81
+ };
82
+ policies?: LogsTabConfig<TPolicy>;
83
+ };
84
+ /** Initial active tab */
85
+ initialTab?: "access" | "sessions" | "policies";
86
+ /** Toast notification handler */
87
+ toast?: (config: ToastConfig) => void;
88
+ /** Auto-refresh interval in seconds */
89
+ refreshInterval?: number;
90
+ /** Query invalidation handler */
91
+ invalidateQueries?: (queryKeys: string[]) => void;
92
+ /** Available page sizes */
93
+ pageSizes?: number[];
94
+ /** Enable auto page sizing */
95
+ autoPageSize?: boolean;
96
+ /** URL state management */
97
+ urlState?: {
98
+ tab: string;
99
+ setTab: (tab: string) => void;
100
+ };
101
+ /** Custom components */
102
+ components?: {
103
+ Card?: React.ComponentType<{ children: React.ReactNode; className?: string }>;
104
+ CardContent?: React.ComponentType<{ children: React.ReactNode; className?: string }>;
105
+ Button?: React.ComponentType<any>;
106
+ Badge?: React.ComponentType<any>;
107
+ Skeleton?: React.ComponentType<{ className?: string }>;
108
+ Select?: React.ComponentType<any>;
109
+ SelectTrigger?: React.ComponentType<any>;
110
+ SelectValue?: React.ComponentType<any>;
111
+ SelectContent?: React.ComponentType<any>;
112
+ SelectItem?: React.ComponentType<any>;
113
+ // Table components
114
+ Table?: React.ComponentType<any>;
115
+ TableHeader?: React.ComponentType<any>;
116
+ TableBody?: React.ComponentType<any>;
117
+ TableRow?: React.ComponentType<any>;
118
+ TableHead?: React.ComponentType<any>;
119
+ TableCell?: React.ComponentType<any>;
120
+ // Tabs components
121
+ Tabs?: React.ComponentType<any>;
122
+ TabsList?: React.ComponentType<any>;
123
+ TabsTrigger?: React.ComponentType<any>;
124
+ TabsContent?: React.ComponentType<any>;
125
+ // Dialog components
126
+ Dialog?: React.ComponentType<any>;
127
+ DialogContent?: React.ComponentType<any>;
128
+ DialogHeader?: React.ComponentType<any>;
129
+ DialogTitle?: React.ComponentType<any>;
130
+ DialogDescription?: React.ComponentType<any>;
131
+ DialogFooter?: React.ComponentType<any>;
132
+ };
133
+ /** Additional class name */
134
+ className?: string;
135
+ /** Optional help text shown below description */
136
+ helpText?: React.ReactNode;
137
+ }
138
+
139
+ // ============================================================================
140
+ // GENERIC LOGS TAB COMPONENT
141
+ // ============================================================================
142
+
143
+ interface LogsTabProps<T extends BaseLogItem> {
144
+ config: LogsTabConfig<T>;
145
+ refreshInterval: number;
146
+ toast?: (config: ToastConfig) => void;
147
+ invalidateQueries?: (queryKeys: string[]) => void;
148
+ pageSizes: number[];
149
+ autoPageSize: boolean;
150
+ components?: LogsPageBaseProps['components'];
151
+ }
152
+
153
+ function LogsTab<T extends BaseLogItem>({
154
+ config,
155
+ refreshInterval,
156
+ toast,
157
+ invalidateQueries,
158
+ pageSizes,
159
+ autoPageSize,
160
+ components = {},
161
+ }: LogsTabProps<T>) {
162
+ const [data, setData] = useState<T[]>([]);
163
+ const [isLoading, setIsLoading] = useState(false);
164
+ const viewportRef = useRef<HTMLDivElement>(null);
165
+
166
+ // Pagination
167
+ const pagination = usePagination({
168
+ initialPageSize: autoPageSize ? 50 : pageSizes[0],
169
+ autoPageSize,
170
+ viewportRef: autoPageSize ? viewportRef : undefined,
171
+ });
172
+
173
+ const fetchData = async () => {
174
+ setIsLoading(true);
175
+ try {
176
+ const result = await config.fetchData(
177
+ pagination.pageSize,
178
+ pagination.page * pagination.pageSize
179
+ );
180
+ setData(result);
181
+ if (invalidateQueries && config.queryKeys) {
182
+ invalidateQueries(config.queryKeys);
183
+ }
184
+ } catch (error) {
185
+ console.error(`Error fetching ${config.key} logs:`, error);
186
+ toast?.({
187
+ title: `Failed to fetch ${config.label.toLowerCase()}`,
188
+ description: error instanceof Error ? error.message : "Unknown error",
189
+ variant: "destructive",
190
+ });
191
+ } finally {
192
+ setIsLoading(false);
193
+ }
194
+ };
195
+
196
+ const { secondsRemaining, refreshNow } = useAutoRefresh({
197
+ intervalSeconds: refreshInterval,
198
+ refresh: fetchData,
199
+ isBlocked: isLoading,
200
+ });
201
+
202
+ // Fetch on mount and when pagination changes
203
+ useEffect(() => {
204
+ void fetchData();
205
+ }, [pagination.page, pagination.pageSize]);
206
+
207
+ const hasNextPage = data.length === pagination.pageSize;
208
+
209
+ // Components (use defaultComponents from ui if not provided)
210
+ const Card = components.Card || defaultComponents.Card;
211
+ const CardContent = components.CardContent || defaultComponents.CardContent;
212
+ const Button = components.Button || defaultComponents.Button;
213
+ const Select = components.Select || defaultComponents.Select;
214
+ const SelectTrigger = components.SelectTrigger || defaultComponents.SelectTrigger;
215
+ const SelectValue = components.SelectValue || defaultComponents.SelectValue;
216
+ const SelectContent = components.SelectContent || defaultComponents.SelectContent;
217
+ const SelectItem = components.SelectItem || defaultComponents.SelectItem;
218
+
219
+ const hasSelectComponents = Select && SelectTrigger && SelectValue && SelectContent && SelectItem;
220
+
221
+ return (
222
+ <Card>
223
+ <CardContent style={{ padding: 0 }}>
224
+ {/* Pagination Controls */}
225
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "0.5rem", padding: "0.75rem 1rem", borderBottom: "1px solid #e5e7eb" }}>
226
+ <div style={{ fontSize: "0.875rem", color: "#6b7280" }}>
227
+ Page <span style={{ fontWeight: 500, color: "#111827" }}>{pagination.page + 1}</span>
228
+ </div>
229
+ <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
230
+ {/* Page Size Selector */}
231
+ {hasSelectComponents && (
232
+ <div style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontSize: "0.875rem", color: "#6b7280" }}>
233
+ Page size
234
+ <Select
235
+ value={pagination.pageSizeSelectValue}
236
+ onValueChange={pagination.onPageSizeSelect}
237
+ >
238
+ <SelectTrigger style={{ height: "2rem", width: "5.75rem" }}>
239
+ <SelectValue />
240
+ </SelectTrigger>
241
+ <SelectContent>
242
+ {autoPageSize && <SelectItem value="auto">Auto</SelectItem>}
243
+ {pageSizes.map((size) => (
244
+ <SelectItem key={size} value={String(size)}>
245
+ {size}
246
+ </SelectItem>
247
+ ))}
248
+ </SelectContent>
249
+ </Select>
250
+ </div>
251
+ )}
252
+
253
+ {/* Navigation */}
254
+ <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
255
+ <Button
256
+ size="sm"
257
+ variant="outline"
258
+ onClick={() => pagination.setPage(Math.max(0, pagination.page - 1))}
259
+ disabled={pagination.page === 0 || isLoading}
260
+ >
261
+ Previous
262
+ </Button>
263
+ <Button
264
+ size="sm"
265
+ variant="outline"
266
+ onClick={() => pagination.setPage(pagination.page + 1)}
267
+ disabled={!hasNextPage || isLoading}
268
+ >
269
+ Next
270
+ </Button>
271
+ </div>
272
+ </div>
273
+ </div>
274
+
275
+ {/* Content */}
276
+ {isLoading && data.length === 0 ? (
277
+ <LoadingSkeleton rows={5} type="list" SkeletonComponent={components.Skeleton} />
278
+ ) : data.length > 0 ? (
279
+ <div
280
+ ref={viewportRef}
281
+ style={autoPageSize ? { height: "max(320px, calc(100vh - 360px))", overflow: "auto" } : undefined}
282
+ >
283
+ <div style={{ minWidth: "850px" }}>
284
+ <DataTable
285
+ data={data}
286
+ columns={config.columns}
287
+ onRowClick={config.onRowClick}
288
+ components={{
289
+ Table: components.Table,
290
+ TableHeader: components.TableHeader,
291
+ TableBody: components.TableBody,
292
+ TableRow: components.TableRow,
293
+ TableHead: components.TableHead,
294
+ TableCell: components.TableCell,
295
+ Skeleton: components.Skeleton,
296
+ }}
297
+ />
298
+ </div>
299
+ </div>
300
+ ) : (
301
+ <EmptyState
302
+ icon={config.emptyState.icon}
303
+ title={config.emptyState.title}
304
+ description={config.emptyState.description}
305
+ />
306
+ )}
307
+ </CardContent>
308
+ </Card>
309
+ );
310
+ }
311
+
312
+ // ============================================================================
313
+ // MAIN LOGS PAGE COMPONENT
314
+ // ============================================================================
315
+
316
+ /**
317
+ * LogsPage - Generic logs page with tabs for access, sessions, and policies
318
+ *
319
+ * @example
320
+ * ```tsx
321
+ * <LogsPage
322
+ * tabs={{
323
+ * access: {
324
+ * key: 'access',
325
+ * label: 'Access',
326
+ * fetchData: (limit, offset) => api.admin.logs.access(limit, offset),
327
+ * columns: accessColumns,
328
+ * emptyState: { icon: <ScrollText />, title: 'No logs', description: '...' },
329
+ * },
330
+ * sessions: {
331
+ * key: 'sessions',
332
+ * label: 'Sessions',
333
+ * content: <SessionHistoryContent />,
334
+ * },
335
+ * policies: {
336
+ * key: 'policies',
337
+ * label: 'Policies',
338
+ * fetchData: (limit, offset) => api.admin.sshPolicies.getLogs(limit, offset),
339
+ * columns: policyColumns,
340
+ * emptyState: { icon: <Shield />, title: 'No policy logs', description: '...' },
341
+ * },
342
+ * }}
343
+ * />
344
+ * ```
345
+ */
346
+ export function LogsPageBase<
347
+ TAccess extends AccessLogItem = AccessLogItem,
348
+ TPolicy extends PolicyLogItem = PolicyLogItem
349
+ >({
350
+ title = "Logs",
351
+ description = "Review access changes and activity",
352
+ tabs,
353
+ initialTab = "access",
354
+ toast,
355
+ refreshInterval = 15,
356
+ invalidateQueries,
357
+ pageSizes = [25, 50, 100, 200],
358
+ autoPageSize = true,
359
+ urlState,
360
+ components = {},
361
+ className,
362
+ helpText,
363
+ }: LogsPageBaseProps<TAccess, TPolicy>) {
364
+ const [activeTab, setActiveTab] = useState<string>(urlState?.tab || initialTab);
365
+
366
+ // Sync with URL state
367
+ useEffect(() => {
368
+ if (urlState?.tab && urlState.tab !== activeTab) {
369
+ setActiveTab(urlState.tab);
370
+ }
371
+ }, [urlState?.tab]);
372
+
373
+ const handleTabChange = (tab: string) => {
374
+ setActiveTab(tab);
375
+ urlState?.setTab(tab);
376
+ };
377
+
378
+ // Build tab definitions
379
+ const tabDefs = [];
380
+
381
+ if (tabs.access) {
382
+ tabDefs.push({
383
+ key: "access",
384
+ label: tabs.access.label || "Access",
385
+ icon: tabs.access.icon,
386
+ content: (
387
+ <LogsTab
388
+ config={tabs.access as LogsTabConfig<any>}
389
+ refreshInterval={refreshInterval}
390
+ toast={toast}
391
+ invalidateQueries={invalidateQueries}
392
+ pageSizes={pageSizes}
393
+ autoPageSize={autoPageSize}
394
+ components={components}
395
+ />
396
+ ),
397
+ });
398
+ }
399
+
400
+ if (tabs.sessions) {
401
+ tabDefs.push({
402
+ key: "sessions",
403
+ label: tabs.sessions.label || "Sessions",
404
+ icon: tabs.sessions.icon || <Activity style={{ height: "1rem", width: "1rem" }} />,
405
+ content: tabs.sessions.content,
406
+ });
407
+ }
408
+
409
+ if (tabs.policies) {
410
+ tabDefs.push({
411
+ key: "policies",
412
+ label: tabs.policies.label || "Policies",
413
+ icon: tabs.policies.icon || <Shield style={{ height: "1rem", width: "1rem" }} />,
414
+ content: (
415
+ <LogsTab
416
+ config={tabs.policies as LogsTabConfig<any>}
417
+ refreshInterval={refreshInterval}
418
+ toast={toast}
419
+ invalidateQueries={invalidateQueries}
420
+ pageSizes={pageSizes}
421
+ autoPageSize={autoPageSize}
422
+ components={components}
423
+ />
424
+ ),
425
+ });
426
+ }
427
+
428
+ // Get current isFetching state for refresh button
429
+ const [isFetching, setIsFetching] = useState(false);
430
+
431
+ const { secondsRemaining, refreshNow } = useAutoRefresh({
432
+ intervalSeconds: refreshInterval,
433
+ refresh: async () => {
434
+ // Trigger refetch - actual fetching happens in tabs
435
+ },
436
+ isBlocked: isFetching,
437
+ });
438
+
439
+ return (
440
+ <div style={{ padding: "1.5rem" }} className={className}>
441
+ {/* Header */}
442
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1.5rem", flexWrap: "wrap", gap: "1rem" }}>
443
+ <div>
444
+ <h1 style={{ fontSize: "1.5rem", fontWeight: 600, display: "flex", alignItems: "center", gap: "0.5rem", margin: 0 }}>
445
+ <ScrollText style={{ width: "1.5rem", height: "1.5rem" }} />
446
+ {title}
447
+ </h1>
448
+ <p style={{ fontSize: "0.875rem", color: "#6b7280", margin: "0.25rem 0 0" }}>{description}</p>
449
+ </div>
450
+ <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
451
+ <RefreshButton
452
+ onClick={() => void refreshNow()}
453
+ isRefreshing={isFetching}
454
+ secondsRemaining={secondsRemaining}
455
+ title="Refresh now"
456
+ />
457
+ </div>
458
+ </div>
459
+
460
+ {/* Help text */}
461
+ {helpText && <div style={{ marginBottom: "1rem" }}>{helpText}</div>}
462
+
463
+ {/* Tabs */}
464
+ <TabsView
465
+ tabs={tabDefs}
466
+ activeTab={activeTab}
467
+ onTabChange={handleTabChange}
468
+ components={{
469
+ Tabs: components.Tabs,
470
+ TabsList: components.TabsList,
471
+ TabsTrigger: components.TabsTrigger,
472
+ TabsContent: components.TabsContent,
473
+ Badge: components.Badge,
474
+ }}
475
+ />
476
+ </div>
477
+ );
478
+ }
479
+
480
+ // ============================================================================
481
+ // HELPER FUNCTIONS FOR CREATING COMMON COLUMNS
482
+ // ============================================================================
483
+
484
+ /**
485
+ * Create timestamp column for logs
486
+ */
487
+ export function createLogTimestampColumn<T extends { timestamp?: number; createdAt?: number; time?: number }>(
488
+ options?: { header?: string; field?: "timestamp" | "createdAt" | "time" }
489
+ ): ColumnDef<T> {
490
+ return {
491
+ key: "timestamp",
492
+ header: options?.header || "Timestamp",
493
+ cell: (item) => {
494
+ const field = options?.field || "timestamp";
495
+ let ts = (item as any)[field];
496
+
497
+ // Handle various timestamp formats
498
+ if (field === "createdAt" && ts) {
499
+ ts = ts * 1000; // Convert seconds to ms
500
+ } else if (field === "time") {
501
+ ts = (item as any).time;
502
+ }
503
+
504
+ return (
505
+ <span style={{ fontSize: "0.875rem", whiteSpace: "nowrap" }}>
506
+ {ts ? formatLogTimestamp(ts) : "-"}
507
+ </span>
508
+ );
509
+ },
510
+ };
511
+ }
512
+
513
+ /**
514
+ * Create action column with icon and color
515
+ */
516
+ export function createActionColumn<T extends { action: string }>(): ColumnDef<T> {
517
+ const iconStyle = { height: "1rem", width: "1rem" };
518
+
519
+ const getActionIcon = (action: string) => {
520
+ switch (action) {
521
+ case "created": return <Plus style={iconStyle} />;
522
+ case "approved": return <Check style={iconStyle} />;
523
+ case "rejected":
524
+ case "denied": return <X style={iconStyle} />;
525
+ case "committed": return <Upload style={iconStyle} />;
526
+ case "cancelled": return <Ban style={iconStyle} />;
527
+ default: return <Activity style={iconStyle} />;
528
+ }
529
+ };
530
+
531
+ const getActionColor = (action: string): string => {
532
+ switch (action) {
533
+ case "created": return "#2563eb";
534
+ case "approved": return "#16a34a";
535
+ case "rejected":
536
+ case "denied": return "#dc2626";
537
+ case "committed": return "#9333ea";
538
+ case "cancelled": return "#6b7280";
539
+ default: return "#6b7280";
540
+ }
541
+ };
542
+
543
+ return {
544
+ key: "action",
545
+ header: "Action",
546
+ cell: (item) => (
547
+ <div style={{ display: "flex", alignItems: "center", gap: "0.375rem", fontSize: "0.875rem", fontWeight: 500, color: getActionColor(item.action) }}>
548
+ {getActionIcon(item.action)}
549
+ <span style={{ textTransform: "capitalize" }}>{item.action === "denied" ? "rejected" : item.action}</span>
550
+ </div>
551
+ ),
552
+ };
553
+ }
554
+
555
+ /**
556
+ * Create progress column for policy logs
557
+ */
558
+ export function createLogProgressColumn<T extends { policyThreshold?: number; approvalCount?: number; rejectionCount?: number }>(): ColumnDef<T> {
559
+ return {
560
+ key: "progress",
561
+ header: "Progress",
562
+ cell: (item) => {
563
+ if (!item.policyThreshold) {
564
+ return <span style={{ color: "#6b7280" }}>-</span>;
565
+ }
566
+
567
+ return (
568
+ <div style={{ display: "flex", alignItems: "center", gap: "0.25rem", fontSize: "0.875rem" }}>
569
+ <span style={{ color: "#16a34a" }}>{item.approvalCount || 0}</span>
570
+ <span style={{ color: "#6b7280" }}>/</span>
571
+ <span>{item.policyThreshold}</span>
572
+ {(item.rejectionCount ?? 0) > 0 && (
573
+ <span style={{ color: "#dc2626", fontSize: "0.75rem", marginLeft: "0.25rem" }}>
574
+ ({item.rejectionCount} ✗)
575
+ </span>
576
+ )}
577
+ </div>
578
+ );
579
+ },
580
+ };
581
+ }