Seth commited on
Commit
1a77d3d
·
1 Parent(s): 1c32e69
backend/app/deal_revenue.py CHANGED
@@ -1,13 +1,13 @@
1
  """
2
- Quarterly recurring metrics in USD from won deals.
3
 
4
- Uses deal **revenue_type** with PO line **subtotals** (same as won popover):
5
- - mrr — line amounts are **monthly** recurring totals → ARR = subtotal × 12
6
- - qrr — line amounts are **quarterly** recurring totals → ARR = subtotal × 4
7
- - arr — line amounts are **annual** recurring totals → ARR = subtotal
8
- - one_time no recurring MRR; one-time USD = sum of lines in win quarter
9
 
10
- Portfolio MRR = ARR ÷ 12 per deal. No FX; amounts are treated as USD ($).
11
  """
12
 
13
  from __future__ import annotations
@@ -35,28 +35,83 @@ def deal_line_subtotal(lines: list[dict[str, Any]]) -> float:
35
  return round(sum(line_amount_native(li) for li in lines), 2)
36
 
37
 
38
- def deal_recurring_arr_usd(revenue_type: str, lines: list[dict[str, Any]]) -> float:
39
- """Annual recurring revenue (USD) implied by deal type + invoice lines."""
40
- rt = (revenue_type or "arr").strip().lower()
41
- sub = deal_line_subtotal(lines)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  if rt == "one_time":
43
  return 0.0
 
44
  if rt == "mrr":
45
  return round(sub * 12.0, 2)
46
  if rt == "qrr":
47
  return round(sub * 4.0, 2)
48
  if rt == "arr":
49
  return round(sub, 2)
50
- return round(sub, 2)
51
 
52
 
53
- def deal_recurring_mrr_usd(revenue_type: str, lines: list[dict[str, Any]]) -> float:
54
- arr = deal_recurring_arr_usd(revenue_type, lines)
55
- return round(arr / 12.0, 2)
 
 
56
 
57
 
58
- def deal_onetime_usd(lines: list[dict[str, Any]]) -> float:
59
- return deal_line_subtotal(lines)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
 
62
  @dataclass(frozen=True)
@@ -123,9 +178,6 @@ def build_quarterly_board(
123
  deals: list[dict[str, Any]],
124
  max_quarters: int = 10,
125
  ) -> dict[str, Any]:
126
- """
127
- deals: each dict needs revenue_type, won_line_items (list), won_at (optional).
128
- """
129
  if not deals:
130
  return {
131
  "currency_display": "USD",
@@ -165,7 +217,7 @@ def build_quarterly_board(
165
  quarters = quarters[-max_quarters:]
166
 
167
  rows: list[dict[str, Any]] = []
168
- prev_total_mrr = 0.0
169
  prev_total_contracts = 0
170
 
171
  for qs in quarters:
@@ -177,12 +229,12 @@ def build_quarterly_board(
177
  return False
178
  return start <= won_at <= end
179
 
180
- new_mrr = 0.0
181
  new_contracts = 0
182
- one_time_usd = 0.0
183
  one_time_deals: list[dict[str, Any]] = []
184
 
185
- total_mrr_end = 0.0
186
  total_contracts_end = 0
187
 
188
  for deal in normalized:
@@ -190,35 +242,34 @@ def build_quarterly_board(
190
  rt = deal["revenue_type"]
191
  won_at = deal["won_at"]
192
 
193
- if rt == "one_time":
194
- if won_at and won_at <= end:
195
- ot = deal_onetime_usd(lines)
196
- if in_quarter(won_at):
197
- one_time_usd += ot
198
- one_time_deals.append(
199
- {
200
- "deal_id": deal["id"],
201
- "name": deal["name"],
202
- "amount_usd": ot,
203
- "won_at": won_at.isoformat() if won_at else None,
204
- }
205
- )
206
- continue
207
 
208
- mrr = deal_recurring_mrr_usd(rt, lines)
209
  if won_at is None or won_at > end:
210
  continue
211
- total_mrr_end += mrr
212
- total_contracts_end += 1
213
-
214
- if in_quarter(won_at):
215
- new_mrr += mrr
216
- new_contracts += 1
217
 
218
- existing_mrr = prev_total_mrr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  existing_contracts = prev_total_contracts
220
 
221
- portfolio_check = round(existing_mrr + new_mrr, 2)
222
  rows.append(
223
  {
224
  "label": qs.label,
@@ -226,28 +277,23 @@ def build_quarterly_board(
226
  "quarter": qs.quarter,
227
  "start": start.isoformat(),
228
  "end": end.isoformat(),
229
- "mrr": {
230
- "existing": round(existing_mrr, 2),
231
- "new": round(new_mrr, 2),
232
- "total": round(total_mrr_end, 2),
233
- },
234
- "arr_usd": {
235
- "existing": round(existing_mrr * 12, 2),
236
- "new": round(new_mrr * 12, 2),
237
- "total": round(total_mrr_end * 12, 2),
238
  },
239
  "contracts": {
240
  "existing": existing_contracts,
241
  "new": new_contracts,
242
  "total": total_contracts_end,
243
  },
244
- "one_time_usd_quarter": round(one_time_usd, 2),
245
  "one_time_deals": one_time_deals,
246
- "portfolio_matches_rollforward": abs(portfolio_check - round(total_mrr_end, 2)) < 0.02,
247
  }
248
  )
249
 
250
- prev_total_mrr = total_mrr_end
251
  prev_total_contracts = total_contracts_end
252
 
253
  return {
 
1
  """
2
+ Quarterly ARR (USD) roll-forward from won deals, using each PO line's billing interval.
3
 
4
+ Per line:
5
+ - monthly → ARR contribution = amount × 12
6
+ - quarterly → ARR contribution = amount × 4
7
+ - annual → ARR contribution = amount × 1
8
+ - one_time excluded from ARR; summed into one-time revenue for the win quarter
9
 
10
+ Legacy won snapshots without line billing_interval fall back to deal revenue_type (previous behavior).
11
  """
12
 
13
  from __future__ import annotations
 
35
  return round(sum(line_amount_native(li) for li in lines), 2)
36
 
37
 
38
+ def _lines_have_explicit_interval(lines: list[dict[str, Any]]) -> bool:
39
+ return any(isinstance(li, dict) and "billing_interval" in li for li in lines)
40
+
41
+
42
+ def _norm_interval(raw: Any) -> str:
43
+ if raw is None:
44
+ return "monthly"
45
+ s = str(raw).strip().lower()
46
+ if s in ("one_time", "onetime", "one-time", "non-recurring"):
47
+ return "one_time"
48
+ if s in ("month", "monthly", "every month", "m"):
49
+ return "monthly"
50
+ if s in ("quarter", "quarterly", "every 3 months", "q"):
51
+ return "quarterly"
52
+ if s in ("year", "annual", "yearly", "every year", "a"):
53
+ return "annual"
54
+ return "monthly"
55
+
56
+
57
+ def _legacy_deal_recurring_arr(lines: list[dict[str, Any]], deal_revenue_type: str) -> float:
58
+ """Old rows with no per-line billing_interval; use deal-level revenue type."""
59
+ rt = (deal_revenue_type or "arr").strip().lower()
60
  if rt == "one_time":
61
  return 0.0
62
+ sub = deal_line_subtotal(lines)
63
  if rt == "mrr":
64
  return round(sub * 12.0, 2)
65
  if rt == "qrr":
66
  return round(sub * 4.0, 2)
67
  if rt == "arr":
68
  return round(sub, 2)
69
+ return round(sub * 12.0, 2)
70
 
71
 
72
+ def _legacy_deal_onetime(lines: list[dict[str, Any]], deal_revenue_type: str) -> float:
73
+ rt = (deal_revenue_type or "arr").strip().lower()
74
+ if rt == "one_time":
75
+ return deal_line_subtotal(lines)
76
+ return 0.0
77
 
78
 
79
+ def deal_recurring_arr_usd(lines: list[dict[str, Any]], deal_revenue_type: str = "arr") -> float:
80
+ """Annual recurring revenue (USD) from recurring lines only."""
81
+ if not lines:
82
+ return 0.0
83
+ if not _lines_have_explicit_interval(lines):
84
+ return _legacy_deal_recurring_arr(lines, deal_revenue_type)
85
+
86
+ total = 0.0
87
+ for li in lines:
88
+ inv = _norm_interval(li.get("billing_interval"))
89
+ if inv == "one_time":
90
+ continue
91
+ amt = line_amount_native(li)
92
+ if inv == "monthly":
93
+ total += amt * 12.0
94
+ elif inv == "quarterly":
95
+ total += amt * 4.0
96
+ elif inv == "annual":
97
+ total += amt
98
+ else:
99
+ total += amt * 12.0
100
+ return round(total, 2)
101
+
102
+
103
+ def deal_onetime_usd(lines: list[dict[str, Any]], deal_revenue_type: str = "arr") -> float:
104
+ """Non-recurring USD from one-time lines (and legacy all–one-time deals)."""
105
+ if not lines:
106
+ return 0.0
107
+ if not _lines_have_explicit_interval(lines):
108
+ return round(_legacy_deal_onetime(lines, deal_revenue_type), 2)
109
+
110
+ total = 0.0
111
+ for li in lines:
112
+ if _norm_interval(li.get("billing_interval")) == "one_time":
113
+ total += line_amount_native(li)
114
+ return round(total, 2)
115
 
116
 
117
  @dataclass(frozen=True)
 
178
  deals: list[dict[str, Any]],
179
  max_quarters: int = 10,
180
  ) -> dict[str, Any]:
 
 
 
181
  if not deals:
182
  return {
183
  "currency_display": "USD",
 
217
  quarters = quarters[-max_quarters:]
218
 
219
  rows: list[dict[str, Any]] = []
220
+ prev_total_arr = 0.0
221
  prev_total_contracts = 0
222
 
223
  for qs in quarters:
 
229
  return False
230
  return start <= won_at <= end
231
 
232
+ new_arr = 0.0
233
  new_contracts = 0
234
+ one_time_sum = 0.0
235
  one_time_deals: list[dict[str, Any]] = []
236
 
237
+ total_arr_end = 0.0
238
  total_contracts_end = 0
239
 
240
  for deal in normalized:
 
242
  rt = deal["revenue_type"]
243
  won_at = deal["won_at"]
244
 
245
+ arr_r = deal_recurring_arr_usd(lines, rt)
246
+ ot_amt = deal_onetime_usd(lines, rt)
 
 
 
 
 
 
 
 
 
 
 
 
247
 
 
248
  if won_at is None or won_at > end:
249
  continue
 
 
 
 
 
 
250
 
251
+ if arr_r > 0:
252
+ total_arr_end += arr_r
253
+ total_contracts_end += 1
254
+ if in_quarter(won_at):
255
+ new_arr += arr_r
256
+ new_contracts += 1
257
+
258
+ if ot_amt > 0 and in_quarter(won_at):
259
+ one_time_sum += ot_amt
260
+ one_time_deals.append(
261
+ {
262
+ "deal_id": deal["id"],
263
+ "name": deal["name"],
264
+ "amount_usd": ot_amt,
265
+ "won_at": won_at.isoformat() if won_at else None,
266
+ }
267
+ )
268
+
269
+ existing_arr = prev_total_arr
270
  existing_contracts = prev_total_contracts
271
 
272
+ portfolio_check = round(existing_arr + new_arr, 2)
273
  rows.append(
274
  {
275
  "label": qs.label,
 
277
  "quarter": qs.quarter,
278
  "start": start.isoformat(),
279
  "end": end.isoformat(),
280
+ "arr": {
281
+ "existing": round(existing_arr, 2),
282
+ "new": round(new_arr, 2),
283
+ "total": round(total_arr_end, 2),
 
 
 
 
 
284
  },
285
  "contracts": {
286
  "existing": existing_contracts,
287
  "new": new_contracts,
288
  "total": total_contracts_end,
289
  },
290
+ "one_time_usd_quarter": round(one_time_sum, 2),
291
  "one_time_deals": one_time_deals,
292
+ "portfolio_matches_rollforward": abs(portfolio_check - round(total_arr_end, 2)) < 0.02,
293
  }
294
  )
295
 
296
+ prev_total_arr = total_arr_end
297
  prev_total_contracts = total_contracts_end
298
 
299
  return {
backend/app/main.py CHANGED
@@ -18,6 +18,7 @@ import asyncio
18
  import math
19
  import re
20
  from datetime import datetime, timedelta
 
21
 
22
  from .database import (
23
  get_db,
@@ -550,6 +551,41 @@ def _deal_access_or_403(row: CrmDeal, t: TenantContext) -> None:
550
  raise HTTPException(status_code=403, detail="You do not have access to this deal")
551
 
552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
553
  def _deal_to_dict(d: CrmDeal) -> dict:
554
  fv = None
555
  if d.deal_value is not None and d.close_probability is not None:
@@ -2795,11 +2831,12 @@ async def list_deals(
2795
  async def quarterly_recurring_metrics(
2796
  owner_user_id: Optional[int] = Query(None),
2797
  max_quarters: int = Query(12, ge=1, le=24),
 
2798
  t: TenantContext = Depends(get_tenant_context),
2799
  ):
2800
  """
2801
- Won deals → recurring MRR/ARR (USD) by calendar quarter: Existing / New / Total roll-forward,
2802
- plus one-time USD in quarter (revenue_type one_time). Uses deal Revenue type + PO line subtotals.
2803
  """
2804
  db = t.db
2805
  q = db.query(CrmDeal).filter(
@@ -2811,15 +2848,19 @@ async def quarterly_recurring_metrics(
2811
  elif owner_user_id is not None:
2812
  q = q.filter(CrmDeal.owner_user_id == int(owner_user_id))
2813
  rows = q.all()
 
2814
  deals_payload = []
2815
  for r in rows:
 
 
 
2816
  deals_payload.append(
2817
  {
2818
  "id": r.id,
2819
  "name": r.name or "",
2820
  "revenue_type": (getattr(r, "revenue_type", None) or "arr"),
2821
  "won_line_items": _won_line_items_list(r),
2822
- "won_at": getattr(r, "won_at", None),
2823
  }
2824
  )
2825
  return build_quarterly_board(deals_payload, max_quarters=max_quarters)
 
18
  import math
19
  import re
20
  from datetime import datetime, timedelta
21
+ from calendar import monthrange
22
 
23
  from .database import (
24
  get_db,
 
551
  raise HTTPException(status_code=403, detail="You do not have access to this deal")
552
 
553
 
554
+ def _dashboard_period_bounds_utc(period: str) -> Optional[tuple[datetime, datetime]]:
555
+ """Bounds for filtering won deals by won_at (dashboard date presets). None = all time."""
556
+ p = (period or "all").strip().lower()
557
+ if p in ("", "all"):
558
+ return None
559
+ now = datetime.utcnow()
560
+ if p == "month":
561
+ start = datetime(now.year, now.month, 1)
562
+ last = monthrange(now.year, now.month)[1]
563
+ end = datetime(now.year, now.month, last, 23, 59, 59)
564
+ return start, end
565
+ if p == "quarter":
566
+ qi = (now.month - 1) // 3
567
+ sm = qi * 3 + 1
568
+ start = datetime(now.year, sm, 1)
569
+ em = sm + 2
570
+ if em == 12:
571
+ end = datetime(now.year, 12, 31, 23, 59, 59)
572
+ else:
573
+ end = datetime(now.year, em + 1, 1) - timedelta(microseconds=1)
574
+ return start, end
575
+ if p == "year":
576
+ return datetime(now.year, 1, 1), datetime(now.year, 12, 31, 23, 59, 59)
577
+ return None
578
+
579
+
580
+ def _won_at_within_bounds(won_at: Optional[datetime], bounds: Optional[tuple[datetime, datetime]]) -> bool:
581
+ if bounds is None:
582
+ return True
583
+ if won_at is None:
584
+ return False
585
+ wdt = won_at.replace(tzinfo=None) if getattr(won_at, "tzinfo", None) else won_at
586
+ return bounds[0] <= wdt <= bounds[1]
587
+
588
+
589
  def _deal_to_dict(d: CrmDeal) -> dict:
590
  fv = None
591
  if d.deal_value is not None and d.close_probability is not None:
 
2831
  async def quarterly_recurring_metrics(
2832
  owner_user_id: Optional[int] = Query(None),
2833
  max_quarters: int = Query(12, ge=1, le=24),
2834
+ period: str = Query("all"),
2835
  t: TenantContext = Depends(get_tenant_context),
2836
  ):
2837
  """
2838
+ Won deals → ARR (USD) roll-forward by calendar quarter from PO lines (interval per line + one-time lines).
2839
+ Optional period=all|month|quarter|year filters by won_at (same presets as the sales dashboard).
2840
  """
2841
  db = t.db
2842
  q = db.query(CrmDeal).filter(
 
2848
  elif owner_user_id is not None:
2849
  q = q.filter(CrmDeal.owner_user_id == int(owner_user_id))
2850
  rows = q.all()
2851
+ bounds = _dashboard_period_bounds_utc(period)
2852
  deals_payload = []
2853
  for r in rows:
2854
+ wa = getattr(r, "won_at", None)
2855
+ if not _won_at_within_bounds(wa, bounds):
2856
+ continue
2857
  deals_payload.append(
2858
  {
2859
  "id": r.id,
2860
  "name": r.name or "",
2861
  "revenue_type": (getattr(r, "revenue_type", None) or "arr"),
2862
  "won_line_items": _won_line_items_list(r),
2863
+ "won_at": wa,
2864
  }
2865
  )
2866
  return build_quarterly_board(deals_payload, max_quarters=max_quarters)
backend/app/models.py CHANGED
@@ -102,8 +102,8 @@ class WonLineItemIn(BaseModel):
102
  description: str = Field(default="", max_length=4000)
103
  qty: float = Field(..., gt=0, le=1_000_000)
104
  rate: float = Field(..., ge=0, le=1_000_000_000)
105
- # Recurring revenue / spreadsheet columns: amount per interval × annualization (matches manual ARR sheet).
106
- billing_interval: Literal["monthly", "quarterly", "annual"] = "monthly"
107
  currency: Literal["CAD", "USD"] = "USD"
108
 
109
  @field_validator("product_service", mode="before")
@@ -126,6 +126,8 @@ class WonLineItemIn(BaseModel):
126
  if v is None:
127
  return "monthly"
128
  s = v.strip().lower() if isinstance(v, str) else "monthly"
 
 
129
  if s in ("month", "monthly", "every month", "m"):
130
  return "monthly"
131
  if s in ("quarter", "quarterly", "every 3 months", "q"):
 
102
  description: str = Field(default="", max_length=4000)
103
  qty: float = Field(..., gt=0, le=1_000_000)
104
  rate: float = Field(..., ge=0, le=1_000_000_000)
105
+ # Per-line cadence for ARR vs one-time (dashboard uses these; legacy deals may omit).
106
+ billing_interval: Literal["monthly", "quarterly", "annual", "one_time"] = "monthly"
107
  currency: Literal["CAD", "USD"] = "USD"
108
 
109
  @field_validator("product_service", mode="before")
 
126
  if v is None:
127
  return "monthly"
128
  s = v.strip().lower() if isinstance(v, str) else "monthly"
129
+ if s in ("one_time", "onetime", "one-time", "non-recurring"):
130
+ return "one_time"
131
  if s in ("month", "monthly", "every month", "m"):
132
  return "monthly"
133
  if s in ("quarter", "quarterly", "every 3 months", "q"):
frontend/src/components/workspace/WonBillingModal.jsx CHANGED
@@ -358,17 +358,20 @@ export default function WonBillingModal({ dealName, busy, onCancel, onSubmit, gm
358
  >
359
  <SelectTrigger className="h-8 bg-white text-xs">
360
  <span className="truncate">
361
- {row.billing_interval === 'quarterly'
362
- ? 'Every 3 mo'
363
- : row.billing_interval === 'annual'
364
- ? 'Every year'
365
- : 'Every month'}
 
 
366
  </span>
367
  </SelectTrigger>
368
  <SelectContent>
369
  <SelectItem value="monthly">Every month</SelectItem>
370
  <SelectItem value="quarterly">Every 3 months</SelectItem>
371
  <SelectItem value="annual">Every year</SelectItem>
 
372
  </SelectContent>
373
  </Select>
374
  </td>
 
358
  >
359
  <SelectTrigger className="h-8 bg-white text-xs">
360
  <span className="truncate">
361
+ {row.billing_interval === 'one_time'
362
+ ? 'One-time'
363
+ : row.billing_interval === 'quarterly'
364
+ ? 'Every 3 mo'
365
+ : row.billing_interval === 'annual'
366
+ ? 'Every year'
367
+ : 'Every month'}
368
  </span>
369
  </SelectTrigger>
370
  <SelectContent>
371
  <SelectItem value="monthly">Every month</SelectItem>
372
  <SelectItem value="quarterly">Every 3 months</SelectItem>
373
  <SelectItem value="annual">Every year</SelectItem>
374
+ <SelectItem value="one_time">One-time</SelectItem>
375
  </SelectContent>
376
  </Select>
377
  </td>
frontend/src/pages/SalesDashboard.jsx CHANGED
@@ -48,10 +48,11 @@ function startOfQuarter(d) {
48
  return new Date(m.getFullYear(), q * 3, 1, 0, 0, 0, 0);
49
  }
50
 
51
- /** Global dashboard date filter: deal created_at must fall in range (all widgets). */
52
  function dealInDashboardPeriod(deal, period) {
53
  if (period === DASHBOARD_PERIOD_ALL) return true;
54
- const iso = deal.created_at;
 
55
  if (!iso) return false;
56
  const dt = new Date(iso);
57
  if (Number.isNaN(dt.getTime())) return false;
@@ -393,6 +394,7 @@ export default function SalesDashboard() {
393
  let cancelled = false;
394
  const params = new URLSearchParams();
395
  params.set('max_quarters', '12');
 
396
  if (isAdmin && ownerFilter !== 'all') {
397
  params.set('owner_user_id', ownerFilter);
398
  }
@@ -411,7 +413,7 @@ export default function SalesDashboard() {
411
  return () => {
412
  cancelled = true;
413
  };
414
- }, [isAdmin, ownerFilter, me?.user_id]);
415
 
416
  useEffect(() => {
417
  if (!isAdmin) setOwnerFilter('all');
@@ -606,7 +608,7 @@ export default function SalesDashboard() {
606
  const dateRangeSub =
607
  dashboardPeriod === DASHBOARD_PERIOD_ALL
608
  ? 'All deals in view'
609
- : 'Deal created in selected range';
610
 
611
  const subtitle = isAdmin
612
  ? ownerFilter === 'all'
@@ -625,11 +627,13 @@ export default function SalesDashboard() {
625
  <div className="space-y-6">
626
  <div className="flex flex-wrap items-center justify-between gap-3">
627
  <p className="text-sm text-slate-600 max-w-3xl">
628
- Charts use the same deal fields as the pipeline board. Actual revenue uses deal
629
- value on <strong className="font-medium text-slate-800">won</strong> deals (there is
630
- no separate &quot;actual value&quot; column yet). Revenue-by-month uses{' '}
631
- <strong className="font-medium text-slate-800">expected close</strong> when set,
632
- otherwise <strong className="font-medium text-slate-800">created date</strong>.
 
 
633
  </p>
634
  <div
635
  className="flex shrink-0 flex-wrap items-center justify-end gap-2"
@@ -708,23 +712,6 @@ export default function SalesDashboard() {
708
  />
709
  </div>
710
 
711
- <div className="rounded-xl border border-indigo-100 bg-indigo-50/50 px-4 py-3 text-sm text-slate-700">
712
- <p className="font-semibold text-slate-900">Quarterly recurring revenue ($ USD)</p>
713
- <p className="mt-1 text-xs leading-relaxed text-slate-600">
714
- Uses <strong className="font-medium text-slate-800">won</strong> deals and the same PO line
715
- subtotals as the win popover. Recurring math follows deal{' '}
716
- <strong className="font-medium text-slate-800">Revenue</strong>:{' '}
717
- <strong className="font-medium text-slate-800">MRR</strong> = lines are monthly totals;
718
- <strong className="font-medium text-slate-800"> QRR</strong> = quarterly totals;
719
- <strong className="font-medium text-slate-800"> ARR</strong> = annual totals;
720
- <strong className="font-medium text-slate-800"> One-time</strong> = non-recurring (sum of
721
- lines). Portfolio <strong className="font-medium text-slate-800">MRR</strong> is monthly
722
- equivalent; <strong className="font-medium text-slate-800">Existing</strong> carries from
723
- the prior quarter; <strong className="font-medium text-slate-800">New</strong> is closed in
724
- that quarter. Attribution uses the <strong className="font-medium text-slate-800">won date</strong>.
725
- </p>
726
- </div>
727
-
728
  {quarterlyLoading ? (
729
  <div className="flex items-center justify-center gap-2 py-10 text-slate-600">
730
  <Loader2 className="h-5 w-5 animate-spin shrink-0" aria-hidden />
@@ -734,8 +721,8 @@ export default function SalesDashboard() {
734
  <div className="space-y-4">
735
  <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
736
  <CardShell
737
- title="Recurring MRR (USD)"
738
- hint="Roll-forward like your deck: Total = portfolio at quarter end; Existing = prior quarter total; New = sum of monthly-equivalent MRR from deals won in the quarter. All amounts USD ($)."
739
  className="min-h-0"
740
  >
741
  <div className="overflow-x-auto -m-1 p-1">
@@ -755,7 +742,7 @@ export default function SalesDashboard() {
755
  <td className="py-2 pr-3 font-medium text-slate-800">TOTAL</td>
756
  {quarterColumns.map((q) => (
757
  <td key={q.label} className="px-2 py-2 text-right font-semibold">
758
- {fmtMoney(q.mrr?.total)}
759
  </td>
760
  ))}
761
  </tr>
@@ -763,7 +750,7 @@ export default function SalesDashboard() {
763
  <td className="py-2 pr-3 text-slate-600">EXISTING</td>
764
  {quarterColumns.map((q) => (
765
  <td key={q.label} className="px-2 py-2 text-right text-slate-700">
766
- {fmtMoney(q.mrr?.existing)}
767
  </td>
768
  ))}
769
  </tr>
@@ -771,21 +758,17 @@ export default function SalesDashboard() {
771
  <td className="py-2 pr-3 text-slate-600">NEW</td>
772
  {quarterColumns.map((q) => (
773
  <td key={q.label} className="px-2 py-2 text-right text-slate-700">
774
- {fmtMoney(q.mrr?.new)}
775
  </td>
776
  ))}
777
  </tr>
778
  </tbody>
779
  </table>
780
  </div>
781
- <p className="mt-3 text-xs text-slate-500">
782
- ARR (USD) = MRR × 12 (API field <span className="font-mono text-slate-600">arr_usd</span>
783
- ).
784
- </p>
785
  </CardShell>
786
  <CardShell
787
  title="Recurring contracts"
788
- hint="Count of recurring (non–one-time) won deals in the portfolio at quarter end; New = deals whose won date falls in the quarter. Amounts are USD."
789
  className="min-h-0"
790
  >
791
  <div className="overflow-x-auto -m-1 p-1">
@@ -831,8 +814,8 @@ export default function SalesDashboard() {
831
  </CardShell>
832
  </div>
833
  <CardShell
834
- title="One-time revenue (USD)"
835
- hint="Deals with revenue type One-time: invoice subtotal (sum of lines), recognized in the quarter the deal was marked won."
836
  className="min-h-0"
837
  >
838
  <div className="overflow-x-auto -m-1 p-1">
@@ -886,14 +869,14 @@ export default function SalesDashboard() {
886
  </div>
887
  ) : (
888
  <p className="rounded-xl border border-dashed border-slate-200 bg-slate-50/50 px-4 py-8 text-center text-sm text-slate-600">
889
- No closed-won deals yet quarterly MRR / contracts will appear after you mark deals won
890
- (with billing lines and billing interval per line).
891
  </p>
892
  )}
893
 
894
  <CardShell
895
  title="Pipeline conversion"
896
- hint="Uses the global date range (deal created date) and team scope from the top of the page. Bars: blue through Negotiation, green for Won. Shaded bands connect stages. Step % = next ÷ previous. Conversion to Won = Won ÷ New."
897
  headerExtra={
898
  <div
899
  className="flex flex-wrap items-center gap-1 sm:gap-1.5"
 
48
  return new Date(m.getFullYear(), q * 3, 1, 0, 0, 0, 0);
49
  }
50
 
51
+ /** Dashboard date filter: open pipeline deals by created_at; Won deals by won_at when present. */
52
  function dealInDashboardPeriod(deal, period) {
53
  if (period === DASHBOARD_PERIOD_ALL) return true;
54
+ const iso =
55
+ normalizeStage(deal.stage) === 'won' && deal.won_at ? deal.won_at : deal.created_at;
56
  if (!iso) return false;
57
  const dt = new Date(iso);
58
  if (Number.isNaN(dt.getTime())) return false;
 
394
  let cancelled = false;
395
  const params = new URLSearchParams();
396
  params.set('max_quarters', '12');
397
+ params.set('period', dashboardPeriod);
398
  if (isAdmin && ownerFilter !== 'all') {
399
  params.set('owner_user_id', ownerFilter);
400
  }
 
413
  return () => {
414
  cancelled = true;
415
  };
416
+ }, [isAdmin, ownerFilter, me?.user_id, dashboardPeriod]);
417
 
418
  useEffect(() => {
419
  if (!isAdmin) setOwnerFilter('all');
 
608
  const dateRangeSub =
609
  dashboardPeriod === DASHBOARD_PERIOD_ALL
610
  ? 'All deals in view'
611
+ : 'Open deals by created date · Won deals by won date';
612
 
613
  const subtitle = isAdmin
614
  ? ownerFilter === 'all'
 
627
  <div className="space-y-6">
628
  <div className="flex flex-wrap items-center justify-between gap-3">
629
  <p className="text-sm text-slate-600 max-w-3xl">
630
+ Date range and team filters apply to the charts below. Open pipeline deals use{' '}
631
+ <strong className="font-medium text-slate-800">created date</strong>;{' '}
632
+ <strong className="font-medium text-slate-800">won</strong> deals use{' '}
633
+ <strong className="font-medium text-slate-800">won date</strong> when present (same as the
634
+ ARR boards). Actual revenue sums deal value on won deals in view. Revenue-by-month uses{' '}
635
+ <strong className="font-medium text-slate-800">expected close</strong> when set, otherwise
636
+ created date.
637
  </p>
638
  <div
639
  className="flex shrink-0 flex-wrap items-center justify-end gap-2"
 
712
  />
713
  </div>
714
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
715
  {quarterlyLoading ? (
716
  <div className="flex items-center justify-center gap-2 py-10 text-slate-600">
717
  <Loader2 className="h-5 w-5 animate-spin shrink-0" aria-hidden />
 
721
  <div className="space-y-4">
722
  <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
723
  <CardShell
724
+ title="Annual recurring revenue (ARR)"
725
+ hint="USD ($). From won PO lines: monthly ×12, quarterly ×4, annual ×1; one-time lines excluded. Total / Existing / New roll-forward by quarter. Respects team + date range filters."
726
  className="min-h-0"
727
  >
728
  <div className="overflow-x-auto -m-1 p-1">
 
742
  <td className="py-2 pr-3 font-medium text-slate-800">TOTAL</td>
743
  {quarterColumns.map((q) => (
744
  <td key={q.label} className="px-2 py-2 text-right font-semibold">
745
+ {fmtMoney(q.arr?.total)}
746
  </td>
747
  ))}
748
  </tr>
 
750
  <td className="py-2 pr-3 text-slate-600">EXISTING</td>
751
  {quarterColumns.map((q) => (
752
  <td key={q.label} className="px-2 py-2 text-right text-slate-700">
753
+ {fmtMoney(q.arr?.existing)}
754
  </td>
755
  ))}
756
  </tr>
 
758
  <td className="py-2 pr-3 text-slate-600">NEW</td>
759
  {quarterColumns.map((q) => (
760
  <td key={q.label} className="px-2 py-2 text-right text-slate-700">
761
+ {fmtMoney(q.arr?.new)}
762
  </td>
763
  ))}
764
  </tr>
765
  </tbody>
766
  </table>
767
  </div>
 
 
 
 
768
  </CardShell>
769
  <CardShell
770
  title="Recurring contracts"
771
+ hint="Won deals with at least one recurring PO line (not one-time only). Respects team + date filters."
772
  className="min-h-0"
773
  >
774
  <div className="overflow-x-auto -m-1 p-1">
 
814
  </CardShell>
815
  </div>
816
  <CardShell
817
+ title="One-time revenue"
818
+ hint="Sum of PO lines marked One-time in the win popover; recognized in the quarter the deal was marked won. USD ($). Same filters as above."
819
  className="min-h-0"
820
  >
821
  <div className="overflow-x-auto -m-1 p-1">
 
869
  </div>
870
  ) : (
871
  <p className="rounded-xl border border-dashed border-slate-200 bg-slate-50/50 px-4 py-8 text-center text-sm text-slate-600">
872
+ No matching closed-won deals for this scope ARR / contracts / one-time update when deals
873
+ are won with PO lines (billing interval per line).
874
  </p>
875
  )}
876
 
877
  <CardShell
878
  title="Pipeline conversion"
879
+ hint="Uses date range and team from the top right (open deals by created date; won deals by won date). Bars: blue through Negotiation, green for Won. Step % = next ÷ previous."
880
  headerExtra={
881
  <div
882
  className="flex flex-wrap items-center gap-1 sm:gap-1.5"