@papernote/ui 1.10.25 → 1.11.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 (61) hide show
  1. package/dist/components/ActionCard.d.ts +48 -0
  2. package/dist/components/ActionCard.d.ts.map +1 -0
  3. package/dist/components/AnomalyBanner.d.ts +27 -0
  4. package/dist/components/AnomalyBanner.d.ts.map +1 -0
  5. package/dist/components/CaseQueueItem.d.ts +35 -0
  6. package/dist/components/CaseQueueItem.d.ts.map +1 -0
  7. package/dist/components/ConfidenceBadge.d.ts +19 -0
  8. package/dist/components/ConfidenceBadge.d.ts.map +1 -0
  9. package/dist/components/ConfidenceIndicator.d.ts +25 -0
  10. package/dist/components/ConfidenceIndicator.d.ts.map +1 -0
  11. package/dist/components/DataGrid.d.ts +6 -0
  12. package/dist/components/DataGrid.d.ts.map +1 -1
  13. package/dist/components/EntityCard.d.ts +46 -0
  14. package/dist/components/EntityCard.d.ts.map +1 -0
  15. package/dist/components/FunnelChart.d.ts +31 -0
  16. package/dist/components/FunnelChart.d.ts.map +1 -0
  17. package/dist/components/MatchIndicator.d.ts +20 -0
  18. package/dist/components/MatchIndicator.d.ts.map +1 -0
  19. package/dist/components/PersonaDashboard.d.ts +39 -0
  20. package/dist/components/PersonaDashboard.d.ts.map +1 -0
  21. package/dist/components/ProcessHealthBar.d.ts +28 -0
  22. package/dist/components/ProcessHealthBar.d.ts.map +1 -0
  23. package/dist/components/ProcessIndicator.d.ts +38 -0
  24. package/dist/components/ProcessIndicator.d.ts.map +1 -0
  25. package/dist/components/ReviewDecisionCard.d.ts +53 -0
  26. package/dist/components/ReviewDecisionCard.d.ts.map +1 -0
  27. package/dist/components/SLAIndicator.d.ts +24 -0
  28. package/dist/components/SLAIndicator.d.ts.map +1 -0
  29. package/dist/components/SplitPane.d.ts +33 -0
  30. package/dist/components/SplitPane.d.ts.map +1 -0
  31. package/dist/components/SystemActionEntry.d.ts +42 -0
  32. package/dist/components/SystemActionEntry.d.ts.map +1 -0
  33. package/dist/components/VarianceDisplay.d.ts +26 -0
  34. package/dist/components/VarianceDisplay.d.ts.map +1 -0
  35. package/dist/components/index.d.ts +32 -0
  36. package/dist/components/index.d.ts.map +1 -1
  37. package/dist/index.d.ts +535 -2
  38. package/dist/index.esm.js +739 -64
  39. package/dist/index.esm.js.map +1 -1
  40. package/dist/index.js +753 -62
  41. package/dist/index.js.map +1 -1
  42. package/dist/styles.css +369 -0
  43. package/package.json +1 -1
  44. package/src/components/ActionCard.tsx +176 -0
  45. package/src/components/AnomalyBanner.tsx +113 -0
  46. package/src/components/CaseQueueItem.tsx +145 -0
  47. package/src/components/ConfidenceBadge.tsx +62 -0
  48. package/src/components/ConfidenceIndicator.tsx +96 -0
  49. package/src/components/DataGrid.tsx +159 -21
  50. package/src/components/EntityCard.tsx +216 -0
  51. package/src/components/FunnelChart.tsx +160 -0
  52. package/src/components/MatchIndicator.tsx +73 -0
  53. package/src/components/PersonaDashboard.tsx +105 -0
  54. package/src/components/ProcessHealthBar.tsx +107 -0
  55. package/src/components/ProcessIndicator.tsx +167 -0
  56. package/src/components/ReviewDecisionCard.tsx +186 -0
  57. package/src/components/SLAIndicator.tsx +108 -0
  58. package/src/components/SplitPane.tsx +150 -0
  59. package/src/components/SystemActionEntry.tsx +175 -0
  60. package/src/components/VarianceDisplay.tsx +116 -0
  61. package/src/components/index.ts +48 -0
@@ -159,6 +159,12 @@ export interface DataGridProps {
159
159
  className?: string;
160
160
  /** Density */
161
161
  density?: 'compact' | 'normal' | 'comfortable';
162
+ /** Enable virtual scrolling for large datasets (only renders visible rows) */
163
+ virtualized?: boolean;
164
+ /** Row height in pixels when virtualized (default: 40) */
165
+ virtualRowHeight?: number;
166
+ /** Number of rows to render above/below visible area (default: 5) */
167
+ virtualOverscan?: number;
162
168
  }
163
169
 
164
170
  /**
@@ -287,11 +293,15 @@ export const DataGrid = forwardRef<DataGridHandle, DataGridProps>(
287
293
  toolbarActions,
288
294
  className = '',
289
295
  density = 'normal',
296
+ virtualized = false,
297
+ virtualRowHeight = 40,
298
+ virtualOverscan = 5,
290
299
  },
291
300
  ref
292
301
  ) => {
293
302
  // State
294
303
  const [data, setData] = useState<DataGridCell[][]>(initialData);
304
+ const [scrollTop, setScrollTop] = useState(0);
295
305
  const [editingCell, setEditingCell] = useState<{ row: number; col: number } | null>(null);
296
306
  const [editValue, setEditValue] = useState<string>('');
297
307
  const [sortConfig, setSortConfig] = useState<SortConfig | null>(null);
@@ -358,25 +368,18 @@ export const DataGrid = forwardRef<DataGridHandle, DataGridProps>(
358
368
  [groupColorMap]
359
369
  );
360
370
 
361
- // Check if a specific row is frozen
362
- const isRowFrozen = useCallback(
363
- (rowIndex: number) => {
364
- if (frozenRowsState === 'none') return false;
365
- if (frozenRowsState === 'first') return rowIndex === 0;
366
- if (frozenRowsState === 'selected') {
367
- return selectedCell ? rowIndex === selectedCell.row : false;
368
- }
369
- if (typeof frozenRowsState === 'number') return rowIndex < frozenRowsState;
370
- return false;
371
- },
372
- [frozenRowsState, selectedCell]
373
- );
374
-
375
371
  // Update data when initialData changes
376
372
  useEffect(() => {
377
373
  setData(initialData);
378
374
  }, [initialData]);
379
375
 
376
+ // Handle scroll for virtualization
377
+ const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
378
+ if (virtualized) {
379
+ setScrollTop(e.currentTarget.scrollTop);
380
+ }
381
+ }, [virtualized]);
382
+
380
383
  // Get computed data with formulas evaluated
381
384
  // Uses a cache to handle formula dependencies (formulas referencing other formulas)
382
385
  const computedData = useMemo(() => {
@@ -496,6 +499,36 @@ export const DataGrid = forwardRef<DataGridHandle, DataGridProps>(
496
499
  return [...frozenData, ...filtered];
497
500
  }, [sortedData, filters, columns, frozenRows]);
498
501
 
502
+ // Calculate visible rows for virtualization
503
+ const visibleRowRange = useMemo(() => {
504
+ if (!virtualized) {
505
+ return { startIndex: 0, endIndex: filteredData.length, paddingTop: 0, paddingBottom: 0, frozenRowCount: 0 };
506
+ }
507
+
508
+ const containerHeight = typeof height === 'number' ? height : 400;
509
+ const headerHeight = 40; // Approximate header height
510
+ const availableHeight = containerHeight - headerHeight;
511
+
512
+ // Account for frozen rows
513
+ const frozenRowCount = frozenRows;
514
+ const scrollableData = filteredData.slice(frozenRowCount);
515
+
516
+ const visibleCount = Math.ceil(availableHeight / virtualRowHeight);
517
+ const startIndex = Math.max(0, Math.floor(scrollTop / virtualRowHeight) - virtualOverscan);
518
+ const endIndex = Math.min(scrollableData.length, startIndex + visibleCount + (virtualOverscan * 2));
519
+
520
+ const paddingTop = startIndex * virtualRowHeight;
521
+ const paddingBottom = Math.max(0, (scrollableData.length - endIndex) * virtualRowHeight);
522
+
523
+ return {
524
+ startIndex: startIndex + frozenRowCount,
525
+ endIndex: endIndex + frozenRowCount,
526
+ paddingTop,
527
+ paddingBottom,
528
+ frozenRowCount
529
+ };
530
+ }, [virtualized, filteredData.length, scrollTop, height, virtualRowHeight, virtualOverscan, frozenRows]);
531
+
499
532
  // Handle cell edit start
500
533
  const handleCellDoubleClick = useCallback(
501
534
  (rowIndex: number, colIndex: number, cellElement?: HTMLElement) => {
@@ -866,6 +899,7 @@ export const DataGrid = forwardRef<DataGridHandle, DataGridProps>(
866
899
  className="relative overflow-auto border border-stone-200 rounded-lg bg-white"
867
900
  style={{ height }}
868
901
  onKeyDown={handleKeyDown}
902
+ onScroll={handleScroll}
869
903
  tabIndex={0}
870
904
  >
871
905
  <table className="border-collapse" style={{ tableLayout: 'auto' }}>
@@ -974,18 +1008,23 @@ export const DataGrid = forwardRef<DataGridHandle, DataGridProps>(
974
1008
 
975
1009
  {/* Body */}
976
1010
  <tbody>
977
- {filteredData.map((row, rowIndex) => {
978
- const isFrozen = isRowFrozen(rowIndex);
1011
+ {/* Top spacer for virtualization */}
1012
+ {virtualized && visibleRowRange.paddingTop > 0 && (
1013
+ <tr style={{ height: visibleRowRange.paddingTop }}>
1014
+ <td colSpan={columns.length + (rowHeaders ? 1 : 0)} />
1015
+ </tr>
1016
+ )}
1017
+
1018
+ {/* Render frozen rows first (always visible) */}
1019
+ {filteredData.slice(0, visibleRowRange.frozenRowCount).map((row, rowIndex) => {
979
1020
  const isZebra = zebraStripes && rowIndex % 2 === 1;
980
1021
 
981
1022
  return (
982
1023
  <tr
983
- key={rowIndex}
984
- className={`${isZebra ? 'bg-paper-50' : 'bg-white'} ${
985
- isFrozen ? 'sticky z-10' : ''
986
- } ${isFrozen ? 'shadow-sm' : ''}`}
1024
+ key={`frozen-${rowIndex}`}
1025
+ className={`${isZebra ? 'bg-paper-50' : 'bg-white'} sticky z-10 shadow-sm`}
987
1026
  style={{
988
- top: isFrozen ? `${40 + rowIndex * 40}px` : undefined,
1027
+ top: `${40 + rowIndex * virtualRowHeight}px`,
989
1028
  }}
990
1029
  >
991
1030
  {/* Row header */}
@@ -1023,6 +1062,7 @@ export const DataGrid = forwardRef<DataGridHandle, DataGridProps>(
1023
1062
  style={{
1024
1063
  left: isFrozenCol ? leftOffset : undefined,
1025
1064
  minWidth: column?.minWidth || 80,
1065
+ height: virtualized ? virtualRowHeight : undefined,
1026
1066
  }}
1027
1067
  onClick={() => handleCellClick(rowIndex, colIndex)}
1028
1068
  onDoubleClick={(e) => handleCellDoubleClick(rowIndex, colIndex, e.currentTarget)}
@@ -1058,6 +1098,104 @@ export const DataGrid = forwardRef<DataGridHandle, DataGridProps>(
1058
1098
  </tr>
1059
1099
  );
1060
1100
  })}
1101
+
1102
+ {/* Render visible rows (virtualized or all) */}
1103
+ {filteredData
1104
+ .slice(
1105
+ virtualized ? Math.max(visibleRowRange.startIndex, visibleRowRange.frozenRowCount) : visibleRowRange.frozenRowCount,
1106
+ virtualized ? visibleRowRange.endIndex : filteredData.length
1107
+ )
1108
+ .map((row, idx) => {
1109
+ const rowIndex = virtualized
1110
+ ? Math.max(visibleRowRange.startIndex, visibleRowRange.frozenRowCount) + idx
1111
+ : visibleRowRange.frozenRowCount + idx;
1112
+ const isZebra = zebraStripes && rowIndex % 2 === 1;
1113
+
1114
+ return (
1115
+ <tr
1116
+ key={rowIndex}
1117
+ className={`${isZebra ? 'bg-paper-50' : 'bg-white'}`}
1118
+ style={{
1119
+ height: virtualized ? virtualRowHeight : undefined,
1120
+ }}
1121
+ >
1122
+ {/* Row header */}
1123
+ {rowHeaders && (
1124
+ <td
1125
+ className={`${cellPadding} border-b border-r border-stone-200 bg-stone-50 text-ink-500 font-medium sticky left-0 z-10`}
1126
+ style={{ width: 50, minWidth: 50, maxWidth: 50 }}
1127
+ >
1128
+ {Array.isArray(rowHeaders) ? rowHeaders[rowIndex] : rowIndex + 1}
1129
+ </td>
1130
+ )}
1131
+
1132
+ {/* Data cells */}
1133
+ {row.map((cell, colIndex) => {
1134
+ const column = columns[colIndex];
1135
+ const isFrozenCol = colIndex < frozenColumns;
1136
+ const isEditing =
1137
+ editingCell?.row === rowIndex && editingCell?.col === colIndex;
1138
+ const isSelected =
1139
+ selectedCell?.row === rowIndex && selectedCell?.col === colIndex;
1140
+ const hasFormula = !!cell?.formula;
1141
+ const leftOffset = rowHeaders
1142
+ ? 50 + columns.slice(0, colIndex).reduce((sum, c) => sum + (c.width || 100), 0)
1143
+ : columns.slice(0, colIndex).reduce((sum, c) => sum + (c.width || 100), 0);
1144
+ const cellBgClass = getCellBgClass(column, isZebra, isFrozenCol);
1145
+
1146
+ return (
1147
+ <td
1148
+ key={colIndex}
1149
+ className={`${cellPadding} border-b border-r border-stone-200 text-${
1150
+ column?.align || 'left'
1151
+ } ${isFrozenCol ? 'sticky z-10' : ''} ${cellBgClass} ${
1152
+ isSelected ? 'ring-2 ring-inset ring-primary-500' : ''
1153
+ } ${hasFormula ? 'bg-blue-50' : ''} ${cell?.className || ''}`}
1154
+ style={{
1155
+ left: isFrozenCol ? leftOffset : undefined,
1156
+ minWidth: column?.minWidth || 80,
1157
+ }}
1158
+ onClick={() => handleCellClick(rowIndex, colIndex)}
1159
+ onDoubleClick={(e) => handleCellDoubleClick(rowIndex, colIndex, e.currentTarget)}
1160
+ >
1161
+ {isEditing ? (
1162
+ formulas ? (
1163
+ <FormulaAutocomplete
1164
+ value={editValue}
1165
+ onChange={setEditValue}
1166
+ onComplete={handleEditComplete}
1167
+ onCancel={handleEditCancel}
1168
+ anchorRect={editingCellRect}
1169
+ autoFocus
1170
+ />
1171
+ ) : (
1172
+ <input
1173
+ ref={inputRef}
1174
+ type="text"
1175
+ value={editValue}
1176
+ onChange={(e) => setEditValue(e.target.value)}
1177
+ onBlur={handleEditComplete}
1178
+ onKeyDown={handleEditKeyDown}
1179
+ className="w-full h-full border-none outline-none bg-transparent"
1180
+ style={{ margin: '-4px', padding: '4px' }}
1181
+ />
1182
+ )
1183
+ ) : (
1184
+ formatValue(cell?.value, column)
1185
+ )}
1186
+ </td>
1187
+ );
1188
+ })}
1189
+ </tr>
1190
+ );
1191
+ })}
1192
+
1193
+ {/* Bottom spacer for virtualization */}
1194
+ {virtualized && visibleRowRange.paddingBottom > 0 && (
1195
+ <tr style={{ height: visibleRowRange.paddingBottom }}>
1196
+ <td colSpan={columns.length + (rowHeaders ? 1 : 0)} />
1197
+ </tr>
1198
+ )}
1061
1199
  </tbody>
1062
1200
  </table>
1063
1201
  </div>
@@ -0,0 +1,216 @@
1
+ import React from 'react';
2
+ import { MoreHorizontal } from 'lucide-react';
3
+
4
+ // =============================================================================
5
+ // Types & Interfaces
6
+ // =============================================================================
7
+
8
+ export interface EntityCardProps {
9
+ /** Card title (e.g., deal name, case subject) */
10
+ title: string;
11
+ /** Optional subtitle (e.g., account name, contact) */
12
+ subtitle?: string;
13
+ /** Primary value display (e.g., deal amount, case priority) */
14
+ value?: string | React.ReactNode;
15
+ /** Status label */
16
+ status?: string;
17
+ /** Status color variant */
18
+ statusColor?: 'slate' | 'blue' | 'indigo' | 'purple' | 'green' | 'red' | 'yellow' | 'orange' | 'teal';
19
+ /** Assignee name (rendered as avatar initials) */
20
+ assignee?: string;
21
+ /** Progress percentage (0-100) */
22
+ progress?: number;
23
+ /** Additional metadata key-value pairs */
24
+ metadata?: Array<{ label: string; value: string | React.ReactNode }>;
25
+ /** Icon to display */
26
+ icon?: React.ReactNode;
27
+ /** Click handler */
28
+ onClick?: () => void;
29
+ /** Context menu handler */
30
+ onContextMenu?: () => void;
31
+ /** Whether the card is selected */
32
+ selected?: boolean;
33
+ /** Whether the card is draggable (adds visual indicator) */
34
+ draggable?: boolean;
35
+ /** Size variant */
36
+ size?: 'sm' | 'md';
37
+ /** Additional className */
38
+ className?: string;
39
+ /** Confidence score (0-100), shown as a small indicator */
40
+ confidence?: number;
41
+ }
42
+
43
+ // =============================================================================
44
+ // Helpers
45
+ // =============================================================================
46
+
47
+ const statusColors: Record<string, { bg: string; text: string }> = {
48
+ slate: { bg: 'bg-slate-100', text: 'text-slate-700' },
49
+ blue: { bg: 'bg-blue-100', text: 'text-blue-700' },
50
+ indigo: { bg: 'bg-indigo-100', text: 'text-indigo-700' },
51
+ purple: { bg: 'bg-purple-100', text: 'text-purple-700' },
52
+ green: { bg: 'bg-green-100', text: 'text-green-700' },
53
+ red: { bg: 'bg-red-100', text: 'text-red-700' },
54
+ yellow: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
55
+ orange: { bg: 'bg-orange-100', text: 'text-orange-700' },
56
+ teal: { bg: 'bg-teal-100', text: 'text-teal-700' },
57
+ };
58
+
59
+ function getInitials(name: string): string {
60
+ const parts = name.trim().split(/\s+/);
61
+ if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
62
+ return name.slice(0, 2).toUpperCase();
63
+ }
64
+
65
+ // =============================================================================
66
+ // Component
67
+ // =============================================================================
68
+
69
+ /**
70
+ * EntityCard — Standardized card for pipeline/board views
71
+ *
72
+ * Renders a compact card with title, value, status, assignee, and metadata.
73
+ * Designed for use in KanbanBoard columns and process view lists.
74
+ */
75
+ export default function EntityCard({
76
+ title,
77
+ subtitle,
78
+ value,
79
+ status,
80
+ statusColor = 'slate',
81
+ assignee,
82
+ progress,
83
+ metadata,
84
+ icon,
85
+ onClick,
86
+ onContextMenu,
87
+ selected = false,
88
+ draggable = false,
89
+ size = 'md',
90
+ className = '',
91
+ confidence,
92
+ }: EntityCardProps) {
93
+ const isCompact = size === 'sm';
94
+ const colors = statusColors[statusColor];
95
+
96
+ return (
97
+ <div
98
+ onClick={onClick}
99
+ role={onClick ? 'button' : undefined}
100
+ tabIndex={onClick ? 0 : undefined}
101
+ onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick(); } : undefined}
102
+ className={`
103
+ group relative rounded-lg border bg-white dark:bg-ink-900
104
+ ${selected ? 'border-primary-400 ring-2 ring-primary-100' : 'border-paper-200 dark:border-ink-700'}
105
+ ${onClick ? 'cursor-pointer hover:shadow-md hover:border-primary-300 transition-all' : ''}
106
+ ${draggable ? 'cursor-grab active:cursor-grabbing' : ''}
107
+ ${isCompact ? 'p-3' : 'p-4'}
108
+ ${className}
109
+ `}
110
+ >
111
+ {/* Drag handle indicator */}
112
+ {draggable && (
113
+ <div className="absolute left-1 top-1/2 -translate-y-1/2 w-1 h-6 rounded-full bg-ink-200 opacity-0 group-hover:opacity-100 transition-opacity" />
114
+ )}
115
+
116
+ {/* Header row: icon + title + context menu */}
117
+ <div className="flex items-start gap-2">
118
+ {icon && (
119
+ <div className="flex-shrink-0 text-ink-400 mt-0.5">
120
+ {icon}
121
+ </div>
122
+ )}
123
+ <div className="flex-1 min-w-0">
124
+ <h4 className={`font-medium text-ink-900 dark:text-ink-100 truncate ${isCompact ? 'text-sm' : 'text-base'}`}>
125
+ {title}
126
+ </h4>
127
+ {subtitle && (
128
+ <p className={`text-ink-500 dark:text-ink-400 truncate ${isCompact ? 'text-xs' : 'text-sm'}`}>
129
+ {subtitle}
130
+ </p>
131
+ )}
132
+ </div>
133
+ {onContextMenu && (
134
+ <button
135
+ type="button"
136
+ onClick={(e) => { e.stopPropagation(); onContextMenu(); }}
137
+ className="flex-shrink-0 opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-paper-100 transition-opacity"
138
+ aria-label="More options"
139
+ >
140
+ <MoreHorizontal className="h-4 w-4 text-ink-400" />
141
+ </button>
142
+ )}
143
+ </div>
144
+
145
+ {/* Value + Status row */}
146
+ {(value || status) && (
147
+ <div className={`flex items-center justify-between gap-2 ${isCompact ? 'mt-2' : 'mt-3'}`}>
148
+ {value && (
149
+ <span className={`font-semibold text-ink-900 dark:text-ink-100 ${isCompact ? 'text-sm' : 'text-lg'}`}>
150
+ {value}
151
+ </span>
152
+ )}
153
+ {status && colors && (
154
+ <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${colors.bg} ${colors.text}`}>
155
+ {status}
156
+ </span>
157
+ )}
158
+ </div>
159
+ )}
160
+
161
+ {/* Metadata */}
162
+ {metadata && metadata.length > 0 && (
163
+ <div className={`grid grid-cols-2 gap-x-3 gap-y-1 ${isCompact ? 'mt-2' : 'mt-3'}`}>
164
+ {metadata.map((item, idx) => (
165
+ <div key={idx} className="min-w-0">
166
+ <span className="text-xs text-ink-400">{item.label}</span>
167
+ <div className="text-xs text-ink-600 dark:text-ink-300 truncate">{item.value}</div>
168
+ </div>
169
+ ))}
170
+ </div>
171
+ )}
172
+
173
+ {/* Footer row: progress + assignee + confidence */}
174
+ {(progress !== undefined || assignee || confidence !== undefined) && (
175
+ <div className={`flex items-center justify-between gap-2 ${isCompact ? 'mt-2' : 'mt-3'}`}>
176
+ {/* Progress bar */}
177
+ {progress !== undefined && (
178
+ <div className="flex-1 min-w-0">
179
+ <div className="h-1.5 bg-paper-200 rounded-full overflow-hidden">
180
+ <div
181
+ className="h-full bg-primary-500 rounded-full transition-all"
182
+ style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
183
+ />
184
+ </div>
185
+ </div>
186
+ )}
187
+
188
+ <div className="flex items-center gap-2 flex-shrink-0">
189
+ {/* Confidence badge */}
190
+ {confidence !== undefined && (
191
+ <span className={`text-xs px-1.5 py-0.5 rounded ${
192
+ confidence >= 80 ? 'bg-success-50 text-success-700' :
193
+ confidence >= 50 ? 'bg-warning-50 text-warning-700' :
194
+ 'bg-error-50 text-error-700'
195
+ }`}>
196
+ {confidence}%
197
+ </span>
198
+ )}
199
+
200
+ {/* Assignee avatar */}
201
+ {assignee && (
202
+ <div
203
+ className="w-6 h-6 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0"
204
+ title={assignee}
205
+ >
206
+ <span className="text-xs font-medium text-primary-700">
207
+ {getInitials(assignee)}
208
+ </span>
209
+ </div>
210
+ )}
211
+ </div>
212
+ </div>
213
+ )}
214
+ </div>
215
+ );
216
+ }
@@ -0,0 +1,160 @@
1
+ // =============================================================================
2
+ // Types & Interfaces
3
+ // =============================================================================
4
+
5
+ export interface FunnelStage {
6
+ /** Stage name */
7
+ name: string;
8
+ /** Stage count */
9
+ count: number;
10
+ /** Display value (e.g., formatted currency) */
11
+ value?: string;
12
+ /** Color for this stage */
13
+ color?: string;
14
+ }
15
+
16
+ export interface FunnelChartProps {
17
+ /** Funnel stages in order (widest to narrowest) */
18
+ stages: FunnelStage[];
19
+ /** Chart height in pixels */
20
+ height?: number;
21
+ /** Whether to show conversion rates between stages */
22
+ showConversion?: boolean;
23
+ /** Click handler for a stage */
24
+ onStageClick?: (stageName: string) => void;
25
+ /** Additional className */
26
+ className?: string;
27
+ }
28
+
29
+ // =============================================================================
30
+ // Default Colors
31
+ // =============================================================================
32
+
33
+ const defaultColors = [
34
+ '#6366f1', // indigo
35
+ '#8b5cf6', // violet
36
+ '#a78bfa', // violet lighter
37
+ '#c084fc', // purple lighter
38
+ '#d8b4fe', // purple lightest
39
+ '#e9d5ff', // purple very light
40
+ '#f3e8ff', // purple ultra light
41
+ ];
42
+
43
+ // =============================================================================
44
+ // Component
45
+ // =============================================================================
46
+
47
+ /**
48
+ * FunnelChart — SVG funnel visualization for pipeline stages
49
+ *
50
+ * Renders a vertical funnel where each stage's width is proportional
51
+ * to its count relative to the first (widest) stage.
52
+ * Shows stage names, counts, values, and optional conversion rates.
53
+ */
54
+ export default function FunnelChart({
55
+ stages,
56
+ height = 300,
57
+ showConversion = true,
58
+ onStageClick,
59
+ className = '',
60
+ }: FunnelChartProps) {
61
+ if (stages.length === 0) return null;
62
+
63
+ const maxCount = Math.max(...stages.map(s => s.count), 1);
64
+ const stageHeight = height / stages.length;
65
+ const svgWidth = 400;
66
+ const padding = 20;
67
+ const funnelWidth = svgWidth - padding * 2 - 160; // Leave room for labels
68
+
69
+ return (
70
+ <div className={className}>
71
+ <svg width="100%" viewBox={`0 0 ${svgWidth} ${height}`} preserveAspectRatio="xMidYMid meet">
72
+ {stages.map((stage, idx) => {
73
+ const ratio = stage.count / maxCount;
74
+ const nextRatio = idx < stages.length - 1 ? stages[idx + 1].count / maxCount : ratio;
75
+ const y = idx * stageHeight;
76
+ const topWidth = funnelWidth * ratio;
77
+ const bottomWidth = funnelWidth * nextRatio;
78
+ const centerX = padding + funnelWidth / 2;
79
+ const color = stage.color || defaultColors[idx % defaultColors.length];
80
+
81
+ // Conversion rate
82
+ const conversionRate = idx > 0 && stages[idx - 1].count > 0
83
+ ? Math.round((stage.count / stages[idx - 1].count) * 100)
84
+ : null;
85
+
86
+ // Trapezoid path
87
+ const path = `
88
+ M ${centerX - topWidth / 2} ${y + 2}
89
+ L ${centerX + topWidth / 2} ${y + 2}
90
+ L ${centerX + bottomWidth / 2} ${y + stageHeight - 2}
91
+ L ${centerX - bottomWidth / 2} ${y + stageHeight - 2}
92
+ Z
93
+ `;
94
+
95
+ return (
96
+ <g
97
+ key={stage.name}
98
+ onClick={() => onStageClick?.(stage.name)}
99
+ style={onStageClick ? { cursor: 'pointer' } : undefined}
100
+ role={onStageClick ? 'button' : undefined}
101
+ >
102
+ {/* Funnel segment */}
103
+ <path
104
+ d={path}
105
+ fill={color}
106
+ opacity={0.85}
107
+ className="transition-opacity hover:opacity-100"
108
+ />
109
+
110
+ {/* Count inside funnel */}
111
+ <text
112
+ x={centerX}
113
+ y={y + stageHeight / 2 + 1}
114
+ textAnchor="middle"
115
+ dominantBaseline="middle"
116
+ className="fill-white text-xs font-bold"
117
+ style={{ fontSize: '12px' }}
118
+ >
119
+ {stage.count.toLocaleString()}
120
+ </text>
121
+
122
+ {/* Labels on right side */}
123
+ <text
124
+ x={centerX + funnelWidth / 2 + 12}
125
+ y={y + stageHeight / 2 - 6}
126
+ className="fill-ink-700 dark:fill-ink-300"
127
+ style={{ fontSize: '11px', fontWeight: 500 }}
128
+ >
129
+ {stage.name}
130
+ </text>
131
+ {stage.value && (
132
+ <text
133
+ x={centerX + funnelWidth / 2 + 12}
134
+ y={y + stageHeight / 2 + 8}
135
+ className="fill-ink-400"
136
+ style={{ fontSize: '10px' }}
137
+ >
138
+ {stage.value}
139
+ </text>
140
+ )}
141
+
142
+ {/* Conversion rate on left */}
143
+ {showConversion && conversionRate !== null && (
144
+ <text
145
+ x={centerX - funnelWidth / 2 - 8}
146
+ y={y + 4}
147
+ textAnchor="end"
148
+ className="fill-ink-400"
149
+ style={{ fontSize: '10px' }}
150
+ >
151
+ {conversionRate}%
152
+ </text>
153
+ )}
154
+ </g>
155
+ );
156
+ })}
157
+ </svg>
158
+ </div>
159
+ );
160
+ }