Companies | Segment & Stage | Account Owner | Open Deals | Pipeline Value | Win Probability | Activity Trend | Last Interaction | ||
|---|---|---|---|---|---|---|---|---|---|
StrategicExpansion | 7 | $612,400 | 84% | Jan 14 | |||||
EnterpriseRenewal | 6 | $548,900 | 27% | Aug 3 | |||||
ExpansionSMB+2 | 4 | $497,210 | 46% | Mar 25 | |||||
Mid-MarketUpsell+2 | 3 | $451,030 | 39% | Sep 30 | |||||
EnterpriseUpsell+2 | 8 | $438,000 | 71% | Nov 12 | |||||
Mid-MarketCo-Sell | 5 | $402,560 | 22% | Jul 9 | |||||
StrategicExpansion | 9 | $389,120 | 88% | Feb 2 | |||||
EnterpriseNew Logo | 4 | $356,800 | 53% | Jun 26 | |||||
ExpansionUpsell+1 | 6 | $331,470 | 36% | Oct 15 | |||||
EnterpriseStrategic | 5 | $99,870 | 63% | Jan 14 | |||||
Enterprise | 2 | $92,410 | 40% | Aug 3 | |||||
Mid-MarketExpansion | 2 | $87,650 | 33% | Mar 25 | |||||
Enterprise | 4 | $81,200 | 72% | Sep 30 | |||||
Strategic | 3 | $76,540 | 86% | Nov 12 | |||||
SMBRenewal | 1 | $70,980 | 21% | Jul 9 | |||||
Mid-Market | 3 | $64,300 | 44% | Feb 2 | |||||
EnterpriseExpansion | 2 | $58,120 | 58% | Apr 28 | |||||
SMBNew Logo | 2 | $51,890 | 37% | Jun 26 | |||||
Mid-Market | 1 | $44,760 | 66% | Oct 15 | |||||
SMBCo-Sell | 2 | $39,500 | 29% | Dec 8 | |||||
20 Companies in view | + Count unique | + Sum | + Average |
crm-table.tsx
styles.css
prompt.md
Collapse
Reset
Copy Code
Download
Run
1
"use client";
2
3
import React, { useState, useEffect, useRef, useMemo } from 'react';
4
import { createPortal } from 'react-dom';
5
import { cn } from "@/lib/utils";
6
import { Building2, Tag, User2, Briefcase, DollarSign, Trophy, Activity, Clock, MoreHorizontal, Check, ChevronDown, X, ArrowUp, ArrowDown } from "lucide-react";
7
8
// Types
9
type Deal = {
10
id: string;
11
company: { name: string; icon: string; };
12
segments: { name: string; color: string }[];
13
extra?: number;
14
owner: { name: string; avatar: string; };
15
deals: number;
16
value: number;
17
probability: number;
18
trend: number[];
19
interaction: string;
20
}
21
22
const CRM_DATA: Deal[] = [
23
{ id: '1', company: { name: 'OpenAI', icon: 'https://unavatar.io/openai.com' }, segments: [{name: 'Strategic', color: 'red'}, {name: 'Expansion', color: 'cyan'}], owner: { name: 'Maya Chen', avatar: 'https://i.pravatar.cc/150?u=maya' }, deals: 7, value: 612400, probability: 84, trend: [20, 30, 25, 45, 40, 60, 50, 75, 70], interaction: 'Jan 14' },
24
{ id: '2', company: { name: 'Harvey', icon: 'https://unavatar.io/harvey.com' }, segments: [{name: 'Enterprise', color: 'blue'}, {name: 'Renewal', color: 'purple'}], owner: { name: 'Leo Hartman', avatar: 'https://i.pravatar.cc/150?u=leo' }, deals: 6, value: 548900, probability: 27, trend: [60, 50, 55, 40, 45, 30, 35, 20, 25], interaction: 'Aug 3' },
25
{ id: '3', company: { name: 'Browserbase', icon: 'https://unavatar.io/browserbase.com' }, segments: [{name: 'Expansion', color: 'cyan'}, {name: 'SMB', color: 'yellow'}], extra: 2, owner: { name: 'Priya Raman', avatar: 'https://i.pravatar.cc/150?u=priya' }, deals: 4, value: 497210, probability: 46, trend: [40, 35, 50, 45, 60, 55, 45, 50, 40], interaction: 'Mar 25' },
26
{ id: '4', company: { name: 'Cursor', icon: 'https://unavatar.io/cursor.sh' }, segments: [{name: 'Mid-Market', color: 'green'}, {name: 'Upsell', color: 'purple'}], extra: 2, owner: { name: 'Tom Okafor', avatar: 'https://i.pravatar.cc/150?u=tom' }, deals: 3, value: 451030, probability: 39, trend: [50, 45, 55, 40, 35, 45, 30, 40, 35], interaction: 'Sep 30' },
27
{ id: '5', company: { name: 'Notion', icon: 'https://unavatar.io/notion.so' }, segments: [{name: 'Enterprise', color: 'blue'}, {name: 'Upsell', color: 'purple'}], extra: 2, owner: { name: 'Elena Rossi', avatar: 'https://i.pravatar.cc/150?u=elena' }, deals: 8, value: 438000, probability: 71, trend: [40, 50, 45, 60, 55, 70, 65, 80, 75], interaction: 'Nov 12' },
28
{ id: '6', company: { name: 'Granola', icon: 'https://unavatar.io/granola.so' }, segments: [{name: 'Mid-Market', color: 'green'}, {name: 'Co-Sell', color: 'orange'}], owner: { name: 'Sam Whitaker', avatar: 'https://i.pravatar.cc/150?u=sam' }, deals: 5, value: 402560, probability: 22, trend: [30, 25, 35, 20, 25, 15, 20, 10, 15], interaction: 'Jul 9' },
29
{ id: '7', company: { name: 'Ramp', icon: 'https://unavatar.io/ramp.com' }, segments: [{name: 'Strategic', color: 'red'}, {name: 'Expansion', color: 'cyan'}], owner: { name: 'Nora Lind', avatar: 'https://i.pravatar.cc/150?u=nora' }, deals: 9, value: 389120, probability: 88, trend: [60, 70, 65, 80, 75, 90, 85, 100, 95], interaction: 'Feb 2' },
30
{ id: '8', company: { name: 'ElevenLabs', icon: 'https://unavatar.io/elevenlabs.io' }, segments: [{name: 'Enterprise', color: 'blue'}, {name: 'New Logo', color: 'green'}], owner: { name: 'Diego Alvarez', avatar: 'https://i.pravatar.cc/150?u=diego' }, deals: 4, value: 356800, probability: 53, trend: [40, 45, 40, 50, 45, 55, 50, 60, 55], interaction: 'Jun 26' },
31
{ id: '9', company: { name: 'Linear', icon: 'https://unavatar.io/linear.app' }, segments: [{name: 'Expansion', color: 'cyan'}, {name: 'Upsell', color: 'purple'}], extra: 1, owner: { name: 'Hana Sato', avatar: 'https://i.pravatar.cc/150?u=hana' }, deals: 6, value: 331470, probability: 36, trend: [50, 45, 40, 35, 30, 25, 20, 15, 10], interaction: 'Oct 15' },
32
{ id: '10', company: { name: 'Snowflake', icon: 'https://unavatar.io/snowflake.com' }, segments: [{name: 'Enterprise', color: 'blue'}, {name: 'Strategic', color: 'red'}], owner: { name: 'Ruth Adeyemi', avatar: 'https://i.pravatar.cc/150?u=ruth' }, deals: 5, value: 99870, probability: 63, trend: [20, 30, 40, 30, 45, 35, 50, 60, 55], interaction: 'Jan 14' },
33
{ id: '11', company: { name: 'Databricks', icon: 'https://unavatar.io/databricks.com' }, segments: [{name: 'Enterprise', color: 'blue'}], owner: { name: 'Maya Chen', avatar: 'https://i.pravatar.cc/150?u=maya' }, deals: 2, value: 92410, probability: 40, trend: [40, 35, 30, 40, 50, 45, 40, 30, 25], interaction: 'Aug 3' },
34
{ id: '12', company: { name: 'Plaid', icon: 'https://unavatar.io/plaid.com' }, segments: [{name: 'Mid-Market', color: 'green'}, {name: 'Expansion', color: 'cyan'}], owner: { name: 'Leo Hartman', avatar: 'https://i.pravatar.cc/150?u=leo' }, deals: 2, value: 87650, probability: 33, trend: [30, 40, 35, 25, 20, 30, 40, 35, 45], interaction: 'Mar 25' },
35
{ id: '13', company: { name: 'Shopify', icon: 'https://unavatar.io/shopify.com' }, segments: [{name: 'Enterprise', color: 'blue'}], owner: { name: 'Priya Raman', avatar: 'https://i.pravatar.cc/150?u=priya' }, deals: 4, value: 81200, probability: 72, trend: [50, 55, 60, 50, 45, 55, 65, 60, 70], interaction: 'Sep 30' },
36
{ id: '14', company: { name: 'Discord', icon: 'https://unavatar.io/discord.com' }, segments: [{name: 'Strategic', color: 'red'}], owner: { name: 'Tom Okafor', avatar: 'https://i.pravatar.cc/150?u=tom' }, deals: 3, value: 76540, probability: 86, trend: [60, 65, 70, 75, 80, 85, 90, 85, 95], interaction: 'Nov 12' },
37
{ id: '15', company: { name: 'Duolingo', icon: 'https://unavatar.io/duolingo.com' }, segments: [{name: 'SMB', color: 'yellow'}, {name: 'Renewal', color: 'purple'}], owner: { name: 'Elena Rossi', avatar: 'https://i.pravatar.cc/150?u=elena' }, deals: 1, value: 70980, probability: 21, trend: [20, 15, 25, 20, 15, 10, 20, 15, 25], interaction: 'Jul 9' },
38
{ id: '16', company: { name: 'Spotify', icon: 'https://unavatar.io/spotify.com' }, segments: [{name: 'Mid-Market', color: 'green'}], owner: { name: 'Sam Whitaker', avatar: 'https://i.pravatar.cc/150?u=sam' }, deals: 3, value: 64300, probability: 44, trend: [30, 35, 40, 45, 40, 35, 40, 45, 50], interaction: 'Feb 2' },
39
{ id: '17', company: { name: 'Airbnb', icon: 'https://unavatar.io/airbnb.com' }, segments: [{name: 'Enterprise', color: 'blue'}, {name: 'Expansion', color: 'cyan'}], owner: { name: 'Nora Lind', avatar: 'https://i.pravatar.cc/150?u=nora' }, deals: 2, value: 58120, probability: 58, trend: [40, 45, 50, 55, 50, 60, 55, 65, 70], interaction: 'Apr 28' },
40
{ id: '18', company: { name: 'HubSpot', icon: 'https://unavatar.io/hubspot.com' }, segments: [{name: 'SMB', color: 'yellow'}, {name: 'New Logo', color: 'green'}], owner: { name: 'Diego Alvarez', avatar: 'https://i.pravatar.cc/150?u=diego' }, deals: 2, value: 51890, probability: 37, trend: [35, 40, 35, 30, 35, 40, 45, 40, 35], interaction: 'Jun 26' },
41
{ id: '19', company: { name: 'Gusto', icon: 'https://unavatar.io/gusto.com' }, segments: [{name: 'Mid-Market', color: 'green'}], owner: { name: 'Hana Sato', avatar: 'https://i.pravatar.cc/150?u=hana' }, deals: 1, value: 44760, probability: 66, trend: [50, 55, 60, 65, 60, 70, 75, 70, 80], interaction: 'Oct 15' },
42
{ id: '20', company: { name: 'Loom', icon: 'https://unavatar.io/loom.com' }, segments: [{name: 'SMB', color: 'yellow'}, {name: 'Co-Sell', color: 'orange'}], owner: { name: 'Owen Price', avatar: 'https://i.pravatar.cc/150?u=owen' }, deals: 2, value: 39500, probability: 29, trend: [25, 20, 30, 25, 20, 15, 25, 30, 25], interaction: 'Dec 8' }
43
];
44
45
const COLUMNS_CONFIG = [
46
{ id: "Companies", label: "Companies", icon: Building2, minWidth: 160 },
47
{ id: "Segment & Stage", label: "Segment & Stage", icon: Tag, minWidth: 220 },
48
{ id: "Account Owner", label: "Account Owner", icon: User2, minWidth: 160 },
49
{ id: "Open Deals", label: "Open Deals", icon: Briefcase, minWidth: 120 },
50
{ id: "Pipeline Value", label: "Pipeline Value", icon: DollarSign, minWidth: 140 },
51
{ id: "Win Probability", label: "Win Probability", icon: Trophy, minWidth: 160 },
52
{ id: "Activity Trend", label: "Activity Trend", icon: Activity, minWidth: 140 },
53
{ id: "Last Interaction", label: "Last Interaction", icon: Clock, minWidth: 140 },
54
];
55
56
const BADGE_COLORS: Record<string, string> = {
57
red: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
58
blue: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
59
yellow: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-500 border-yellow-500/20",
60
purple: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20",
61
green: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
62
cyan: "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/20",
63
orange: "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20",
64
};
65
66
const formatCurrency = (value: number) => {
67
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(value);
68
};
69
70
const ProbabilityIndicator = ({ prob }: { prob: number }) => {
71
const bars = 5;
72
const activeBars = Math.round((prob / 100) * bars);
73
74
let color = "bg-emerald-500";
75
if (prob < 40) color = "bg-orange-500";
76
else if (prob < 60) color = "bg-yellow-500";
77
78
return (
79
<div className="flex items-center gap-2">
80
<span className="w-8 text-right">{prob}%</span>
81
<div className="flex gap-[2px]">
82
{Array.from({ length: bars }).map((_, i) => (
83
<div
84
key={i}
85
className={cn(
86
"w-1 h-3.5 rounded-[1px] transition-colors",
87
i < activeBars ? color : "bg-black/10 dark:bg-white/10"
88
)}
89
/>
90
))}
91
</div>
92
</div>
93
);
94
};
95
96
const Sparkline = ({ data, prob }: { data: number[], prob: number }) => {
97
let color = "#10b981"; // emerald-500
98
if (prob < 40) color = "#f97316"; // orange-500
99
else if (prob < 60) color = "#eab308"; // yellow-500
100
101
const max = Math.max(...data);
102
const min = Math.min(...data);
103
const range = max - min || 1;
104
const width = 50;
105
const height = 16;
106
107
const points = data.map((d, i) => {
108
const x = (i / (data.length - 1)) * width;
109
const y = height - ((d - min) / range) * height;
110
return `${x},${y}`;
111
}).join(' ');
112
113
return (
114
<svg width={width} height={height} className="overflow-visible">
115
<defs>
116
<linearGradient id={`grad-${color}`} x1="0" y1="0" x2="0" y2="1">
117
<stop offset="0%" stopColor={color} stopOpacity="0.15" />
118
<stop offset="100%" stopColor={color} stopOpacity="0" />
119
</linearGradient>
120
</defs>
121
<polygon points={`0,${height} ${points} ${width},${height}`} fill={`url(#grad-${color})`} />
122
<polyline points={points} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="miter" />
123
</svg>
124
);
125
};
126
127
export function CRMTable({ className, pinnedColumns = ["Companies"], showShadow = true }: { className?: string, pinnedColumns?: string[], showShadow?: boolean }) {
128
const [selected, setSelected] = useState<Set<string>>(new Set());
129
const [sortConfig, setSortConfig] = useState<{key: string, direction: 'asc' | 'desc'} | null>(null);
130
const allSelected = selected.size === CRM_DATA.length;
131
132
const sortedData = useMemo(() => {
133
let sortableItems = [...CRM_DATA];
134
if (sortConfig !== null) {
135
sortableItems.sort((a, b) => {
136
let aValue: any;
137
let bValue: any;
138
139
switch (sortConfig.key) {
140
case 'Companies': aValue = a.company.name; bValue = b.company.name; break;
141
case 'Segment & Stage': aValue = a.segments[0]?.name || ''; bValue = b.segments[0]?.name || ''; break;
142
case 'Account Owner': aValue = a.owner.name; bValue = b.owner.name; break;
143
case 'Open Deals': aValue = a.deals; bValue = b.deals; break;
144
case 'Pipeline Value': aValue = a.value; bValue = b.value; break;
145
case 'Win Probability': aValue = a.probability; bValue = b.probability; break;
146
case 'Activity Trend': aValue = a.trend[a.trend.length - 1]; bValue = b.trend[b.trend.length - 1]; break;
147
case 'Last Interaction': aValue = a.interaction; bValue = b.interaction; break;
148
default: aValue = 0; bValue = 0;
149
}
150
151
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
152
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
153
return 0;
154
});
155
}
156
return sortableItems;
157
}, [sortConfig]);
158
159
// Calculate Column Ordering and Sticky Offsets
160
const pinnedIds = pinnedColumns;
161
const isAnyPinned = pinnedIds.length > 0;
162
const pinnedCols = COLUMNS_CONFIG.filter(c => pinnedIds.includes(c.id)).sort((a, b) => pinnedIds.indexOf(a.id) - pinnedIds.indexOf(b.id));
163
const unpinnedCols = COLUMNS_CONFIG.filter(c => !pinnedIds.includes(c.id));
164
const orderedCols = [...pinnedCols, ...unpinnedCols];
165
const lastPinnedId = pinnedCols.length > 0 ? pinnedCols[pinnedCols.length - 1].id : null;
166
167
let currentLeft = 50; // Initial 50px for the Checkbox column (if sticky)
168
const colsWithOffsets = orderedCols.map(col => {
169
const isPinned = pinnedIds.includes(col.id);
170
const left = currentLeft;
171
if (isPinned) currentLeft += col.minWidth;
172
const isLastPinned = isPinned && col.id === lastPinnedId;
173
return { ...col, isPinned, left, isLastPinned };
174
});
175
176
const requestSort = (key: string) => {
177
let direction: 'asc' | 'desc' = 'asc';
178
if (sortConfig && sortConfig.key === key && sortConfig.direction === 'asc') {
179
direction = 'desc';
180
}
181
setSortConfig({ key, direction });
182
};
183
184
const toggleAll = () => {
185
if (allSelected) setSelected(new Set());
186
else setSelected(new Set(CRM_DATA.map(d => d.id)));
187
};
188
189
const toggleRow = (id: string) => {
190
const newSet = new Set(selected);
191
if (newSet.has(id)) newSet.delete(id);
192
else newSet.add(id);
193
setSelected(newSet);
194
};
195
196
const renderCell = (colId: string, row: Deal) => {
197
switch (colId) {
198
case "Companies":
199
return (
200
<div className="flex items-center gap-3">
201
<img src={row.company.icon} alt={row.company.name} className="w-5 h-5 rounded-[4px] shadow-sm bg-white" />
202
{row.company.name}
203
</div>
204
);
205
case "Segment & Stage":
206
return (
207
<div className="flex items-center gap-2">
208
{row.segments.map(s => (
209
<span key={s.name} className={cn("px-2 py-0.5 rounded-full border text-[11px] font-medium", BADGE_COLORS[s.color])}>
210
{s.name}
211
</span>
212
))}
213
{row.extra && (
214
<span className="px-2 py-0.5 rounded-full border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 text-zinc-600 dark:text-zinc-400 text-[11px] font-medium">
215
+{row.extra}
216
</span>
217
)}
218
</div>
219
);
220
case "Account Owner":
221
return (
222
<div className="flex items-center gap-2.5">
223
<img src={row.owner.avatar} alt={row.owner.name} className="w-5 h-5 rounded-full shadow-sm" />
224
{row.owner.name}
225
</div>
226
);
227
case "Open Deals":
228
return <div className="text-right font-mono text-[12px] pr-2">{row.deals}</div>;
229
case "Pipeline Value":
230
return <div className="text-right font-mono text-[12px] pr-2">{formatCurrency(row.value)}</div>;
231
case "Win Probability":
232
return <ProbabilityIndicator prob={row.probability} />;
233
case "Activity Trend":
234
return <Sparkline data={row.trend} prob={row.probability} />;
235
case "Last Interaction":
236
return <span className="text-zinc-500 dark:text-zinc-400">{row.interaction}</span>;
237
default:
238
return null;
239
}
240
};
241
242
return (
243
<div className={cn("relative w-full max-w-[calc(100vw-32px)] md:max-w-[1050px] font-sans pt-12 mx-auto scale-[0.95] origin-top", className)}>
244
245
{/* Hand-drawn filter indicator */}
246
<div className="absolute top-0 left-[270px] flex flex-col items-center pointer-events-none text-zinc-500 dark:text-zinc-400/80">
247
<span
248
className="text-[17px] tracking-wide"
249
style={{ fontFamily: '"Comic Sans MS", "Chalkboard SE", "Marker Felt", cursive' }}
250
>
251
tap label to filter
252
</span>
253
</div>
254
255
<div className={cn(
256
"w-full bg-white dark:bg-[#151515] rounded-[16px] border border-black/5 dark:border-white/[0.08] overflow-hidden flex flex-col",
257
showShadow && "shadow-sm"
258
)}>
259
<div className="w-full overflow-x-auto overflow-y-auto max-h-[380px] sm:max-h-[460px] md:max-h-[600px] custom-scrollbar [-webkit-overflow-scrolling:touch]">
260
<table className="w-full text-left border-separate border-spacing-0 whitespace-nowrap">
261
<thead className="relative">
262
<tr className="text-[12px] font-medium text-zinc-500 dark:text-zinc-400">
263
<th className={cn(
264
"sticky top-0 left-0 z-50 bg-zinc-50 dark:bg-[#1A1A1A] border-b border-black/[0.04] dark:border-white/[0.06] px-4 py-2.5 w-[50px] min-w-[50px] max-w-[50px] align-middle",
265
!isAnyPinned && "border-r border-black/10 dark:border-white/10"
266
)}>
267
<button
268
onClick={toggleAll}
269
className={cn(
270
"w-4 h-4 rounded-[4px] border flex items-center justify-center transition-colors",
271
allSelected
272
? "bg-zinc-800 border-zinc-800 dark:bg-zinc-200 dark:border-zinc-200 text-white dark:text-black"
273
: "border-black/20 dark:border-white/20 hover:border-black/40 dark:hover:border-white/40"
274
)}
275
>
276
{allSelected && <Check className="w-3 h-3" strokeWidth={3} />}
277
</button>
278
</th>
279
280
{colsWithOffsets.map(col => {
281
const Icon = col.icon;
282
const isSorted = sortConfig?.key === col.id;
283
return (
284
<th
285
key={col.id}
286
onClick={() => requestSort(col.id)}
287
className={cn(
288
"sticky top-0 px-4 py-2.5 border-b border-black/[0.04] dark:border-white/[0.06] align-middle cursor-pointer transition-colors group",
289
col.isPinned
290
? "z-50 bg-zinc-50 dark:bg-[#1A1A1A] hover:bg-[#F0F0F3] dark:hover:bg-[#1C1C1F]"
291
: "z-30 bg-zinc-50 dark:bg-[#1A1A1A] hover:bg-[#F0F0F3] dark:hover:bg-[#1C1C1F]",
292
col.isLastPinned && "border-r border-black/10 dark:border-white/10"
293
)}
294
style={{ minWidth: col.minWidth, ...(col.isPinned ? { left: col.left } : {}) }}
295
>
296
<div className="flex items-center gap-2">
297
<Icon className={cn("w-3.5 h-3.5", isSorted ? "text-zinc-900 dark:text-white" : "")} />
298
<span className={cn(isSorted ? "text-zinc-900 dark:text-white" : "")}>{col.label}</span>
299
{isSorted ? (
300
sortConfig.direction === 'asc' ? <ArrowUp className="w-3.5 h-3.5 ml-auto text-zinc-900 dark:text-white" /> : <ArrowDown className="w-3.5 h-3.5 ml-auto text-zinc-900 dark:text-white" />
301
) : (
302
<ArrowUp className="w-3.5 h-3.5 ml-auto opacity-0 group-hover:opacity-40 transition-opacity" />
303
)}
304
</div>
305
</th>
306
);
307
})}
308
309
<th className="sticky top-0 z-30 bg-zinc-50 dark:bg-[#1A1A1A] border-b border-black/[0.04] dark:border-white/[0.06] px-4 py-2.5 w-[50px] text-center align-middle">
310
</th>
311
</tr>
312
</thead>
313
314
<tbody className="text-[13px] text-zinc-900 dark:text-zinc-200">
315
{sortedData.map((row, i) => {
316
const isSelected = selected.has(row.id);
317
const isEven = i % 2 === 0;
318
319
const rowBg = isSelected
320
? "bg-[#EEF5FF] hover:bg-[#E2EDFF] dark:bg-[#1E293B] dark:hover:bg-[#25354C]"
321
: isEven
322
? "bg-white hover:bg-[#F2F2F5] dark:bg-[#161618] dark:hover:bg-[#1E1E22]"
323
: "bg-[#F9F9FB] hover:bg-[#F2F2F5] dark:bg-[#121214] dark:hover:bg-[#1E1E22]";
324
325
const stickyBg = isSelected
326
? "bg-[#EEF5FF] group-hover:bg-[#E2EDFF] dark:bg-[#1E293B] dark:group-hover:bg-[#25354C]"
327
: isEven
328
? "bg-white group-hover:bg-[#F2F2F5] dark:bg-[#161618] dark:group-hover:bg-[#1E1E22]"
329
: "bg-[#F9F9FB] group-hover:bg-[#F2F2F5] dark:bg-[#121214] dark:group-hover:bg-[#1E1E22]";
330
331
return (
332
<tr
333
key={row.id}
334
className={cn(
335
"group transition-colors",
336
rowBg
337
)}
338
>
339
<td className={cn(
340
"sticky left-0 z-20 px-4 py-2.5 w-[50px] min-w-[50px] max-w-[50px] align-middle transition-colors border-b border-black/[0.04] dark:border-white/[0.06]",
341
stickyBg,
342
!isAnyPinned && "border-r border-black/10 dark:border-white/10"
343
)}>
344
<button
345
onClick={() => toggleRow(row.id)}
346
className={cn(
347
"w-4 h-4 rounded-[4px] border flex items-center justify-center transition-colors",
348
isSelected
349
? "bg-zinc-800 border-zinc-800 dark:bg-zinc-200 dark:border-zinc-200 text-white dark:text-black"
350
: "border-black/20 dark:border-white/20 group-hover:border-black/40 dark:group-hover:border-white/40"
351
)}
352
>
353
{isSelected && <Check className="w-3 h-3" strokeWidth={3} />}
354
</button>
355
</td>
356
357
{colsWithOffsets.map(col => (
358
<td
359
key={col.id}
360
className={cn(
361
"px-4 py-2.5 align-middle transition-colors border-b border-black/[0.04] dark:border-white/[0.06]",
362
col.isPinned && cn("sticky z-20", stickyBg),
363
col.isLastPinned && "border-r border-black/10 dark:border-white/10",
364
col.id === 'Companies' && "font-medium"
365
)}
366
style={{ minWidth: col.minWidth, ...(col.isPinned ? { left: col.left } : {}) }}
367
>
368
{renderCell(col.id, row)}
369
</td>
370
))}
371
372
<td className="px-4 py-2.5 text-center text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 cursor-pointer align-middle border-b border-black/[0.04] dark:border-white/[0.06]">
373
<MoreHorizontal className="w-4 h-4 mx-auto" />
374
</td>
375
</tr>
376
);
377
})}
378
</tbody>
379
380
<tfoot className="relative">
381
<tr className="text-[12px] font-medium text-zinc-500 dark:text-zinc-400">
382
<td className={cn(
383
"sticky bottom-0 left-0 z-50 bg-zinc-50 dark:bg-[#1A1A1A] px-4 py-2.5 border-t border-black/[0.08] dark:border-white/[0.1]",
384
!isAnyPinned && "border-r border-black/10 dark:border-white/10"
385
)} colSpan={1}></td>
386
387
{colsWithOffsets.map((col, idx) => {
388
let content = null;
389
if (idx === 0) {
390
content = (
391
<div className="pl-1">
392
{selected.size > 0 ? (
393
<span className="text-zinc-900 dark:text-zinc-200 font-semibold">{selected.size} of {CRM_DATA.length} selected</span>
394
) : (
395
<span><strong className="text-zinc-900 dark:text-zinc-200">{CRM_DATA.length}</strong> Companies in view</span>
396
)}
397
</div>
398
);
399
} else if (idx === 1) {
400
content = "+ Count unique";
401
} else if (idx === 3) {
402
content = "+ Sum";
403
} else if (idx === 4) {
404
content = "+ Average";
405
}
406
407
return (
408
<td
409
key={col.id}
410
className={cn(
411
"sticky bottom-0 px-4 py-2.5 border-t border-black/[0.08] dark:border-white/[0.1] align-middle transition-colors",
412
col.isPinned ? "z-50 bg-zinc-50 dark:bg-[#1A1A1A]" : "z-30 bg-zinc-50 dark:bg-[#1A1A1A]",
413
col.isLastPinned && "border-r border-black/10 dark:border-white/10",
414
content && typeof content === 'string' && "hover:bg-[#F0F0F3] dark:hover:bg-[#1C1C1F] cursor-pointer"
415
)}
416
style={{ minWidth: col.minWidth, ...(col.isPinned ? { left: col.left } : {}) }}
417
>
418
{content}
419
</td>
420
);
421
})}
422
423
<td className="sticky bottom-0 z-30 bg-zinc-50 dark:bg-[#1A1A1A] border-t border-black/[0.08] dark:border-white/[0.1] px-4 py-2.5 align-middle"></td>
424
</tr>
425
</tfoot>
426
</table>
427
</div>
428
</div>
429
</div>
430
);
431
}
432
433
// Interactive Showcase wrapper
434
export default function CRMTableDemo() {
435
const [mounted, setMounted] = useState(false);
436
const [shadow, setShadow] = useState(true);
437
const [pinned, setPinned] = useState<string[]>(["Companies"]);
438
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
439
const dropdownRef = useRef<HTMLDivElement>(null);
440
441
useEffect(() => {
442
setMounted(true);
443
444
const handleClickOutside = (event: MouseEvent) => {
445
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
446
setIsDropdownOpen(false);
447
}
448
};
449
450
document.addEventListener("mousedown", handleClickOutside);
451
return () => document.removeEventListener("mousedown", handleClickOutside);
452
}, []);
453
454
const togglePin = (id: string) => {
455
setPinned(prev =>
456
prev.includes(id)
457
? prev.filter(p => p !== id)
458
: [...prev, id]
459
);
460
};
461
462
const removePin = (id: string, e: React.MouseEvent) => {
463
e.stopPropagation();
464
setPinned(prev => prev.filter(p => p !== id));
465
};
466
467
const controls = mounted ? createPortal(
468
<div className="flex flex-col gap-5 mb-8 animate-in fade-in slide-in-from-top-4 duration-500">
469
<div className="flex items-center gap-2 text-[13px] font-semibold text-[#1F1F1F] dark:text-[#D4D4D8]">
470
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-[#FBD364]">
471
<path d="M2 12h4l3-9 5 18 3-9h5"/>
472
</svg>
473
Preview
474
</div>
475
476
{/* Shadow Toggle */}
477
<div className="flex items-center justify-between">
478
<span className="text-[13px] text-[#555555] dark:text-[#A1A1AA] font-medium">Shadow</span>
479
<button
480
onClick={() => setShadow(!shadow)}
481
className={cn(
482
"w-8 h-4.5 rounded-full p-[2px] transition-colors relative flex items-center",
483
shadow ? "bg-[#1F1F1F] dark:bg-white" : "bg-black/10 dark:bg-white/10"
484
)}
485
>
486
<div className={cn(
487
"w-3.5 h-3.5 rounded-full transition-all duration-300 shadow-sm",
488
shadow ? "bg-white dark:bg-[#1F1F1F] translate-x-[14px]" : "bg-white dark:bg-[#A1A1AA] translate-x-0"
489
)} />
490
</button>
491
</div>
492
493
{/* Pinned Columns Multiselect */}
494
<div className="flex flex-col gap-2 relative" ref={dropdownRef}>
495
<span className="text-[13px] text-[#555555] dark:text-[#A1A1AA] font-medium">Pinned columns</span>
496
<div
497
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
498
className="flex flex-wrap items-center gap-1.5 p-2 min-h-[38px] rounded-lg border border-black/10 dark:border-white/10 bg-transparent hover:border-black/20 dark:hover:border-white/20 transition-colors cursor-pointer"
499
>
500
{pinned.length === 0 ? (
501
<span className="text-[13px] text-black/40 dark:text-white/40 pl-1">None</span>
502
) : (
503
pinned.map(id => (
504
<span key={id} className="flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-[4px] bg-black/5 dark:bg-white/10 text-[11.5px] font-medium text-black dark:text-white">
505
{id}
506
<div onClick={(e) => removePin(id, e)} className="p-0.5 rounded-[2px] hover:bg-black/10 dark:hover:bg-white/10 transition-colors">
507
<X className="w-3 h-3 opacity-60" />
508
</div>
509
</span>
510
))
511
)}
512
</div>
513
514
{/* Dropdown Menu */}
515
{isDropdownOpen && (
516
<div className="absolute top-full left-0 right-0 mt-1.5 py-1.5 rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-[#1C1C1C] shadow-lg z-50 animate-in fade-in zoom-in-95 duration-200">
517
{COLUMNS_CONFIG.map(col => {
518
const isSelected = pinned.includes(col.id);
519
return (
520
<div
521
key={col.id}
522
onClick={() => togglePin(col.id)}
523
className="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
524
>
525
<div className={cn(
526
"w-4 h-4 rounded-[4px] border flex items-center justify-center transition-colors",
527
isSelected
528
? "bg-zinc-800 border-zinc-800 dark:bg-zinc-200 dark:border-zinc-200 text-white dark:text-black"
529
: "border-black/20 dark:border-white/20"
530
)}>
531
{isSelected && <Check className="w-3 h-3" strokeWidth={3} />}
532
</div>
533
<span className="text-[13px] font-medium text-[#1F1F1F] dark:text-[#F5F5F7]">{col.label}</span>
534
</div>
535
);
536
})}
537
</div>
538
)}
539
</div>
540
</div>,
541
document.getElementById("custom-controls-portal")!
542
) : null;
543
544
return (
545
<>
546
{controls}
547
<CRMTable pinnedColumns={pinned} showShadow={shadow} />
548
</>
549
);
550
}
551

