Seth commited on
Commit
ac07ad9
·
1 Parent(s): 1129913
frontend/package.json CHANGED
@@ -16,7 +16,8 @@
16
  "lucide-react": "^0.344.0",
17
  "class-variance-authority": "^0.7.0",
18
  "clsx": "^2.1.0",
19
- "tailwind-merge": "^2.2.0"
 
20
  },
21
  "devDependencies": {
22
  "@vitejs/plugin-react": "^4.3.1",
 
16
  "lucide-react": "^0.344.0",
17
  "class-variance-authority": "^0.7.0",
18
  "clsx": "^2.1.0",
19
+ "tailwind-merge": "^2.2.0",
20
+ "recharts": "^2.15.1"
21
  },
22
  "devDependencies": {
23
  "@vitejs/plugin-react": "^4.3.1",
frontend/src/App.jsx CHANGED
@@ -4,6 +4,7 @@ import EmailSequenceGenerator from "./pages/EmailSequenceGenerator";
4
  import Contacts from "./pages/Contacts";
5
  import Leads from "./pages/Leads";
6
  import Deals from "./pages/Deals";
 
7
  import Settings from "./pages/Settings";
8
  import "./index.css";
9
 
@@ -15,6 +16,7 @@ export default function App() {
15
  <Route path="/contacts" element={<Contacts />} />
16
  <Route path="/leads" element={<Leads />} />
17
  <Route path="/deals" element={<Deals />} />
 
18
  <Route path="/settings" element={<Settings />} />
19
  <Route path="/history" element={<Navigate to="/leads" replace />} />
20
  </Routes>
 
4
  import Contacts from "./pages/Contacts";
5
  import Leads from "./pages/Leads";
6
  import Deals from "./pages/Deals";
7
+ import SalesDashboard from "./pages/SalesDashboard";
8
  import Settings from "./pages/Settings";
9
  import "./index.css";
10
 
 
16
  <Route path="/contacts" element={<Contacts />} />
17
  <Route path="/leads" element={<Leads />} />
18
  <Route path="/deals" element={<Deals />} />
19
+ <Route path="/dashboard" element={<SalesDashboard />} />
20
  <Route path="/settings" element={<Settings />} />
21
  <Route path="/history" element={<Navigate to="/leads" replace />} />
22
  </Routes>
frontend/src/components/layout/AppShell.jsx CHANGED
@@ -6,6 +6,7 @@ import {
6
  Users,
7
  Inbox,
8
  Handshake,
 
9
  ChevronLeft,
10
  ChevronRight,
11
  } from 'lucide-react';
@@ -18,6 +19,7 @@ const NAV_ITEMS = [
18
  { label: 'Contacts', href: '/contacts', icon: Users },
19
  { label: 'Leads', href: '/leads', icon: Inbox },
20
  { label: 'Deals', href: '/deals', icon: Handshake },
 
21
  ];
22
 
23
  const SIDEBAR_COLLAPSED_KEY = 'sequenceai-sidebar-collapsed';
 
6
  Users,
7
  Inbox,
8
  Handshake,
9
+ PieChart,
10
  ChevronLeft,
11
  ChevronRight,
12
  } from 'lucide-react';
 
19
  { label: 'Contacts', href: '/contacts', icon: Users },
20
  { label: 'Leads', href: '/leads', icon: Inbox },
21
  { label: 'Deals', href: '/deals', icon: Handshake },
22
+ { label: 'Dashboard', href: '/dashboard', icon: PieChart },
23
  ];
24
 
25
  const SIDEBAR_COLLAPSED_KEY = 'sequenceai-sidebar-collapsed';
frontend/src/lib/ownerAvatar.jsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { cn } from '@/lib/utils';
3
+
4
+ /** Stable accent per user id for owner avatars (table, pipeline, charts). */
5
+ const OWNER_AVATAR_PALETTES = [
6
+ { ring: 'border-rose-200', bg: 'bg-rose-100', text: 'text-rose-800' },
7
+ { ring: 'border-amber-200', bg: 'bg-amber-100', text: 'text-amber-900' },
8
+ { ring: 'border-emerald-200', bg: 'bg-emerald-100', text: 'text-emerald-900' },
9
+ { ring: 'border-sky-200', bg: 'bg-sky-100', text: 'text-sky-900' },
10
+ { ring: 'border-violet-200', bg: 'bg-violet-100', text: 'text-violet-800' },
11
+ { ring: 'border-fuchsia-200', bg: 'bg-fuchsia-100', text: 'text-fuchsia-900' },
12
+ { ring: 'border-cyan-200', bg: 'bg-cyan-100', text: 'text-cyan-900' },
13
+ { ring: 'border-orange-200', bg: 'bg-orange-100', text: 'text-orange-900' },
14
+ { ring: 'border-lime-200', bg: 'bg-lime-100', text: 'text-lime-900' },
15
+ { ring: 'border-indigo-200', bg: 'bg-indigo-100', text: 'text-indigo-900' },
16
+ { ring: 'border-teal-200', bg: 'bg-teal-100', text: 'text-teal-900' },
17
+ ];
18
+
19
+ const UNASSIGNED_AVATAR_PALETTE = {
20
+ ring: 'border-slate-200',
21
+ bg: 'bg-slate-100',
22
+ text: 'text-slate-500',
23
+ };
24
+
25
+ function hashStringToUint(s) {
26
+ let h = 0;
27
+ for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
28
+ return Math.abs(h);
29
+ }
30
+
31
+ export function ownerAvatarPalette(userId) {
32
+ if (userId == null || userId === '') return UNASSIGNED_AVATAR_PALETTE;
33
+ const idx = hashStringToUint(String(userId)) % OWNER_AVATAR_PALETTES.length;
34
+ return OWNER_AVATAR_PALETTES[idx];
35
+ }
36
+
37
+ export function ownerDisplayLabel(deal) {
38
+ if (deal.owner_user_id == null || deal.owner_user_id === '') return 'Unassigned';
39
+ return (
40
+ deal.owner_display_name ||
41
+ deal.owner_initials ||
42
+ `User ${deal.owner_user_id}`
43
+ );
44
+ }
45
+
46
+ export function OwnerAvatarCircle({ userId, initials, className }) {
47
+ const pal = ownerAvatarPalette(userId);
48
+ const unassigned = userId == null || userId === '';
49
+ const label = unassigned ? '?' : (initials || '?').toString().slice(0, 2).toUpperCase();
50
+ return (
51
+ <span
52
+ className={cn(
53
+ 'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border text-xs font-semibold leading-none',
54
+ pal.ring,
55
+ pal.bg,
56
+ pal.text,
57
+ className
58
+ )}
59
+ aria-hidden
60
+ >
61
+ {label}
62
+ </span>
63
+ );
64
+ }
frontend/src/pages/Deals.jsx CHANGED
@@ -14,6 +14,7 @@ import { DealContactSearch, DealCompanySearch } from '@/components/workspace/Dea
14
  import { EditableCell, EditableDateCell } from '@/components/workspace/EditableCell';
15
  import { SearchableCountryPicker } from '@/components/workspace/SearchableCountryPicker';
16
  import { cn } from '@/lib/utils';
 
17
  import { flagEmojiFromCode, getAllCountries, matchCountry } from '@/lib/countries';
18
 
19
  const STAGES = [
@@ -29,68 +30,6 @@ const EMPTY_COUNTRY_KEY = '__country_none__';
29
  const EMPTY_OWNER_KEY = '__owner_none__';
30
  const MEMBER_TAKE_OWNERSHIP_VALUE = '__member_take_ownership__';
31
 
32
- /** Stable accent per user id for owner avatars (table, pipeline, grouped headers). */
33
- const OWNER_AVATAR_PALETTES = [
34
- { ring: 'border-rose-200', bg: 'bg-rose-100', text: 'text-rose-800' },
35
- { ring: 'border-amber-200', bg: 'bg-amber-100', text: 'text-amber-900' },
36
- { ring: 'border-emerald-200', bg: 'bg-emerald-100', text: 'text-emerald-900' },
37
- { ring: 'border-sky-200', bg: 'bg-sky-100', text: 'text-sky-900' },
38
- { ring: 'border-violet-200', bg: 'bg-violet-100', text: 'text-violet-800' },
39
- { ring: 'border-fuchsia-200', bg: 'bg-fuchsia-100', text: 'text-fuchsia-900' },
40
- { ring: 'border-cyan-200', bg: 'bg-cyan-100', text: 'text-cyan-900' },
41
- { ring: 'border-orange-200', bg: 'bg-orange-100', text: 'text-orange-900' },
42
- { ring: 'border-lime-200', bg: 'bg-lime-100', text: 'text-lime-900' },
43
- { ring: 'border-indigo-200', bg: 'bg-indigo-100', text: 'text-indigo-900' },
44
- { ring: 'border-teal-200', bg: 'bg-teal-100', text: 'text-teal-900' },
45
- ];
46
-
47
- const UNASSIGNED_AVATAR_PALETTE = {
48
- ring: 'border-slate-200',
49
- bg: 'bg-slate-100',
50
- text: 'text-slate-500',
51
- };
52
-
53
- function hashStringToUint(s) {
54
- let h = 0;
55
- for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
56
- return Math.abs(h);
57
- }
58
-
59
- function ownerAvatarPalette(userId) {
60
- if (userId == null || userId === '') return UNASSIGNED_AVATAR_PALETTE;
61
- const idx = hashStringToUint(String(userId)) % OWNER_AVATAR_PALETTES.length;
62
- return OWNER_AVATAR_PALETTES[idx];
63
- }
64
-
65
- function ownerDisplayLabel(deal) {
66
- if (deal.owner_user_id == null || deal.owner_user_id === '') return 'Unassigned';
67
- return (
68
- deal.owner_display_name ||
69
- deal.owner_initials ||
70
- `User ${deal.owner_user_id}`
71
- );
72
- }
73
-
74
- function OwnerAvatarCircle({ userId, initials, className }) {
75
- const pal = ownerAvatarPalette(userId);
76
- const unassigned = userId == null || userId === '';
77
- const label = unassigned ? '?' : (initials || '?').toString().slice(0, 2).toUpperCase();
78
- return (
79
- <span
80
- className={cn(
81
- 'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border text-xs font-semibold leading-none',
82
- pal.ring,
83
- pal.bg,
84
- pal.text,
85
- className
86
- )}
87
- aria-hidden
88
- >
89
- {label}
90
- </span>
91
- );
92
- }
93
-
94
  /** Accent bars for By Country / By Owner groups (rotate). */
95
  const GROUP_BAR_ROTATING = [
96
  'border-l-[6px] border-l-indigo-500 bg-indigo-50/75',
 
14
  import { EditableCell, EditableDateCell } from '@/components/workspace/EditableCell';
15
  import { SearchableCountryPicker } from '@/components/workspace/SearchableCountryPicker';
16
  import { cn } from '@/lib/utils';
17
+ import { OwnerAvatarCircle, ownerDisplayLabel } from '@/lib/ownerAvatar';
18
  import { flagEmojiFromCode, getAllCountries, matchCountry } from '@/lib/countries';
19
 
20
  const STAGES = [
 
30
  const EMPTY_OWNER_KEY = '__owner_none__';
31
  const MEMBER_TAKE_OWNERSHIP_VALUE = '__member_take_ownership__';
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  /** Accent bars for By Country / By Owner groups (rotate). */
34
  const GROUP_BAR_ROTATING = [
35
  'border-l-[6px] border-l-indigo-500 bg-indigo-50/75',
frontend/src/pages/SalesDashboard.jsx ADDED
@@ -0,0 +1,676 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { Link } from 'react-router-dom';
3
+ import {
4
+ Bar,
5
+ BarChart,
6
+ CartesianGrid,
7
+ Cell,
8
+ Legend,
9
+ Pie,
10
+ PieChart,
11
+ ResponsiveContainer,
12
+ Tooltip,
13
+ XAxis,
14
+ YAxis,
15
+ } from 'recharts';
16
+ import { Info, Loader2 } from 'lucide-react';
17
+ import AppShell from '@/components/layout/AppShell';
18
+ import { Select, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/select';
19
+ import { apiFetch } from '@/lib/api';
20
+ import { cn } from '@/lib/utils';
21
+ import { OwnerAvatarCircle } from '@/lib/ownerAvatar';
22
+
23
+ const STAGE_ORDER = ['new', 'discovery', 'proposal', 'negotiation', 'won', 'lost'];
24
+ const FUNNEL_STAGES = ['new', 'discovery', 'proposal', 'negotiation', 'won'];
25
+
26
+ const STAGE_META = {
27
+ new: { label: 'New', fill: '#7c3aed' },
28
+ discovery: { label: 'Discovery', fill: '#06b6d4' },
29
+ proposal: { label: 'Proposal', fill: '#0ea5e9' },
30
+ negotiation: { label: 'Negotiation', fill: '#0d9488' },
31
+ won: { label: 'Won', fill: '#059669' },
32
+ lost: { label: 'Lost', fill: '#ef4444' },
33
+ };
34
+
35
+ function normalizeStage(s) {
36
+ const v = (s || 'new').toLowerCase();
37
+ return STAGE_ORDER.includes(v) ? v : 'new';
38
+ }
39
+
40
+ function isOpenStage(stage) {
41
+ const s = normalizeStage(stage);
42
+ return s !== 'won' && s !== 'lost';
43
+ }
44
+
45
+ function fmtMoney(n) {
46
+ if (n == null || n === '' || Number.isNaN(n)) return '—';
47
+ return new Intl.NumberFormat('en-US', {
48
+ style: 'currency',
49
+ currency: 'USD',
50
+ maximumFractionDigits: 0,
51
+ }).format(n);
52
+ }
53
+
54
+ function fmtMoneyCompact(n) {
55
+ if (n == null || n === '' || Number.isNaN(n)) return '—';
56
+ return new Intl.NumberFormat('en-US', {
57
+ style: 'currency',
58
+ currency: 'USD',
59
+ notation: 'compact',
60
+ maximumFractionDigits: 1,
61
+ }).format(n);
62
+ }
63
+
64
+ function monthKeyFromDealDate(iso) {
65
+ if (!iso || typeof iso !== 'string') return null;
66
+ const m = iso.slice(0, 7);
67
+ return /^\d{4}-\d{2}$/.test(m) ? m : null;
68
+ }
69
+
70
+ function forecastOnDeal(d) {
71
+ const v = d.forecast_value;
72
+ if (v == null || v === '') return 0;
73
+ const n = Number(v);
74
+ return Number.isFinite(n) ? n : 0;
75
+ }
76
+
77
+ function dealValueNum(d) {
78
+ const v = d.deal_value;
79
+ if (v == null || v === '') return null;
80
+ const n = Number(v);
81
+ return Number.isFinite(n) ? n : null;
82
+ }
83
+
84
+ function CardShell({ title, hint, children, className }) {
85
+ return (
86
+ <section
87
+ className={cn(
88
+ 'flex flex-col rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden',
89
+ className
90
+ )}
91
+ >
92
+ <div className="border-b border-slate-100 px-4 py-3">
93
+ <div className="flex items-start justify-between gap-2">
94
+ <h3 className="text-sm font-semibold text-slate-900">{title}</h3>
95
+ {hint ? (
96
+ <span className="group relative shrink-0 text-slate-400 hover:text-slate-600">
97
+ <Info className="h-4 w-4" aria-hidden />
98
+ <span className="pointer-events-none absolute right-0 top-full z-10 mt-1 hidden w-64 rounded-lg border border-slate-200 bg-white p-2 text-xs font-normal leading-snug text-slate-600 shadow-md group-hover:block">
99
+ {hint}
100
+ </span>
101
+ </span>
102
+ ) : null}
103
+ </div>
104
+ </div>
105
+ <div className="min-h-[220px] flex-1 p-3">{children}</div>
106
+ </section>
107
+ );
108
+ }
109
+
110
+ function KpiCard({ title, hint, value, sub }) {
111
+ return (
112
+ <div className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
113
+ <div className="flex items-start justify-between gap-2">
114
+ <h3 className="text-sm font-medium text-slate-600">{title}</h3>
115
+ {hint ? (
116
+ <span className="group relative shrink-0 text-slate-400 hover:text-slate-600">
117
+ <Info className="h-4 w-4" aria-hidden />
118
+ <span className="pointer-events-none absolute right-0 top-full z-10 mt-1 hidden w-64 rounded-lg border border-slate-200 bg-white p-2 text-xs font-normal leading-snug text-slate-600 shadow-md group-hover:block">
119
+ {hint}
120
+ </span>
121
+ </span>
122
+ ) : null}
123
+ </div>
124
+ <p className="mt-3 text-2xl font-bold tabular-nums tracking-tight text-slate-900 sm:text-3xl">
125
+ {value}
126
+ </p>
127
+ {sub ? <p className="mt-1 text-xs text-slate-500">{sub}</p> : null}
128
+ </div>
129
+ );
130
+ }
131
+
132
+ const TOOLTIP_STYLE = {
133
+ borderRadius: '8px',
134
+ border: '1px solid #e2e8f0',
135
+ fontSize: '12px',
136
+ };
137
+
138
+ export default function SalesDashboard() {
139
+ const [deals, setDeals] = useState([]);
140
+ const [loading, setLoading] = useState(true);
141
+ const [me, setMe] = useState(null);
142
+ const [tenantMembers, setTenantMembers] = useState([]);
143
+ const [ownerFilter, setOwnerFilter] = useState('all');
144
+
145
+ const isAdmin = me?.current_role === 'admin';
146
+
147
+ useEffect(() => {
148
+ apiFetch('/api/auth/me')
149
+ .then((r) => (r.ok ? r.json() : null))
150
+ .then(setMe)
151
+ .catch(() => setMe(null));
152
+ }, []);
153
+
154
+ useEffect(() => {
155
+ if (!me || !isAdmin) {
156
+ setTenantMembers([]);
157
+ return;
158
+ }
159
+ apiFetch('/api/tenants/members')
160
+ .then((r) => (r.ok ? r.json() : null))
161
+ .then((d) => setTenantMembers(d?.members || []))
162
+ .catch(() => setTenantMembers([]));
163
+ }, [me, isAdmin]);
164
+
165
+ const fetchDeals = useCallback(async () => {
166
+ setLoading(true);
167
+ try {
168
+ const params = new URLSearchParams();
169
+ params.set('limit', '500');
170
+ params.set('offset', '0');
171
+ params.set('sort_by', 'created_at');
172
+ params.set('sort_dir', 'desc');
173
+ const res = await apiFetch(`/api/deals?${params.toString()}`);
174
+ if (res.ok) {
175
+ const data = await res.json();
176
+ setDeals(data.deals || []);
177
+ }
178
+ } catch (e) {
179
+ console.error(e);
180
+ } finally {
181
+ setLoading(false);
182
+ }
183
+ }, []);
184
+
185
+ useEffect(() => {
186
+ fetchDeals();
187
+ }, [fetchDeals]);
188
+
189
+ useEffect(() => {
190
+ if (!isAdmin) setOwnerFilter('all');
191
+ }, [isAdmin]);
192
+
193
+ const visibleDeals = useMemo(() => {
194
+ if (!isAdmin || ownerFilter === 'all') return deals;
195
+ const id = Number(ownerFilter);
196
+ if (!Number.isFinite(id)) return deals;
197
+ return deals.filter((d) => d.owner_user_id != null && Number(d.owner_user_id) === id);
198
+ }, [deals, isAdmin, ownerFilter]);
199
+
200
+ const metrics = useMemo(() => {
201
+ const open = visibleDeals.filter((d) => isOpenStage(d.stage));
202
+ const won = visibleDeals.filter((d) => normalizeStage(d.stage) === 'won');
203
+
204
+ const forecastBoardTotal = visibleDeals.reduce((a, d) => a + forecastOnDeal(d), 0);
205
+ const actualRevenueTotal = won.reduce((a, d) => {
206
+ const v = dealValueNum(d);
207
+ return v != null ? a + v : a;
208
+ }, 0);
209
+ const wonValues = won.map(dealValueNum).filter((v) => v != null);
210
+ const avgWon =
211
+ wonValues.length > 0 ? wonValues.reduce((a, b) => a + b, 0) / wonValues.length : null;
212
+
213
+ const stageCounts = {};
214
+ STAGE_ORDER.forEach((s) => {
215
+ stageCounts[s] = 0;
216
+ });
217
+ for (const d of visibleDeals) {
218
+ const s = normalizeStage(d.stage);
219
+ stageCounts[s] = (stageCounts[s] || 0) + 1;
220
+ }
221
+ const pieData = STAGE_ORDER.filter((s) => stageCounts[s] > 0).map((s) => ({
222
+ name: STAGE_META[s].label,
223
+ value: stageCounts[s],
224
+ fill: STAGE_META[s].fill,
225
+ }));
226
+
227
+ const forecastByStage = STAGE_ORDER.filter((s) => s !== 'won' && s !== 'lost').map((s) => ({
228
+ stage: STAGE_META[s].label,
229
+ forecast: open
230
+ .filter((d) => normalizeStage(d.stage) === s)
231
+ .reduce((a, d) => a + forecastOnDeal(d), 0),
232
+ }));
233
+
234
+ const forecastMonthMap = new Map();
235
+ let undatedForecast = 0;
236
+ for (const d of open) {
237
+ const mk = monthKeyFromDealDate(d.expected_close_date);
238
+ const fv = forecastOnDeal(d);
239
+ if (!mk) {
240
+ undatedForecast += fv;
241
+ continue;
242
+ }
243
+ forecastMonthMap.set(mk, (forecastMonthMap.get(mk) || 0) + fv);
244
+ }
245
+ const forecastByMonth = [...forecastMonthMap.entries()]
246
+ .sort(([a], [b]) => a.localeCompare(b))
247
+ .map(([month, forecast]) => ({ month, forecast }));
248
+ if (undatedForecast > 0) {
249
+ forecastByMonth.push({ month: 'Undated', forecast: undatedForecast });
250
+ }
251
+
252
+ const actualMonthMap = new Map();
253
+ for (const d of won) {
254
+ const mk =
255
+ monthKeyFromDealDate(d.expected_close_date) ||
256
+ monthKeyFromDealDate(d.created_at);
257
+ if (!mk) continue;
258
+ const v = dealValueNum(d);
259
+ if (v == null) continue;
260
+ actualMonthMap.set(mk, (actualMonthMap.get(mk) || 0) + v);
261
+ }
262
+ const actualByMonth = [...actualMonthMap.entries()]
263
+ .sort(([a], [b]) => a.localeCompare(b))
264
+ .map(([month, actual]) => ({ month, actual }));
265
+
266
+ const cohortMap = new Map();
267
+ for (const d of visibleDeals) {
268
+ const mk = monthKeyFromDealDate(d.created_at);
269
+ if (!mk) continue;
270
+ if (!cohortMap.has(mk)) {
271
+ const row = { month: mk };
272
+ STAGE_ORDER.forEach((s) => {
273
+ row[s] = 0;
274
+ });
275
+ cohortMap.set(mk, row);
276
+ }
277
+ const row = cohortMap.get(mk);
278
+ const s = normalizeStage(d.stage);
279
+ row[s] = (row[s] || 0) + 1;
280
+ }
281
+ const dealProgressByMonth = [...cohortMap.values()].sort((a, b) =>
282
+ a.month.localeCompare(b.month)
283
+ );
284
+
285
+ const repMap = new Map();
286
+ for (const d of visibleDeals) {
287
+ const key = d.owner_user_id == null || d.owner_user_id === '' ? 'none' : String(d.owner_user_id);
288
+ if (!repMap.has(key)) {
289
+ repMap.set(key, {
290
+ repKey: key,
291
+ ownerUserId: d.owner_user_id == null || d.owner_user_id === '' ? null : d.owner_user_id,
292
+ owner_initials: d.owner_initials,
293
+ owner_display_name: d.owner_display_name,
294
+ sortLabel: (
295
+ d.owner_display_name ||
296
+ d.owner_initials ||
297
+ (key === 'none' ? 'Unassigned' : `User ${d.owner_user_id}`)
298
+ ).trim(),
299
+ });
300
+ STAGE_ORDER.forEach((s) => {
301
+ repMap.get(key)[s] = 0;
302
+ });
303
+ }
304
+ const row = repMap.get(key);
305
+ const s = normalizeStage(d.stage);
306
+ row[s] = (row[s] || 0) + 1;
307
+ }
308
+ const dealsByRep = [...repMap.values()].sort((a, b) => {
309
+ if (a.repKey === 'none') return 1;
310
+ if (b.repKey === 'none') return -1;
311
+ return a.sortLabel.localeCompare(b.sortLabel, undefined, { sensitivity: 'base' });
312
+ });
313
+
314
+ const funnelCounts = FUNNEL_STAGES.map((s) => ({
315
+ stage: s,
316
+ label: STAGE_META[s].label,
317
+ count: visibleDeals.filter((d) => normalizeStage(d.stage) === s).length,
318
+ }));
319
+ const maxFunnel = Math.max(1, ...funnelCounts.map((f) => f.count));
320
+
321
+ return {
322
+ forecastBoardTotal,
323
+ actualRevenueTotal,
324
+ avgWon,
325
+ pieData,
326
+ forecastByStage,
327
+ forecastByMonth,
328
+ actualByMonth,
329
+ dealProgressByMonth,
330
+ dealsByRep,
331
+ funnelCounts,
332
+ maxFunnel,
333
+ };
334
+ }, [visibleDeals]);
335
+
336
+ const repRowByKey = useMemo(
337
+ () => new Map(metrics.dealsByRep.map((r) => [r.repKey, r])),
338
+ [metrics.dealsByRep]
339
+ );
340
+
341
+ const ownerFilterLabel = useMemo(() => {
342
+ if (ownerFilter === 'all') return 'All team';
343
+ const m = tenantMembers.find((x) => String(x.user_id) === ownerFilter);
344
+ return (m?.name || m?.email || `User ${ownerFilter}`).trim();
345
+ }, [ownerFilter, tenantMembers]);
346
+
347
+ const conversionToWon =
348
+ visibleDeals.length > 0
349
+ ? (
350
+ (visibleDeals.filter((d) => normalizeStage(d.stage) === 'won').length /
351
+ visibleDeals.length) *
352
+ 100
353
+ ).toFixed(1)
354
+ : '0.0';
355
+
356
+ const subtitle = isAdmin
357
+ ? ownerFilter === 'all'
358
+ ? 'Workspace totals. Use the people filter to focus on one rep.'
359
+ : 'Filtered to the selected team member.'
360
+ : 'Your deals and unassigned deals in this workspace.';
361
+
362
+ return (
363
+ <AppShell
364
+ title="Sales Dashboard"
365
+ subtitle={subtitle}
366
+ rightContent={
367
+ isAdmin ? (
368
+ <div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
369
+ <span className="hidden text-xs text-slate-500 sm:inline">View</span>
370
+ <Select value={ownerFilter} onValueChange={setOwnerFilter}>
371
+ <SelectTrigger className="h-9 w-[min(100%,14rem)] border-slate-200 bg-white text-sm shadow-sm">
372
+ <span className="truncate">{ownerFilterLabel}</span>
373
+ </SelectTrigger>
374
+ <SelectContent className="min-w-[12rem]">
375
+ <SelectItem value="all">All team</SelectItem>
376
+ {tenantMembers.map((m) => (
377
+ <SelectItem key={m.user_id} value={String(m.user_id)}>
378
+ {(m.name || m.email || `User ${m.user_id}`).trim()}
379
+ </SelectItem>
380
+ ))}
381
+ </SelectContent>
382
+ </Select>
383
+ </div>
384
+ ) : null
385
+ }
386
+ >
387
+ {loading ? (
388
+ <div className="flex items-center justify-center gap-2 py-24 text-slate-600">
389
+ <Loader2 className="h-6 w-6 animate-spin" aria-hidden />
390
+ Loading dashboard…
391
+ </div>
392
+ ) : (
393
+ <div className="space-y-6">
394
+ <div className="flex flex-wrap items-center justify-between gap-3">
395
+ <p className="text-sm text-slate-600 max-w-3xl">
396
+ Charts use the same deal fields as the pipeline board. Actual revenue uses deal
397
+ value on <strong className="font-medium text-slate-800">won</strong> deals (there is
398
+ no separate &quot;actual value&quot; column yet). Revenue-by-month uses{' '}
399
+ <strong className="font-medium text-slate-800">expected close</strong> when set,
400
+ otherwise <strong className="font-medium text-slate-800">created date</strong>.
401
+ </p>
402
+ <Link
403
+ to="/deals"
404
+ className="text-sm font-medium text-violet-700 hover:text-violet-900 underline-offset-2 hover:underline"
405
+ >
406
+ Open Deals board
407
+ </Link>
408
+ </div>
409
+
410
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
411
+ <KpiCard
412
+ title="Forecasted revenue"
413
+ hint="Sum of the Forecast value column for every deal currently shown — same totals as on the Sales Pipeline board."
414
+ value={fmtMoney(metrics.forecastBoardTotal)}
415
+ sub="All deals in view"
416
+ />
417
+ <KpiCard
418
+ title="Actual revenue"
419
+ hint="Sum of deal value for deals marked won — your closed-won total on the board."
420
+ value={fmtMoney(metrics.actualRevenueTotal)}
421
+ sub="Won deals"
422
+ />
423
+ <KpiCard
424
+ title="Avg. value of won deals"
425
+ hint="Average deal value among won deals that have an amount set."
426
+ value={metrics.avgWon != null ? fmtMoney(metrics.avgWon) : '—'}
427
+ sub={metrics.avgWon != null ? 'Won deals with value' : 'No amounts yet'}
428
+ />
429
+ </div>
430
+
431
+ <CardShell
432
+ title="Pipeline conversion"
433
+ hint="Deal counts at each stage from New through Won. Percentages show step-to-step retention; overall conversion to won compares won deals to all deals shown."
434
+ >
435
+ <div className="space-y-3">
436
+ <div className="flex flex-wrap items-center justify-between gap-2 text-xs text-slate-600">
437
+ <span>
438
+ Conversion to won (all deals in view):{' '}
439
+ <strong className="text-slate-900">{conversionToWon}%</strong>
440
+ </span>
441
+ </div>
442
+ <div className="flex flex-col gap-2">
443
+ {metrics.funnelCounts.map((step, i) => {
444
+ const pct = Math.round((step.count / metrics.maxFunnel) * 100);
445
+ const next = metrics.funnelCounts[i + 1];
446
+ const stepRate =
447
+ next && step.count > 0
448
+ ? ((next.count / step.count) * 100).toFixed(1)
449
+ : null;
450
+ return (
451
+ <div key={step.stage} className="flex flex-col gap-1">
452
+ <div className="flex items-center justify-between text-xs text-slate-600">
453
+ <span className="font-medium text-slate-800">
454
+ {step.label}
455
+ </span>
456
+ <span className="tabular-nums">{step.count}</span>
457
+ </div>
458
+ <div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-100">
459
+ <div
460
+ className="h-full rounded-full transition-all"
461
+ style={{
462
+ width: `${pct}%`,
463
+ backgroundColor: STAGE_META[step.stage].fill,
464
+ }}
465
+ />
466
+ </div>
467
+ {stepRate != null ? (
468
+ <div className="text-[11px] text-slate-500">
469
+ → {next.label}: {stepRate}%
470
+ </div>
471
+ ) : null}
472
+ </div>
473
+ );
474
+ })}
475
+ </div>
476
+ </div>
477
+ </CardShell>
478
+
479
+ <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
480
+ <CardShell
481
+ title="Sales pipeline"
482
+ hint="How many deals sit in each stage right now — use it to see where work piles up."
483
+ >
484
+ {metrics.pieData.length === 0 ? (
485
+ <p className="py-12 text-center text-sm text-slate-500">No deals to chart.</p>
486
+ ) : (
487
+ <ResponsiveContainer width="100%" height={280}>
488
+ <PieChart>
489
+ <Pie
490
+ data={metrics.pieData}
491
+ dataKey="value"
492
+ nameKey="name"
493
+ innerRadius={48}
494
+ outerRadius={100}
495
+ paddingAngle={1}
496
+ label={({ name, percent }) =>
497
+ `${name} ${(percent * 100).toFixed(0)}%`
498
+ }
499
+ >
500
+ {metrics.pieData.map((entry, i) => (
501
+ <Cell key={i} fill={entry.fill} />
502
+ ))}
503
+ </Pie>
504
+ <Tooltip contentStyle={TOOLTIP_STYLE} />
505
+ </PieChart>
506
+ </ResponsiveContainer>
507
+ )}
508
+ </CardShell>
509
+
510
+ <CardShell
511
+ title="Actual revenue by month (won)"
512
+ hint="Closed-won deal value grouped by month. Month is expected close when set, otherwise the deal created date."
513
+ >
514
+ {metrics.actualByMonth.length === 0 ? (
515
+ <p className="py-12 text-center text-sm text-slate-500">
516
+ No won deals with dates yet.
517
+ </p>
518
+ ) : (
519
+ <ResponsiveContainer width="100%" height={280}>
520
+ <BarChart data={metrics.actualByMonth} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
521
+ <CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
522
+ <XAxis dataKey="month" tick={{ fontSize: 11 }} stroke="#64748b" />
523
+ <YAxis
524
+ tick={{ fontSize: 11 }}
525
+ stroke="#64748b"
526
+ tickFormatter={(v) => fmtMoneyCompact(v)}
527
+ />
528
+ <Tooltip
529
+ formatter={(v) => fmtMoney(v)}
530
+ contentStyle={TOOLTIP_STYLE}
531
+ />
532
+ <Bar dataKey="actual" name="Actual" fill="#059669" radius={[4, 4, 0, 0]} />
533
+ </BarChart>
534
+ </ResponsiveContainer>
535
+ )}
536
+ </CardShell>
537
+ </div>
538
+
539
+ <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
540
+ <CardShell
541
+ title="Forecasted revenue by month"
542
+ hint="Open pipeline forecast totals by expected close month (plus Undated when no close date)."
543
+ >
544
+ {metrics.forecastByMonth.length === 0 ? (
545
+ <p className="py-12 text-center text-sm text-slate-500">
546
+ No forecast in view.
547
+ </p>
548
+ ) : (
549
+ <ResponsiveContainer width="100%" height={280}>
550
+ <BarChart data={metrics.forecastByMonth} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
551
+ <CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
552
+ <XAxis dataKey="month" tick={{ fontSize: 11 }} stroke="#64748b" />
553
+ <YAxis
554
+ tick={{ fontSize: 11 }}
555
+ stroke="#64748b"
556
+ tickFormatter={(v) => fmtMoneyCompact(v)}
557
+ />
558
+ <Tooltip
559
+ formatter={(v) => fmtMoney(v)}
560
+ contentStyle={TOOLTIP_STYLE}
561
+ />
562
+ <Bar dataKey="forecast" name="Forecast" fill="#6366f1" radius={[4, 4, 0, 0]} />
563
+ </BarChart>
564
+ </ResponsiveContainer>
565
+ )}
566
+ </CardShell>
567
+
568
+ <CardShell
569
+ title="Forecasted revenue by stage"
570
+ hint="Sum of forecast value on open deals, split by current stage."
571
+ >
572
+ <ResponsiveContainer width="100%" height={280}>
573
+ <BarChart data={metrics.forecastByStage} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
574
+ <CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
575
+ <XAxis dataKey="stage" tick={{ fontSize: 11 }} stroke="#64748b" />
576
+ <YAxis
577
+ tick={{ fontSize: 11 }}
578
+ stroke="#64748b"
579
+ tickFormatter={(v) => fmtMoneyCompact(v)}
580
+ />
581
+ <Tooltip formatter={(v) => fmtMoney(v)} contentStyle={TOOLTIP_STYLE} />
582
+ <Bar dataKey="forecast" name="Forecast" fill="#7c3aed" radius={[4, 4, 0, 0]} />
583
+ </BarChart>
584
+ </ResponsiveContainer>
585
+ </CardShell>
586
+ </div>
587
+
588
+ <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
589
+ <CardShell
590
+ title="Deal progress by month added"
591
+ hint="Each bar is deals created in that month, stacked by where they sit today — cohort view of the pipeline."
592
+ >
593
+ {metrics.dealProgressByMonth.length === 0 ? (
594
+ <p className="py-12 text-center text-sm text-slate-500">No deals with dates.</p>
595
+ ) : (
596
+ <ResponsiveContainer width="100%" height={300}>
597
+ <BarChart data={metrics.dealProgressByMonth} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
598
+ <CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
599
+ <XAxis dataKey="month" tick={{ fontSize: 11 }} stroke="#64748b" />
600
+ <YAxis allowDecimals={false} tick={{ fontSize: 11 }} stroke="#64748b" />
601
+ <Tooltip contentStyle={TOOLTIP_STYLE} />
602
+ <Legend wrapperStyle={{ fontSize: 12 }} />
603
+ {STAGE_ORDER.map((s) => (
604
+ <Bar
605
+ key={s}
606
+ dataKey={s}
607
+ name={STAGE_META[s].label}
608
+ stackId="cohort"
609
+ fill={STAGE_META[s].fill}
610
+ />
611
+ ))}
612
+ </BarChart>
613
+ </ResponsiveContainer>
614
+ )}
615
+ </CardShell>
616
+
617
+ <CardShell
618
+ title="Deal stages by rep"
619
+ hint="Stacked deal counts by owner. Assign owners on the Deals board to split work here. Avatars use the same colors as elsewhere in the app."
620
+ >
621
+ {metrics.dealsByRep.length === 0 ? (
622
+ <p className="py-12 text-center text-sm text-slate-500">No deals.</p>
623
+ ) : (
624
+ <ResponsiveContainer width="100%" height={300}>
625
+ <BarChart data={metrics.dealsByRep} margin={{ top: 8, right: 8, left: 0, bottom: 12 }}>
626
+ <CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
627
+ <XAxis
628
+ dataKey="repKey"
629
+ type="category"
630
+ interval={0}
631
+ height={56}
632
+ tick={({ x, y, payload }) => {
633
+ const row = repRowByKey.get(payload.value);
634
+ return (
635
+ <g transform={`translate(${x},${y})`}>
636
+ <foreignObject x={-18} y={4} width={36} height={36}>
637
+ <div className="flex justify-center">
638
+ <OwnerAvatarCircle
639
+ userId={row?.ownerUserId}
640
+ initials={row?.owner_initials}
641
+ className="h-8 w-8"
642
+ />
643
+ </div>
644
+ </foreignObject>
645
+ </g>
646
+ );
647
+ }}
648
+ />
649
+ <YAxis allowDecimals={false} tick={{ fontSize: 11 }} stroke="#64748b" />
650
+ <Tooltip
651
+ contentStyle={TOOLTIP_STYLE}
652
+ labelFormatter={(k) => {
653
+ const row = repRowByKey.get(k);
654
+ return row?.sortLabel || k;
655
+ }}
656
+ />
657
+ <Legend wrapperStyle={{ fontSize: 12 }} />
658
+ {STAGE_ORDER.map((s) => (
659
+ <Bar
660
+ key={s}
661
+ dataKey={s}
662
+ name={STAGE_META[s].label}
663
+ stackId="rep"
664
+ fill={STAGE_META[s].fill}
665
+ />
666
+ ))}
667
+ </BarChart>
668
+ </ResponsiveContainer>
669
+ )}
670
+ </CardShell>
671
+ </div>
672
+ </div>
673
+ )}
674
+ </AppShell>
675
+ );
676
+ }