@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.
- package/dist/components/ActionCard.d.ts +48 -0
- package/dist/components/ActionCard.d.ts.map +1 -0
- package/dist/components/AnomalyBanner.d.ts +27 -0
- package/dist/components/AnomalyBanner.d.ts.map +1 -0
- package/dist/components/CaseQueueItem.d.ts +35 -0
- package/dist/components/CaseQueueItem.d.ts.map +1 -0
- package/dist/components/ConfidenceBadge.d.ts +19 -0
- package/dist/components/ConfidenceBadge.d.ts.map +1 -0
- package/dist/components/ConfidenceIndicator.d.ts +25 -0
- package/dist/components/ConfidenceIndicator.d.ts.map +1 -0
- package/dist/components/DataGrid.d.ts +6 -0
- package/dist/components/DataGrid.d.ts.map +1 -1
- package/dist/components/EntityCard.d.ts +46 -0
- package/dist/components/EntityCard.d.ts.map +1 -0
- package/dist/components/FunnelChart.d.ts +31 -0
- package/dist/components/FunnelChart.d.ts.map +1 -0
- package/dist/components/MatchIndicator.d.ts +20 -0
- package/dist/components/MatchIndicator.d.ts.map +1 -0
- package/dist/components/PersonaDashboard.d.ts +39 -0
- package/dist/components/PersonaDashboard.d.ts.map +1 -0
- package/dist/components/ProcessHealthBar.d.ts +28 -0
- package/dist/components/ProcessHealthBar.d.ts.map +1 -0
- package/dist/components/ProcessIndicator.d.ts +38 -0
- package/dist/components/ProcessIndicator.d.ts.map +1 -0
- package/dist/components/ReviewDecisionCard.d.ts +53 -0
- package/dist/components/ReviewDecisionCard.d.ts.map +1 -0
- package/dist/components/SLAIndicator.d.ts +24 -0
- package/dist/components/SLAIndicator.d.ts.map +1 -0
- package/dist/components/SplitPane.d.ts +33 -0
- package/dist/components/SplitPane.d.ts.map +1 -0
- package/dist/components/SystemActionEntry.d.ts +42 -0
- package/dist/components/SystemActionEntry.d.ts.map +1 -0
- package/dist/components/VarianceDisplay.d.ts +26 -0
- package/dist/components/VarianceDisplay.d.ts.map +1 -0
- package/dist/components/index.d.ts +32 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/index.d.ts +535 -2
- package/dist/index.esm.js +739 -64
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +753 -62
- package/dist/index.js.map +1 -1
- package/dist/styles.css +369 -0
- package/package.json +1 -1
- package/src/components/ActionCard.tsx +176 -0
- package/src/components/AnomalyBanner.tsx +113 -0
- package/src/components/CaseQueueItem.tsx +145 -0
- package/src/components/ConfidenceBadge.tsx +62 -0
- package/src/components/ConfidenceIndicator.tsx +96 -0
- package/src/components/DataGrid.tsx +159 -21
- package/src/components/EntityCard.tsx +216 -0
- package/src/components/FunnelChart.tsx +160 -0
- package/src/components/MatchIndicator.tsx +73 -0
- package/src/components/PersonaDashboard.tsx +105 -0
- package/src/components/ProcessHealthBar.tsx +107 -0
- package/src/components/ProcessIndicator.tsx +167 -0
- package/src/components/ReviewDecisionCard.tsx +186 -0
- package/src/components/SLAIndicator.tsx +108 -0
- package/src/components/SplitPane.tsx +150 -0
- package/src/components/SystemActionEntry.tsx +175 -0
- package/src/components/VarianceDisplay.tsx +116 -0
- 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
|
-
{
|
|
978
|
-
|
|
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:
|
|
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
|
+
}
|