Seth commited on
Commit ·
067e8d2
1
Parent(s): 6a96b23
update
Browse files- backend/app/database.py +8 -0
- backend/app/deal_revenue.py +296 -0
- backend/app/main.py +45 -0
- backend/app/models.py +26 -1
- frontend/src/components/workspace/WonBillingModal.jsx +47 -1
- frontend/src/pages/SalesDashboard.jsx +230 -0
backend/app/database.py
CHANGED
|
@@ -190,6 +190,7 @@ class CrmDeal(Base):
|
|
| 190 |
won_note_to_customer = Column(Text, default="")
|
| 191 |
won_note_to_accounts = Column(Text, default="")
|
| 192 |
won_line_items = Column(JSON, nullable=True)
|
|
|
|
| 193 |
created_at = Column(DateTime, default=datetime.utcnow)
|
| 194 |
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 195 |
|
|
@@ -304,6 +305,13 @@ def run_migrations(connection_engine):
|
|
| 304 |
conn.execute(text("ALTER TABLE crm_deals ADD COLUMN won_note_to_accounts TEXT DEFAULT ''"))
|
| 305 |
if "won_line_items" not in dcols:
|
| 306 |
conn.execute(text("ALTER TABLE crm_deals ADD COLUMN won_line_items TEXT"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
|
| 309 |
# Create tables then migrate legacy SQLite schemas
|
|
|
|
| 190 |
won_note_to_customer = Column(Text, default="")
|
| 191 |
won_note_to_accounts = Column(Text, default="")
|
| 192 |
won_line_items = Column(JSON, nullable=True)
|
| 193 |
+
won_at = Column(DateTime, nullable=True) # first transition to won (for quarterly attribution)
|
| 194 |
created_at = Column(DateTime, default=datetime.utcnow)
|
| 195 |
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 196 |
|
|
|
|
| 305 |
conn.execute(text("ALTER TABLE crm_deals ADD COLUMN won_note_to_accounts TEXT DEFAULT ''"))
|
| 306 |
if "won_line_items" not in dcols:
|
| 307 |
conn.execute(text("ALTER TABLE crm_deals ADD COLUMN won_line_items TEXT"))
|
| 308 |
+
if "won_at" not in dcols:
|
| 309 |
+
conn.execute(text("ALTER TABLE crm_deals ADD COLUMN won_at DATETIME"))
|
| 310 |
+
conn.execute(
|
| 311 |
+
text(
|
| 312 |
+
"UPDATE crm_deals SET won_at = updated_at WHERE stage = 'won' AND won_at IS NULL"
|
| 313 |
+
)
|
| 314 |
+
)
|
| 315 |
|
| 316 |
|
| 317 |
# Create tables then migrate legacy SQLite schemas
|
backend/app/deal_revenue.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ARR/MRR in CAD from won deal line items — same methodology as the manual spreadsheet:
|
| 3 |
+
per-line amount × interval factor (×12 / ×4 / ×1), USD→CAD, sum → ARR; MRR = ARR/12.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from datetime import datetime, timedelta
|
| 11 |
+
from typing import Any, Optional
|
| 12 |
+
|
| 13 |
+
INTERVAL_ANNUAL_MULT = {
|
| 14 |
+
"monthly": 12,
|
| 15 |
+
"quarterly": 4,
|
| 16 |
+
"annual": 1,
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def usd_to_cad_rate() -> float:
|
| 21 |
+
raw = os.environ.get("USD_TO_CAD_RATE", "1.38").strip()
|
| 22 |
+
try:
|
| 23 |
+
x = float(raw)
|
| 24 |
+
return x if x > 0 else 1.38
|
| 25 |
+
except (TypeError, ValueError):
|
| 26 |
+
return 1.38
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _norm_interval(raw: Any) -> str:
|
| 30 |
+
if not raw:
|
| 31 |
+
return "monthly"
|
| 32 |
+
s = str(raw).strip().lower()
|
| 33 |
+
if s in ("quarterly", "quarter", "every 3 months"):
|
| 34 |
+
return "quarterly"
|
| 35 |
+
if s in ("annual", "year", "yearly", "every year"):
|
| 36 |
+
return "annual"
|
| 37 |
+
return "monthly"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _norm_currency(raw: Any) -> str:
|
| 41 |
+
if raw and str(raw).strip().upper() == "CAD":
|
| 42 |
+
return "CAD"
|
| 43 |
+
return "USD"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def line_amount_native(line: dict[str, Any]) -> float:
|
| 47 |
+
if line.get("amount") is not None:
|
| 48 |
+
try:
|
| 49 |
+
return round(float(line["amount"]), 2)
|
| 50 |
+
except (TypeError, ValueError):
|
| 51 |
+
pass
|
| 52 |
+
try:
|
| 53 |
+
q = float(line.get("qty") or 0)
|
| 54 |
+
r = float(line.get("rate") or 0)
|
| 55 |
+
return round(q * r, 2)
|
| 56 |
+
except (TypeError, ValueError):
|
| 57 |
+
return 0.0
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def line_arr_cad(line: dict[str, Any], rate_usd_cad: float) -> float:
|
| 61 |
+
"""Annual recurring revenue in CAD for one line."""
|
| 62 |
+
amt = line_amount_native(line)
|
| 63 |
+
interval = _norm_interval(line.get("billing_interval"))
|
| 64 |
+
mult = INTERVAL_ANNUAL_MULT.get(interval, 12)
|
| 65 |
+
annual_native = amt * mult
|
| 66 |
+
cur = _norm_currency(line.get("currency"))
|
| 67 |
+
if cur == "CAD":
|
| 68 |
+
return annual_native
|
| 69 |
+
return annual_native * rate_usd_cad
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def deal_subtotal_cad_onetime(lines: list[dict[str, Any]], rate_usd_cad: float) -> float:
|
| 73 |
+
total = 0.0
|
| 74 |
+
for line in lines:
|
| 75 |
+
amt = line_amount_native(line)
|
| 76 |
+
cur = _norm_currency(line.get("currency"))
|
| 77 |
+
if cur == "CAD":
|
| 78 |
+
total += amt
|
| 79 |
+
else:
|
| 80 |
+
total += amt * rate_usd_cad
|
| 81 |
+
return round(total, 2)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def deal_recurring_arr_cad(revenue_type: str, lines: list[dict[str, Any]], rate_usd_cad: float) -> float:
|
| 85 |
+
rt = (revenue_type or "arr").strip().lower()
|
| 86 |
+
if rt == "one_time":
|
| 87 |
+
return 0.0
|
| 88 |
+
return round(sum(line_arr_cad(li, rate_usd_cad) for li in lines), 2)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def deal_recurring_mrr_cad(revenue_type: str, lines: list[dict[str, Any]], rate_usd_cad: float) -> float:
|
| 92 |
+
arr = deal_recurring_arr_cad(revenue_type, lines, rate_usd_cad)
|
| 93 |
+
return round(arr / 12.0, 2)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@dataclass(frozen=True)
|
| 97 |
+
class QuarterSpec:
|
| 98 |
+
year: int
|
| 99 |
+
quarter: int # 1–4
|
| 100 |
+
|
| 101 |
+
@property
|
| 102 |
+
def label(self) -> str:
|
| 103 |
+
return f"Q{self.quarter} {self.year}"
|
| 104 |
+
|
| 105 |
+
def start_dt(self) -> datetime:
|
| 106 |
+
m = (self.quarter - 1) * 3 + 1
|
| 107 |
+
return datetime(self.year, m, 1)
|
| 108 |
+
|
| 109 |
+
def end_dt(self) -> datetime:
|
| 110 |
+
sm = (self.quarter - 1) * 3 + 1
|
| 111 |
+
em = sm + 2
|
| 112 |
+
if em == 12:
|
| 113 |
+
return datetime(self.year, 12, 31, 23, 59, 59)
|
| 114 |
+
next_month = datetime(self.year, em + 1, 1)
|
| 115 |
+
return next_month - timedelta(microseconds=1)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def quarter_for_datetime(dt: datetime) -> QuarterSpec:
|
| 119 |
+
q = (dt.month - 1) // 3 + 1
|
| 120 |
+
return QuarterSpec(dt.year, q)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def iter_quarters(a: QuarterSpec, b: QuarterSpec) -> list[QuarterSpec]:
|
| 124 |
+
"""Inclusive range from a to b (assumes a <= b)."""
|
| 125 |
+
out: list[QuarterSpec] = []
|
| 126 |
+
y, q = a.year, a.quarter
|
| 127 |
+
while (y, q) <= (b.year, b.quarter):
|
| 128 |
+
out.append(QuarterSpec(y, q))
|
| 129 |
+
q += 1
|
| 130 |
+
if q > 4:
|
| 131 |
+
q = 1
|
| 132 |
+
y += 1
|
| 133 |
+
return out
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _parse_won_at(val: Any) -> Optional[datetime]:
|
| 137 |
+
if val is None:
|
| 138 |
+
return None
|
| 139 |
+
if isinstance(val, datetime):
|
| 140 |
+
if val.tzinfo is not None:
|
| 141 |
+
return val.replace(tzinfo=None)
|
| 142 |
+
return val
|
| 143 |
+
if isinstance(val, str):
|
| 144 |
+
try:
|
| 145 |
+
s = val.strip()
|
| 146 |
+
if s.endswith("Z"):
|
| 147 |
+
s = s[:-1] + "+00:00"
|
| 148 |
+
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
| 149 |
+
if dt.tzinfo is not None:
|
| 150 |
+
return dt.replace(tzinfo=None)
|
| 151 |
+
return dt
|
| 152 |
+
except ValueError:
|
| 153 |
+
return None
|
| 154 |
+
return None
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def build_quarterly_board(
|
| 158 |
+
deals: list[dict[str, Any]],
|
| 159 |
+
rate_usd_cad: Optional[float] = None,
|
| 160 |
+
max_quarters: int = 10,
|
| 161 |
+
) -> dict[str, Any]:
|
| 162 |
+
"""
|
| 163 |
+
deals: each dict needs keys revenue_type, won_line_items (list), won_at (optional ISO/datetime).
|
| 164 |
+
"""
|
| 165 |
+
r = rate_usd_cad if rate_usd_cad is not None else usd_to_cad_rate()
|
| 166 |
+
|
| 167 |
+
if not deals:
|
| 168 |
+
return {
|
| 169 |
+
"usd_to_cad_rate": r,
|
| 170 |
+
"currency_display": "CAD",
|
| 171 |
+
"quarters": [],
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
normalized: list[dict[str, Any]] = []
|
| 175 |
+
for d in deals:
|
| 176 |
+
lines = d.get("won_line_items") or []
|
| 177 |
+
if not isinstance(lines, list):
|
| 178 |
+
lines = []
|
| 179 |
+
rt = (d.get("revenue_type") or "arr").strip().lower()
|
| 180 |
+
if rt not in ("arr", "qrr", "mrr", "one_time"):
|
| 181 |
+
rt = "arr"
|
| 182 |
+
won_at = _parse_won_at(d.get("won_at"))
|
| 183 |
+
normalized.append(
|
| 184 |
+
{
|
| 185 |
+
"id": d.get("id"),
|
| 186 |
+
"name": d.get("name") or "",
|
| 187 |
+
"revenue_type": rt,
|
| 188 |
+
"won_line_items": lines,
|
| 189 |
+
"won_at": won_at,
|
| 190 |
+
}
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
now = datetime.utcnow()
|
| 194 |
+
current_q = quarter_for_datetime(now)
|
| 195 |
+
|
| 196 |
+
starts = [quarter_for_datetime(x["won_at"]) for x in normalized if x["won_at"]]
|
| 197 |
+
if starts:
|
| 198 |
+
first_q = min(starts, key=lambda q: (q.year, q.quarter))
|
| 199 |
+
else:
|
| 200 |
+
first_q = current_q
|
| 201 |
+
|
| 202 |
+
quarters = iter_quarters(first_q, current_q)
|
| 203 |
+
if len(quarters) > max_quarters:
|
| 204 |
+
quarters = quarters[-max_quarters:]
|
| 205 |
+
|
| 206 |
+
rows: list[dict[str, Any]] = []
|
| 207 |
+
prev_total_mrr = 0.0
|
| 208 |
+
prev_total_contracts = 0
|
| 209 |
+
|
| 210 |
+
for qs in quarters:
|
| 211 |
+
start = qs.start_dt()
|
| 212 |
+
end = qs.end_dt()
|
| 213 |
+
|
| 214 |
+
def in_quarter(won_at: Optional[datetime]) -> bool:
|
| 215 |
+
if not won_at:
|
| 216 |
+
return False
|
| 217 |
+
return start <= won_at <= end
|
| 218 |
+
|
| 219 |
+
new_mrr = 0.0
|
| 220 |
+
new_contracts = 0
|
| 221 |
+
one_time_cad = 0.0
|
| 222 |
+
one_time_deals: list[dict[str, Any]] = []
|
| 223 |
+
|
| 224 |
+
total_mrr_end = 0.0
|
| 225 |
+
total_contracts_end = 0
|
| 226 |
+
|
| 227 |
+
for deal in normalized:
|
| 228 |
+
lines = deal["won_line_items"]
|
| 229 |
+
rt = deal["revenue_type"]
|
| 230 |
+
won_at = deal["won_at"]
|
| 231 |
+
|
| 232 |
+
if rt == "one_time":
|
| 233 |
+
if won_at and won_at <= end:
|
| 234 |
+
ot = deal_subtotal_cad_onetime(lines, r)
|
| 235 |
+
if in_quarter(won_at):
|
| 236 |
+
one_time_cad += ot
|
| 237 |
+
one_time_deals.append(
|
| 238 |
+
{
|
| 239 |
+
"deal_id": deal["id"],
|
| 240 |
+
"name": deal["name"],
|
| 241 |
+
"amount_cad": ot,
|
| 242 |
+
"won_at": won_at.isoformat() if won_at else None,
|
| 243 |
+
}
|
| 244 |
+
)
|
| 245 |
+
continue
|
| 246 |
+
|
| 247 |
+
mrr = deal_recurring_mrr_cad(rt, lines, r)
|
| 248 |
+
if won_at is None or won_at > end:
|
| 249 |
+
continue
|
| 250 |
+
total_mrr_end += mrr
|
| 251 |
+
total_contracts_end += 1
|
| 252 |
+
|
| 253 |
+
if in_quarter(won_at):
|
| 254 |
+
new_mrr += mrr
|
| 255 |
+
new_contracts += 1
|
| 256 |
+
|
| 257 |
+
existing_mrr = prev_total_mrr
|
| 258 |
+
existing_contracts = prev_total_contracts
|
| 259 |
+
|
| 260 |
+
portfolio_check = round(existing_mrr + new_mrr, 2)
|
| 261 |
+
rows.append(
|
| 262 |
+
{
|
| 263 |
+
"label": qs.label,
|
| 264 |
+
"year": qs.year,
|
| 265 |
+
"quarter": qs.quarter,
|
| 266 |
+
"start": start.isoformat(),
|
| 267 |
+
"end": end.isoformat(),
|
| 268 |
+
"mrr": {
|
| 269 |
+
"existing": round(existing_mrr, 2),
|
| 270 |
+
"new": round(new_mrr, 2),
|
| 271 |
+
"total": round(total_mrr_end, 2),
|
| 272 |
+
},
|
| 273 |
+
"arr_cad": {
|
| 274 |
+
"existing": round(existing_mrr * 12, 2),
|
| 275 |
+
"new": round(new_mrr * 12, 2),
|
| 276 |
+
"total": round(total_mrr_end * 12, 2),
|
| 277 |
+
},
|
| 278 |
+
"contracts": {
|
| 279 |
+
"existing": existing_contracts,
|
| 280 |
+
"new": new_contracts,
|
| 281 |
+
"total": total_contracts_end,
|
| 282 |
+
},
|
| 283 |
+
"one_time_cad_quarter": round(one_time_cad, 2),
|
| 284 |
+
"one_time_deals": one_time_deals,
|
| 285 |
+
"portfolio_matches_rollforward": abs(portfolio_check - round(total_mrr_end, 2)) < 0.02,
|
| 286 |
+
}
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
prev_total_mrr = total_mrr_end
|
| 290 |
+
prev_total_contracts = total_contracts_end
|
| 291 |
+
|
| 292 |
+
return {
|
| 293 |
+
"usd_to_cad_rate": r,
|
| 294 |
+
"currency_display": "CAD",
|
| 295 |
+
"quarters": rows,
|
| 296 |
+
}
|
backend/app/main.py
CHANGED
|
@@ -56,6 +56,7 @@ from .smartlead_client import SmartleadClient
|
|
| 56 |
from .auth_routes import router as auth_router
|
| 57 |
from .tenant_deps import TenantContext, get_tenant_context
|
| 58 |
from .tenant_routes import router as tenant_router
|
|
|
|
| 59 |
|
| 60 |
app = FastAPI()
|
| 61 |
|
|
@@ -592,6 +593,7 @@ def _deal_to_dict(d: CrmDeal) -> dict:
|
|
| 592 |
"won_note_to_customer": _safe_str(getattr(d, "won_note_to_customer", None) or ""),
|
| 593 |
"won_note_to_accounts": _safe_str(getattr(d, "won_note_to_accounts", None) or ""),
|
| 594 |
"won_line_items": _won_line_items_list(d),
|
|
|
|
| 595 |
}
|
| 596 |
|
| 597 |
|
|
@@ -619,6 +621,7 @@ def _clear_won_billing_fields(row: CrmDeal) -> None:
|
|
| 619 |
row.won_note_to_customer = ""
|
| 620 |
row.won_note_to_accounts = ""
|
| 621 |
row.won_line_items = None
|
|
|
|
| 622 |
|
| 623 |
|
| 624 |
async def _notify_tenant_admins_deal_won(
|
|
@@ -2755,6 +2758,44 @@ async def list_deals(
|
|
| 2755 |
return {"total": total, "deals": deals_out}
|
| 2756 |
|
| 2757 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2758 |
@app.post("/api/deals")
|
| 2759 |
async def create_deal(body: CrmDealCreateRequest, t: TenantContext = Depends(get_tenant_context)):
|
| 2760 |
stage = (body.stage or "new").strip().lower() or "new"
|
|
@@ -2973,6 +3014,8 @@ async def patch_deal(deal_id: int, body: CrmDealPatchRequest, t: TenantContext =
|
|
| 2973 |
"qty": float(li.qty),
|
| 2974 |
"rate": float(li.rate),
|
| 2975 |
"amount": amt,
|
|
|
|
|
|
|
| 2976 |
}
|
| 2977 |
)
|
| 2978 |
row.won_po_number = _safe_str(wb.po_number).strip()
|
|
@@ -2987,6 +3030,8 @@ async def patch_deal(deal_id: int, body: CrmDealPatchRequest, t: TenantContext =
|
|
| 2987 |
row.lost_price_discount_pct = None
|
| 2988 |
row.lost_chose_product_name = ""
|
| 2989 |
row.stage = "won"
|
|
|
|
|
|
|
| 2990 |
did_mark_won_this_request = True
|
| 2991 |
else:
|
| 2992 |
if prev_stage == "lost":
|
|
|
|
| 56 |
from .auth_routes import router as auth_router
|
| 57 |
from .tenant_deps import TenantContext, get_tenant_context
|
| 58 |
from .tenant_routes import router as tenant_router
|
| 59 |
+
from .deal_revenue import build_quarterly_board, usd_to_cad_rate
|
| 60 |
|
| 61 |
app = FastAPI()
|
| 62 |
|
|
|
|
| 593 |
"won_note_to_customer": _safe_str(getattr(d, "won_note_to_customer", None) or ""),
|
| 594 |
"won_note_to_accounts": _safe_str(getattr(d, "won_note_to_accounts", None) or ""),
|
| 595 |
"won_line_items": _won_line_items_list(d),
|
| 596 |
+
"won_at": d.won_at.isoformat() if getattr(d, "won_at", None) else None,
|
| 597 |
}
|
| 598 |
|
| 599 |
|
|
|
|
| 621 |
row.won_note_to_customer = ""
|
| 622 |
row.won_note_to_accounts = ""
|
| 623 |
row.won_line_items = None
|
| 624 |
+
row.won_at = None
|
| 625 |
|
| 626 |
|
| 627 |
async def _notify_tenant_admins_deal_won(
|
|
|
|
| 2758 |
return {"total": total, "deals": deals_out}
|
| 2759 |
|
| 2760 |
|
| 2761 |
+
@app.get("/api/deals/quarterly-recurring-metrics")
|
| 2762 |
+
async def quarterly_recurring_metrics(
|
| 2763 |
+
owner_user_id: Optional[int] = Query(None),
|
| 2764 |
+
max_quarters: int = Query(12, ge=1, le=24),
|
| 2765 |
+
t: TenantContext = Depends(get_tenant_context),
|
| 2766 |
+
):
|
| 2767 |
+
"""
|
| 2768 |
+
Won deals → recurring ARR/MRR (CAD) by calendar quarter: Existing / New / Total roll-forward,
|
| 2769 |
+
plus one-time CAD recognized in quarter (revenue_type one_time). Uses USD_TO_CAD_RATE env (default 1.38).
|
| 2770 |
+
"""
|
| 2771 |
+
db = t.db
|
| 2772 |
+
q = db.query(CrmDeal).filter(
|
| 2773 |
+
CrmDeal.tenant_id == t.tenant_id,
|
| 2774 |
+
CrmDeal.stage == "won",
|
| 2775 |
+
)
|
| 2776 |
+
if t.role != "admin":
|
| 2777 |
+
q = q.filter(or_(CrmDeal.owner_user_id == t.user_id, CrmDeal.owner_user_id.is_(None)))
|
| 2778 |
+
elif owner_user_id is not None:
|
| 2779 |
+
q = q.filter(CrmDeal.owner_user_id == int(owner_user_id))
|
| 2780 |
+
rows = q.all()
|
| 2781 |
+
deals_payload = []
|
| 2782 |
+
for r in rows:
|
| 2783 |
+
deals_payload.append(
|
| 2784 |
+
{
|
| 2785 |
+
"id": r.id,
|
| 2786 |
+
"name": r.name or "",
|
| 2787 |
+
"revenue_type": (getattr(r, "revenue_type", None) or "arr"),
|
| 2788 |
+
"won_line_items": _won_line_items_list(r),
|
| 2789 |
+
"won_at": getattr(r, "won_at", None),
|
| 2790 |
+
}
|
| 2791 |
+
)
|
| 2792 |
+
return build_quarterly_board(
|
| 2793 |
+
deals_payload,
|
| 2794 |
+
rate_usd_cad=usd_to_cad_rate(),
|
| 2795 |
+
max_quarters=max_quarters,
|
| 2796 |
+
)
|
| 2797 |
+
|
| 2798 |
+
|
| 2799 |
@app.post("/api/deals")
|
| 2800 |
async def create_deal(body: CrmDealCreateRequest, t: TenantContext = Depends(get_tenant_context)):
|
| 2801 |
stage = (body.stage or "new").strip().lower() or "new"
|
|
|
|
| 3014 |
"qty": float(li.qty),
|
| 3015 |
"rate": float(li.rate),
|
| 3016 |
"amount": amt,
|
| 3017 |
+
"billing_interval": li.billing_interval,
|
| 3018 |
+
"currency": li.currency,
|
| 3019 |
}
|
| 3020 |
)
|
| 3021 |
row.won_po_number = _safe_str(wb.po_number).strip()
|
|
|
|
| 3030 |
row.lost_price_discount_pct = None
|
| 3031 |
row.lost_chose_product_name = ""
|
| 3032 |
row.stage = "won"
|
| 3033 |
+
if prev_stage != "won":
|
| 3034 |
+
row.won_at = datetime.utcnow()
|
| 3035 |
did_mark_won_this_request = True
|
| 3036 |
else:
|
| 3037 |
if prev_stage == "lost":
|
backend/app/models.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
from pydantic import BaseModel, Field, field_validator
|
| 2 |
-
from typing import Optional, List, Dict
|
| 3 |
from datetime import datetime
|
| 4 |
|
| 5 |
|
|
@@ -102,6 +102,9 @@ 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 |
|
| 106 |
@field_validator("product_service", mode="before")
|
| 107 |
@classmethod
|
|
@@ -117,6 +120,28 @@ class WonLineItemIn(BaseModel):
|
|
| 117 |
return ""
|
| 118 |
return v.strip() if isinstance(v, str) else v
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
class WonBillingPayload(BaseModel):
|
| 122 |
"""Billing / PO details required when setting a deal to Won (invoicing)."""
|
|
|
|
| 1 |
from pydantic import BaseModel, Field, field_validator
|
| 2 |
+
from typing import Optional, List, Dict, Literal
|
| 3 |
from datetime import datetime
|
| 4 |
|
| 5 |
|
|
|
|
| 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")
|
| 110 |
@classmethod
|
|
|
|
| 120 |
return ""
|
| 121 |
return v.strip() if isinstance(v, str) else v
|
| 122 |
|
| 123 |
+
@field_validator("billing_interval", mode="before")
|
| 124 |
+
@classmethod
|
| 125 |
+
def norm_interval(cls, v):
|
| 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"):
|
| 132 |
+
return "quarterly"
|
| 133 |
+
if s in ("year", "annual", "yearly", "every year", "a"):
|
| 134 |
+
return "annual"
|
| 135 |
+
return "monthly"
|
| 136 |
+
|
| 137 |
+
@field_validator("currency", mode="before")
|
| 138 |
+
@classmethod
|
| 139 |
+
def norm_currency(cls, v):
|
| 140 |
+
if v is None:
|
| 141 |
+
return "USD"
|
| 142 |
+
s = v.strip().upper() if isinstance(v, str) else "USD"
|
| 143 |
+
return s if s in ("CAD", "USD") else "USD"
|
| 144 |
+
|
| 145 |
|
| 146 |
class WonBillingPayload(BaseModel):
|
| 147 |
"""Billing / PO details required when setting a deal to Won (invoicing)."""
|
frontend/src/components/workspace/WonBillingModal.jsx
CHANGED
|
@@ -3,6 +3,7 @@ import { ChevronDown, ChevronUp, GripVertical, Loader2, Trash2 } from 'lucide-re
|
|
| 3 |
import { Button } from '@/components/ui/button';
|
| 4 |
import { Input } from '@/components/ui/input';
|
| 5 |
import { Textarea } from '@/components/ui/textarea';
|
|
|
|
| 6 |
|
| 7 |
function newLineRow() {
|
| 8 |
return {
|
|
@@ -11,6 +12,8 @@ function newLineRow() {
|
|
| 11 |
description: '',
|
| 12 |
qty: '1',
|
| 13 |
rate: '0',
|
|
|
|
|
|
|
| 14 |
};
|
| 15 |
}
|
| 16 |
|
|
@@ -137,6 +140,8 @@ export default function WonBillingModal({ dealName, busy, onCancel, onSubmit, gm
|
|
| 137 |
description: row.description.trim(),
|
| 138 |
qty: q,
|
| 139 |
rate: r,
|
|
|
|
|
|
|
| 140 |
});
|
| 141 |
}
|
| 142 |
onSubmit({
|
|
@@ -274,13 +279,15 @@ export default function WonBillingModal({ dealName, busy, onCancel, onSubmit, gm
|
|
| 274 |
</div>
|
| 275 |
</div>
|
| 276 |
<div className="overflow-x-auto rounded-lg border border-slate-200">
|
| 277 |
-
<table className="w-full min-w-[
|
| 278 |
<thead>
|
| 279 |
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs font-semibold text-slate-600">
|
| 280 |
<th className="w-8 px-1 py-2" aria-hidden />
|
| 281 |
<th className="w-8 px-1 py-2 text-center">#</th>
|
| 282 |
<th className="min-w-[140px] px-2 py-2">Product / service</th>
|
| 283 |
<th className="min-w-[120px] px-2 py-2">Description</th>
|
|
|
|
|
|
|
| 284 |
<th className="w-20 px-2 py-2 text-right">Qty</th>
|
| 285 |
<th className="w-24 px-2 py-2 text-right">Rate</th>
|
| 286 |
<th className="w-28 px-2 py-2 text-right">Amount</th>
|
|
@@ -342,6 +349,45 @@ export default function WonBillingModal({ dealName, busy, onCancel, onSubmit, gm
|
|
| 342 |
placeholder="Description"
|
| 343 |
/>
|
| 344 |
</td>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
<td className="px-2 py-1">
|
| 346 |
<Input
|
| 347 |
value={row.qty}
|
|
|
|
| 3 |
import { Button } from '@/components/ui/button';
|
| 4 |
import { Input } from '@/components/ui/input';
|
| 5 |
import { Textarea } from '@/components/ui/textarea';
|
| 6 |
+
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
|
| 7 |
|
| 8 |
function newLineRow() {
|
| 9 |
return {
|
|
|
|
| 12 |
description: '',
|
| 13 |
qty: '1',
|
| 14 |
rate: '0',
|
| 15 |
+
billing_interval: 'monthly',
|
| 16 |
+
currency: 'USD',
|
| 17 |
};
|
| 18 |
}
|
| 19 |
|
|
|
|
| 140 |
description: row.description.trim(),
|
| 141 |
qty: q,
|
| 142 |
rate: r,
|
| 143 |
+
billing_interval: row.billing_interval || 'monthly',
|
| 144 |
+
currency: row.currency || 'USD',
|
| 145 |
});
|
| 146 |
}
|
| 147 |
onSubmit({
|
|
|
|
| 279 |
</div>
|
| 280 |
</div>
|
| 281 |
<div className="overflow-x-auto rounded-lg border border-slate-200">
|
| 282 |
+
<table className="w-full min-w-[960px] border-collapse text-sm">
|
| 283 |
<thead>
|
| 284 |
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs font-semibold text-slate-600">
|
| 285 |
<th className="w-8 px-1 py-2" aria-hidden />
|
| 286 |
<th className="w-8 px-1 py-2 text-center">#</th>
|
| 287 |
<th className="min-w-[140px] px-2 py-2">Product / service</th>
|
| 288 |
<th className="min-w-[120px] px-2 py-2">Description</th>
|
| 289 |
+
<th className="min-w-[130px] px-2 py-2">Billing interval</th>
|
| 290 |
+
<th className="w-24 px-2 py-2">Currency</th>
|
| 291 |
<th className="w-20 px-2 py-2 text-right">Qty</th>
|
| 292 |
<th className="w-24 px-2 py-2 text-right">Rate</th>
|
| 293 |
<th className="w-28 px-2 py-2 text-right">Amount</th>
|
|
|
|
| 349 |
placeholder="Description"
|
| 350 |
/>
|
| 351 |
</td>
|
| 352 |
+
<td className="px-2 py-1">
|
| 353 |
+
<Select
|
| 354 |
+
value={row.billing_interval || 'monthly'}
|
| 355 |
+
onValueChange={(v) =>
|
| 356 |
+
updateLine(row.id, { billing_interval: v })
|
| 357 |
+
}
|
| 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>
|
| 375 |
+
<td className="px-2 py-1">
|
| 376 |
+
<Select
|
| 377 |
+
value={row.currency || 'USD'}
|
| 378 |
+
onValueChange={(v) =>
|
| 379 |
+
updateLine(row.id, { currency: v })
|
| 380 |
+
}
|
| 381 |
+
>
|
| 382 |
+
<SelectTrigger className="h-8 bg-white text-xs">
|
| 383 |
+
{row.currency || 'USD'}
|
| 384 |
+
</SelectTrigger>
|
| 385 |
+
<SelectContent>
|
| 386 |
+
<SelectItem value="USD">USD</SelectItem>
|
| 387 |
+
<SelectItem value="CAD">CAD</SelectItem>
|
| 388 |
+
</SelectContent>
|
| 389 |
+
</Select>
|
| 390 |
+
</td>
|
| 391 |
<td className="px-2 py-1">
|
| 392 |
<Input
|
| 393 |
value={row.qty}
|
frontend/src/pages/SalesDashboard.jsx
CHANGED
|
@@ -106,6 +106,17 @@ function fmtMoneyCompact(n) {
|
|
| 106 |
}).format(n);
|
| 107 |
}
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
function monthKeyFromDealDate(iso) {
|
| 110 |
if (!iso || typeof iso !== 'string') return null;
|
| 111 |
const m = iso.slice(0, 7);
|
|
@@ -342,6 +353,8 @@ export default function SalesDashboard() {
|
|
| 342 |
const [tenantMembers, setTenantMembers] = useState([]);
|
| 343 |
const [ownerFilter, setOwnerFilter] = useState('all');
|
| 344 |
const [dashboardPeriod, setDashboardPeriod] = useState(DASHBOARD_PERIOD_ALL);
|
|
|
|
|
|
|
| 345 |
|
| 346 |
const isAdmin = me?.current_role === 'admin';
|
| 347 |
|
|
@@ -387,6 +400,30 @@ export default function SalesDashboard() {
|
|
| 387 |
fetchDeals();
|
| 388 |
}, [fetchDeals]);
|
| 389 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
useEffect(() => {
|
| 391 |
if (!isAdmin) setOwnerFilter('all');
|
| 392 |
}, [isAdmin]);
|
|
@@ -571,6 +608,12 @@ export default function SalesDashboard() {
|
|
| 571 |
? 'This year'
|
| 572 |
: 'All time';
|
| 573 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 574 |
const dateRangeSub =
|
| 575 |
dashboardPeriod === DASHBOARD_PERIOD_ALL
|
| 576 |
? 'All deals in view'
|
|
@@ -676,6 +719,193 @@ export default function SalesDashboard() {
|
|
| 676 |
/>
|
| 677 |
</div>
|
| 678 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 679 |
<CardShell
|
| 680 |
title="Pipeline conversion"
|
| 681 |
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."
|
|
|
|
| 106 |
}).format(n);
|
| 107 |
}
|
| 108 |
|
| 109 |
+
/** Dashboard quarterly board (matches spreadsheet ARR methodology; amounts in CAD). */
|
| 110 |
+
function fmtCad(n) {
|
| 111 |
+
if (n == null || n === '' || Number.isNaN(n)) return '—';
|
| 112 |
+
return new Intl.NumberFormat('en-CA', {
|
| 113 |
+
style: 'currency',
|
| 114 |
+
currency: 'CAD',
|
| 115 |
+
minimumFractionDigits: 0,
|
| 116 |
+
maximumFractionDigits: 0,
|
| 117 |
+
}).format(n);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
function monthKeyFromDealDate(iso) {
|
| 121 |
if (!iso || typeof iso !== 'string') return null;
|
| 122 |
const m = iso.slice(0, 7);
|
|
|
|
| 353 |
const [tenantMembers, setTenantMembers] = useState([]);
|
| 354 |
const [ownerFilter, setOwnerFilter] = useState('all');
|
| 355 |
const [dashboardPeriod, setDashboardPeriod] = useState(DASHBOARD_PERIOD_ALL);
|
| 356 |
+
const [quarterlyBoard, setQuarterlyBoard] = useState(null);
|
| 357 |
+
const [quarterlyLoading, setQuarterlyLoading] = useState(true);
|
| 358 |
|
| 359 |
const isAdmin = me?.current_role === 'admin';
|
| 360 |
|
|
|
|
| 400 |
fetchDeals();
|
| 401 |
}, [fetchDeals]);
|
| 402 |
|
| 403 |
+
useEffect(() => {
|
| 404 |
+
let cancelled = false;
|
| 405 |
+
const params = new URLSearchParams();
|
| 406 |
+
params.set('max_quarters', '12');
|
| 407 |
+
if (isAdmin && ownerFilter !== 'all') {
|
| 408 |
+
params.set('owner_user_id', ownerFilter);
|
| 409 |
+
}
|
| 410 |
+
setQuarterlyLoading(true);
|
| 411 |
+
apiFetch(`/api/deals/quarterly-recurring-metrics?${params.toString()}`)
|
| 412 |
+
.then((r) => (r.ok ? r.json() : null))
|
| 413 |
+
.then((data) => {
|
| 414 |
+
if (!cancelled) setQuarterlyBoard(data);
|
| 415 |
+
})
|
| 416 |
+
.catch(() => {
|
| 417 |
+
if (!cancelled) setQuarterlyBoard(null);
|
| 418 |
+
})
|
| 419 |
+
.finally(() => {
|
| 420 |
+
if (!cancelled) setQuarterlyLoading(false);
|
| 421 |
+
});
|
| 422 |
+
return () => {
|
| 423 |
+
cancelled = true;
|
| 424 |
+
};
|
| 425 |
+
}, [isAdmin, ownerFilter, me?.user_id]);
|
| 426 |
+
|
| 427 |
useEffect(() => {
|
| 428 |
if (!isAdmin) setOwnerFilter('all');
|
| 429 |
}, [isAdmin]);
|
|
|
|
| 608 |
? 'This year'
|
| 609 |
: 'All time';
|
| 610 |
|
| 611 |
+
const quarterColumns = useMemo(() => {
|
| 612 |
+
const qs = quarterlyBoard?.quarters || [];
|
| 613 |
+
if (qs.length <= 6) return qs;
|
| 614 |
+
return qs.slice(-6);
|
| 615 |
+
}, [quarterlyBoard]);
|
| 616 |
+
|
| 617 |
const dateRangeSub =
|
| 618 |
dashboardPeriod === DASHBOARD_PERIOD_ALL
|
| 619 |
? 'All deals in view'
|
|
|
|
| 719 |
/>
|
| 720 |
</div>
|
| 721 |
|
| 722 |
+
<div className="rounded-xl border border-indigo-100 bg-indigo-50/50 px-4 py-3 text-sm text-slate-700">
|
| 723 |
+
<p className="font-semibold text-slate-900">Quarterly recurring revenue (board)</p>
|
| 724 |
+
<p className="mt-1 text-xs leading-relaxed text-slate-600">
|
| 725 |
+
Uses <strong className="font-medium text-slate-800">won</strong> deals only. Each PO line
|
| 726 |
+
contributes MRR in CAD from amount × interval (monthly ×12, quarterly ×4, yearly ×1) and
|
| 727 |
+
USD→CAD using the server rate below.{' '}
|
| 728 |
+
<strong className="font-medium text-slate-800">Existing</strong> MRR is the recurring total
|
| 729 |
+
carried from the prior quarter; <strong className="font-medium text-slate-800">New</strong>{' '}
|
| 730 |
+
is won in that quarter. Deals with type{' '}
|
| 731 |
+
<strong className="font-medium text-slate-800">One-time</strong> are listed separately.
|
| 732 |
+
Attribution uses the <strong className="font-medium text-slate-800">won date</strong>{' '}
|
| 733 |
+
(set when the deal is marked won).
|
| 734 |
+
</p>
|
| 735 |
+
</div>
|
| 736 |
+
|
| 737 |
+
{quarterlyLoading ? (
|
| 738 |
+
<div className="flex items-center justify-center gap-2 py-10 text-slate-600">
|
| 739 |
+
<Loader2 className="h-5 w-5 animate-spin shrink-0" aria-hidden />
|
| 740 |
+
Loading quarterly metrics…
|
| 741 |
+
</div>
|
| 742 |
+
) : quarterColumns.length > 0 ? (
|
| 743 |
+
<div className="space-y-4">
|
| 744 |
+
<p className="text-xs text-slate-500">
|
| 745 |
+
USD→CAD rate (env):{' '}
|
| 746 |
+
<span className="font-mono tabular-nums">
|
| 747 |
+
{quarterlyBoard?.usd_to_cad_rate ?? '—'}
|
| 748 |
+
</span>
|
| 749 |
+
</p>
|
| 750 |
+
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
| 751 |
+
<CardShell
|
| 752 |
+
title="Recurring MRR (CAD)"
|
| 753 |
+
hint="Roll-forward like your deck: Total = portfolio at quarter end; Existing = prior quarter total; New = sum of MRR from deals won in the quarter."
|
| 754 |
+
className="min-h-0"
|
| 755 |
+
>
|
| 756 |
+
<div className="overflow-x-auto -m-1 p-1">
|
| 757 |
+
<table className="w-full min-w-[340px] text-sm">
|
| 758 |
+
<thead>
|
| 759 |
+
<tr className="border-b border-slate-200 text-left text-xs font-semibold text-slate-600">
|
| 760 |
+
<th className="py-2 pr-3"> </th>
|
| 761 |
+
{quarterColumns.map((q) => (
|
| 762 |
+
<th key={q.label} className="px-2 py-2 text-right whitespace-nowrap">
|
| 763 |
+
{q.label}
|
| 764 |
+
</th>
|
| 765 |
+
))}
|
| 766 |
+
</tr>
|
| 767 |
+
</thead>
|
| 768 |
+
<tbody className="tabular-nums">
|
| 769 |
+
<tr className="border-b border-slate-100">
|
| 770 |
+
<td className="py-2 pr-3 font-medium text-slate-800">TOTAL</td>
|
| 771 |
+
{quarterColumns.map((q) => (
|
| 772 |
+
<td key={q.label} className="px-2 py-2 text-right font-semibold">
|
| 773 |
+
{fmtCad(q.mrr?.total)}
|
| 774 |
+
</td>
|
| 775 |
+
))}
|
| 776 |
+
</tr>
|
| 777 |
+
<tr className="border-b border-slate-100">
|
| 778 |
+
<td className="py-2 pr-3 text-slate-600">EXISTING</td>
|
| 779 |
+
{quarterColumns.map((q) => (
|
| 780 |
+
<td key={q.label} className="px-2 py-2 text-right text-slate-700">
|
| 781 |
+
{fmtCad(q.mrr?.existing)}
|
| 782 |
+
</td>
|
| 783 |
+
))}
|
| 784 |
+
</tr>
|
| 785 |
+
<tr>
|
| 786 |
+
<td className="py-2 pr-3 text-slate-600">NEW</td>
|
| 787 |
+
{quarterColumns.map((q) => (
|
| 788 |
+
<td key={q.label} className="px-2 py-2 text-right text-slate-700">
|
| 789 |
+
{fmtCad(q.mrr?.new)}
|
| 790 |
+
</td>
|
| 791 |
+
))}
|
| 792 |
+
</tr>
|
| 793 |
+
</tbody>
|
| 794 |
+
</table>
|
| 795 |
+
</div>
|
| 796 |
+
<p className="mt-3 text-xs text-slate-500">
|
| 797 |
+
ARR (CAD) = MRR × 12 (same rows available as{' '}
|
| 798 |
+
<span className="font-medium text-slate-700">arr_cad</span> in the API).
|
| 799 |
+
</p>
|
| 800 |
+
</CardShell>
|
| 801 |
+
<CardShell
|
| 802 |
+
title="Recurring contracts"
|
| 803 |
+
hint="Count of recurring (non–one-time) won deals in the portfolio at quarter end; New = deals whose won date falls in the quarter."
|
| 804 |
+
className="min-h-0"
|
| 805 |
+
>
|
| 806 |
+
<div className="overflow-x-auto -m-1 p-1">
|
| 807 |
+
<table className="w-full min-w-[340px] text-sm">
|
| 808 |
+
<thead>
|
| 809 |
+
<tr className="border-b border-slate-200 text-left text-xs font-semibold text-slate-600">
|
| 810 |
+
<th className="py-2 pr-3"> </th>
|
| 811 |
+
{quarterColumns.map((q) => (
|
| 812 |
+
<th key={q.label} className="px-2 py-2 text-right whitespace-nowrap">
|
| 813 |
+
{q.label}
|
| 814 |
+
</th>
|
| 815 |
+
))}
|
| 816 |
+
</tr>
|
| 817 |
+
</thead>
|
| 818 |
+
<tbody className="tabular-nums">
|
| 819 |
+
<tr className="border-b border-slate-100">
|
| 820 |
+
<td className="py-2 pr-3 font-medium text-slate-800">TOTAL</td>
|
| 821 |
+
{quarterColumns.map((q) => (
|
| 822 |
+
<td key={q.label} className="px-2 py-2 text-right font-semibold">
|
| 823 |
+
{q.contracts?.total ?? '—'}
|
| 824 |
+
</td>
|
| 825 |
+
))}
|
| 826 |
+
</tr>
|
| 827 |
+
<tr className="border-b border-slate-100">
|
| 828 |
+
<td className="py-2 pr-3 text-slate-600">EXISTING</td>
|
| 829 |
+
{quarterColumns.map((q) => (
|
| 830 |
+
<td key={q.label} className="px-2 py-2 text-right text-slate-700">
|
| 831 |
+
{q.contracts?.existing ?? '—'}
|
| 832 |
+
</td>
|
| 833 |
+
))}
|
| 834 |
+
</tr>
|
| 835 |
+
<tr>
|
| 836 |
+
<td className="py-2 pr-3 text-slate-600">NEW</td>
|
| 837 |
+
{quarterColumns.map((q) => (
|
| 838 |
+
<td key={q.label} className="px-2 py-2 text-right text-slate-700">
|
| 839 |
+
{q.contracts?.new ?? '—'}
|
| 840 |
+
</td>
|
| 841 |
+
))}
|
| 842 |
+
</tr>
|
| 843 |
+
</tbody>
|
| 844 |
+
</table>
|
| 845 |
+
</div>
|
| 846 |
+
</CardShell>
|
| 847 |
+
</div>
|
| 848 |
+
<CardShell
|
| 849 |
+
title="One-time revenue (CAD)"
|
| 850 |
+
hint="Deals with revenue type One-time: invoice subtotal converted to CAD, recognized in the quarter the deal was marked won."
|
| 851 |
+
className="min-h-0"
|
| 852 |
+
>
|
| 853 |
+
<div className="overflow-x-auto -m-1 p-1">
|
| 854 |
+
<table className="w-full min-w-[280px] text-sm">
|
| 855 |
+
<thead>
|
| 856 |
+
<tr className="border-b border-slate-200 text-left text-xs font-semibold text-slate-600">
|
| 857 |
+
<th className="py-2 pr-3">Quarter</th>
|
| 858 |
+
<th className="py-2 text-right">One-time (CAD)</th>
|
| 859 |
+
</tr>
|
| 860 |
+
</thead>
|
| 861 |
+
<tbody className="tabular-nums">
|
| 862 |
+
{quarterColumns.map((q) => (
|
| 863 |
+
<tr key={q.label} className="border-b border-slate-100">
|
| 864 |
+
<td className="py-2 pr-3 text-slate-800">{q.label}</td>
|
| 865 |
+
<td className="py-2 text-right">
|
| 866 |
+
{fmtCad(q.one_time_cad_quarter)}
|
| 867 |
+
</td>
|
| 868 |
+
</tr>
|
| 869 |
+
))}
|
| 870 |
+
</tbody>
|
| 871 |
+
</table>
|
| 872 |
+
</div>
|
| 873 |
+
{(() => {
|
| 874 |
+
const last = quarterColumns[quarterColumns.length - 1];
|
| 875 |
+
const deals = last?.one_time_deals || [];
|
| 876 |
+
if (!deals.length) {
|
| 877 |
+
return (
|
| 878 |
+
<p className="mt-3 text-xs text-slate-500">
|
| 879 |
+
No one-time deals in the latest shown quarter.
|
| 880 |
+
</p>
|
| 881 |
+
);
|
| 882 |
+
}
|
| 883 |
+
return (
|
| 884 |
+
<div className="mt-4 rounded-lg border border-slate-100 bg-slate-50/80 px-3 py-2 text-xs text-slate-700">
|
| 885 |
+
<p className="font-semibold text-slate-800">
|
| 886 |
+
Latest quarter detail ({last?.label})
|
| 887 |
+
</p>
|
| 888 |
+
<ul className="mt-2 list-disc space-y-1 pl-5">
|
| 889 |
+
{deals.map((d) => (
|
| 890 |
+
<li key={d.deal_id}>
|
| 891 |
+
<span className="font-medium">{d.name || 'Deal'}</span>
|
| 892 |
+
{' · '}
|
| 893 |
+
{fmtCad(d.amount_cad)}
|
| 894 |
+
</li>
|
| 895 |
+
))}
|
| 896 |
+
</ul>
|
| 897 |
+
</div>
|
| 898 |
+
);
|
| 899 |
+
})()}
|
| 900 |
+
</CardShell>
|
| 901 |
+
</div>
|
| 902 |
+
) : (
|
| 903 |
+
<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">
|
| 904 |
+
No closed-won deals yet — quarterly MRR / contracts will appear after you mark deals won
|
| 905 |
+
(with billing lines and billing interval per line).
|
| 906 |
+
</p>
|
| 907 |
+
)}
|
| 908 |
+
|
| 909 |
<CardShell
|
| 910 |
title="Pipeline conversion"
|
| 911 |
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."
|