Seth commited on
Commit
00a69e6
·
1 Parent(s): 86b80b0
frontend/src/lib/wonBillingEmailFormat.js ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Matches backend/app/main.py helpers used in _notify_tenant_admins_deal_won (won-deal email body).
3
+ */
4
+
5
+ export function wonCurrencyLabel(raw) {
6
+ const c = String(raw || 'USD').trim().toUpperCase();
7
+ return c === 'CAD' ? 'CAD' : 'USD';
8
+ }
9
+
10
+ export function fmtWonMoney(amount, currencyRaw) {
11
+ const lab = wonCurrencyLabel(currencyRaw);
12
+ const sym = lab === 'CAD' ? 'CA$' : '$';
13
+ let n = Number(amount);
14
+ if (!Number.isFinite(n)) n = 0;
15
+ const s = n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
16
+ return `${sym}${s} ${lab}`;
17
+ }
18
+
19
+ export function wonBillingIntervalLabel(raw) {
20
+ const s = String(raw || 'monthly').trim().toLowerCase();
21
+ if (['one_time', 'onetime', 'one-time', 'non-recurring'].includes(s)) return 'One-time';
22
+ if (['quarterly', 'quarter', 'every 3 months'].includes(s)) return 'Every 3 months';
23
+ if (['annual', 'year', 'yearly', 'every year'].includes(s)) return 'Every year';
24
+ return 'Every month';
25
+ }
26
+
27
+ /** Python {:g}-style qty for line display */
28
+ export function fmtQtyG(qf) {
29
+ if (!Number.isFinite(qf)) return '0';
30
+ return Number.isInteger(qf) ? String(qf) : String(qf);
31
+ }
32
+
33
+ /**
34
+ * One numbered line item line, same text as the admin email (excluding leading newline).
35
+ */
36
+ export function wonEmailLineItemBodyLine(index1, li) {
37
+ const ps = String(li.product_service || '').trim();
38
+ const cur = wonCurrencyLabel(li.currency);
39
+ let qf = Number(li.qty);
40
+ let rf = Number(li.rate);
41
+ let af = Number(li.amount);
42
+ if (!Number.isFinite(qf)) qf = 0;
43
+ if (!Number.isFinite(rf)) rf = 0;
44
+ if (!Number.isFinite(af)) af = 0;
45
+ const cadence = wonBillingIntervalLabel(li.billing_interval);
46
+ return ` ${index1}. ${ps} | ${cadence} | Qty ${fmtQtyG(qf)} × ${fmtWonMoney(rf, cur)} = ${fmtWonMoney(af, cur)}`;
47
+ }
48
+
49
+ export function wonEmailSubtotalLine(lines, subtotal) {
50
+ const st = Number(subtotal);
51
+ const sum = Number.isFinite(st) ? st : 0;
52
+ if (!lines?.length) {
53
+ return `Subtotal: ${sum.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
54
+ }
55
+ const distinct = new Set(lines.map((li) => wonCurrencyLabel(li.currency)));
56
+ if (distinct.size === 1) {
57
+ const only = distinct.values().next().value;
58
+ return `Subtotal: ${fmtWonMoney(sum, only)}`;
59
+ }
60
+ return `Subtotal (numeric sum of line amounts; USD and CAD lines are not converted between currencies): ${sum.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
61
+ }
frontend/src/pages/Deals.jsx CHANGED
@@ -17,6 +17,7 @@ import { SearchableCountryPicker } from '@/components/workspace/SearchableCountr
17
  import { cn } from '@/lib/utils';
18
  import { OwnerAvatarCircle, ownerDisplayLabel } from '@/lib/ownerAvatar';
19
  import { flagEmojiFromCode, getAllCountries, matchCountry } from '@/lib/countries';
 
20
 
21
  const STAGES = [
22
  { value: 'new', label: 'New', className: 'bg-slate-800 text-white' },
@@ -121,6 +122,48 @@ function revenueTypeSelectValue(deal) {
121
  return REVENUE_TYPES.some((t) => t.value === v) ? v : 'arr';
122
  }
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  function focusFirstEditableInRow(tr) {
125
  if (!tr) return;
126
  const el = tr.querySelector('input:not([type="checkbox"]):not([type="hidden"]), textarea');
@@ -904,6 +947,14 @@ export default function Deals() {
904
  const isAdmin = me?.current_role === 'admin';
905
  const currentUserId = me?.user_id ?? null;
906
 
 
 
 
 
 
 
 
 
907
  const refreshMe = useCallback(() => {
908
  apiFetch('/api/auth/me')
909
  .then((r) => (r.ok ? r.json() : null))
@@ -1894,59 +1945,10 @@ export default function Deals() {
1894
  </p>
1895
  ) : null}
1896
  {dealDetail.stage === 'won' && dealDetail.won_po_number ? (
1897
- <div className="mt-3 space-y-1.5 rounded-md border border-emerald-100 bg-emerald-50/60 p-3 text-xs text-slate-700">
1898
- <p className="font-semibold text-emerald-900">Won — billing snapshot</p>
1899
- <p>
1900
- <span className="font-medium text-slate-800">PO:</span>{' '}
1901
- {dealDetail.won_po_number}
1902
- </p>
1903
- <p>
1904
- <span className="font-medium text-slate-800">Legal name:</span>{' '}
1905
- {dealDetail.won_customer_legal_name}
1906
- </p>
1907
- <p>
1908
- <span className="font-medium text-slate-800">Address:</span>{' '}
1909
- <span className="whitespace-pre-wrap">{dealDetail.won_customer_address}</span>
1910
- </p>
1911
- <p>
1912
- <span className="font-medium text-slate-800">Contact:</span>{' '}
1913
- {dealDetail.won_contact_person_name}
1914
- </p>
1915
- {dealDetail.won_channel_partner_name ? (
1916
- <p>
1917
- <span className="font-medium text-slate-800">Channel partner:</span>{' '}
1918
- {dealDetail.won_channel_partner_name}
1919
- </p>
1920
- ) : null}
1921
- <p className="pt-1 text-slate-600">
1922
- <span className="font-medium text-slate-800">Note (customer):</span>{' '}
1923
- {dealDetail.won_note_to_customer}
1924
- </p>
1925
- <p className="text-slate-600">
1926
- <span className="font-medium text-slate-800">Note (accounts):</span>{' '}
1927
- {dealDetail.won_note_to_accounts}
1928
- </p>
1929
- <ul className="list-inside list-disc pt-1 text-slate-600">
1930
- {(dealDetail.won_line_items || []).map((li, i) => (
1931
- <li key={i}>
1932
- {li.product_service}{' '}
1933
- <span className="tabular-nums">
1934
- (×{li.qty} @ {li.rate} = {li.amount})
1935
- </span>
1936
- </li>
1937
- ))}
1938
- </ul>
1939
- <p className="pt-1 font-semibold text-slate-900 tabular-nums">
1940
- Subtotal:{' '}
1941
- {(dealDetail.won_line_items || []).reduce(
1942
- (a, li) => a + (Number(li.amount) || 0),
1943
- 0
1944
- ).toLocaleString('en-US', {
1945
- style: 'currency',
1946
- currency: 'USD',
1947
- })}
1948
- </p>
1949
- </div>
1950
  ) : null}
1951
  </div>
1952
  <div onClick={(e) => e.stopPropagation()}>
 
17
  import { cn } from '@/lib/utils';
18
  import { OwnerAvatarCircle, ownerDisplayLabel } from '@/lib/ownerAvatar';
19
  import { flagEmojiFromCode, getAllCountries, matchCountry } from '@/lib/countries';
20
+ import { wonEmailLineItemBodyLine, wonEmailSubtotalLine } from '@/lib/wonBillingEmailFormat';
21
 
22
  const STAGES = [
23
  { value: 'new', label: 'New', className: 'bg-slate-800 text-white' },
 
122
  return REVENUE_TYPES.some((t) => t.value === v) ? v : 'arr';
123
  }
124
 
125
+ /** Same structure and copy as the won-deal admin email (main.py _notify_tenant_admins_deal_won). */
126
+ function WonBillingEmailSnapshot({ deal, workspaceName }) {
127
+ const lines = deal.won_line_items || [];
128
+ const subtotal = lines.reduce((a, li) => a + (Number(li.amount) || 0), 0);
129
+ const s = (v) => String(v ?? '').trim();
130
+ const ws = (workspaceName || '').trim() || 'Workspace';
131
+ return (
132
+ <div className="mt-3 space-y-1.5 rounded-md border border-emerald-100 bg-emerald-50/60 p-3 text-xs text-slate-700">
133
+ <p className="font-semibold text-emerald-900">Won — billing snapshot</p>
134
+ <p>A deal was marked Won in workspace &quot;{ws}&quot;.</p>
135
+ <p className="pt-1">Deal ID: {deal.id}</p>
136
+ <p>Deal name: {s(deal.name) || 'Deal'}</p>
137
+ <p>PO number: {s(deal.won_po_number)}</p>
138
+ <p>Customer legal name: {s(deal.won_customer_legal_name)}</p>
139
+ <p>Customer address:</p>
140
+ <p className="whitespace-pre-wrap text-slate-700">{s(deal.won_customer_address)}</p>
141
+ <p>Contact person: {s(deal.won_contact_person_name)}</p>
142
+ {s(deal.won_channel_partner_name) ? (
143
+ <p>Channel partner: {s(deal.won_channel_partner_name)}</p>
144
+ ) : null}
145
+ <p className="pt-1">Note to customer:</p>
146
+ <p className="whitespace-pre-wrap text-slate-700">{s(deal.won_note_to_customer)}</p>
147
+ <p className="pt-1">Note to our accounts:</p>
148
+ <p className="whitespace-pre-wrap text-slate-700">{s(deal.won_note_to_accounts)}</p>
149
+ <p className="pt-1">Products / services (line items):</p>
150
+ <ul className="list-none space-y-0.5 pl-0 text-slate-700">
151
+ {lines.map((li, i) => (
152
+ <li key={i}>
153
+ <p className="whitespace-pre-wrap leading-relaxed text-slate-800">
154
+ {wonEmailLineItemBodyLine(i + 1, li)}
155
+ </p>
156
+ {s(li.description) ? (
157
+ <p className="whitespace-pre-wrap text-slate-600">{` Description: ${s(li.description)}`}</p>
158
+ ) : null}
159
+ </li>
160
+ ))}
161
+ </ul>
162
+ <p className="pt-1 font-semibold tabular-nums text-slate-900">{wonEmailSubtotalLine(lines, subtotal)}</p>
163
+ </div>
164
+ );
165
+ }
166
+
167
  function focusFirstEditableInRow(tr) {
168
  if (!tr) return;
169
  const el = tr.querySelector('input:not([type="checkbox"]):not([type="hidden"]), textarea');
 
947
  const isAdmin = me?.current_role === 'admin';
948
  const currentUserId = me?.user_id ?? null;
949
 
950
+ const workspaceLabel = useMemo(() => {
951
+ const tid = me?.current_tenant_id;
952
+ const list = me?.tenants;
953
+ if (tid == null || !Array.isArray(list)) return 'Workspace';
954
+ const t = list.find((x) => x.id === tid);
955
+ return (t?.name || '').trim() || 'Workspace';
956
+ }, [me]);
957
+
958
  const refreshMe = useCallback(() => {
959
  apiFetch('/api/auth/me')
960
  .then((r) => (r.ok ? r.json() : null))
 
1945
  </p>
1946
  ) : null}
1947
  {dealDetail.stage === 'won' && dealDetail.won_po_number ? (
1948
+ <WonBillingEmailSnapshot
1949
+ deal={dealDetail}
1950
+ workspaceName={workspaceLabel}
1951
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1952
  ) : null}
1953
  </div>
1954
  <div onClick={(e) => e.stopPropagation()}>
frontend/src/pages/SalesDashboard.jsx CHANGED
@@ -49,25 +49,16 @@ function startOfQuarter(d) {
49
  }
50
 
51
  /** Dashboard date filter: open pipeline deals by created_at; Won deals by won_at when present. */
52
- /** Latest roll-forward row + merged one-time from quarterly API payload. */
53
  function revenueSnapshotFromQuarters(quarters) {
54
  const qs = quarters || [];
55
  const cols = qs.length <= 6 ? qs : qs.slice(-6);
56
  const row = cols.length ? cols[cols.length - 1] : null;
57
  let oneTimeSum = 0;
58
- const mergedDeals = [];
59
- const seen = new Set();
60
  for (const q of cols) {
61
  oneTimeSum += Number(q.one_time_usd_quarter) || 0;
62
- for (const d of q.one_time_deals || []) {
63
- const id = d.deal_id;
64
- if (id != null && !seen.has(id)) {
65
- seen.add(id);
66
- mergedDeals.push(d);
67
- }
68
- }
69
  }
70
- return { row, oneTimeSum, mergedDeals, hasData: Boolean(row) };
71
  }
72
 
73
  function dealInDashboardPeriod(deal, period) {
@@ -932,30 +923,11 @@ export default function SalesDashboard() {
932
  </tbody>
933
  </table>
934
  </div>
935
- {revenueBoardSnapshot.mergedDeals.length === 0 ? (
936
  <p className="mt-3 text-xs text-slate-500">
937
  No one-time revenue in this scope.
938
  </p>
939
- ) : (
940
- <div className="mt-4 rounded-lg border border-slate-100 bg-slate-50/80 px-3 py-2 text-xs text-slate-700">
941
- <p className="font-semibold text-slate-800">
942
- Detail ({dashboardPeriodLabel}
943
- {isAdmin && ownerFilter !== 'all'
944
- ? ` · ${ownerFilterLabel}`
945
- : ''}
946
- )
947
- </p>
948
- <ul className="mt-2 list-disc space-y-1 pl-5">
949
- {revenueBoardSnapshot.mergedDeals.map((d) => (
950
- <li key={d.deal_id}>
951
- <span className="font-medium">{d.name || 'Deal'}</span>
952
- {' · '}
953
- {fmtMoney(d.amount_usd)}
954
- </li>
955
- ))}
956
- </ul>
957
- </div>
958
- )}
959
  </CardShell>
960
  </div>
961
  </div>
 
49
  }
50
 
51
  /** Dashboard date filter: open pipeline deals by created_at; Won deals by won_at when present. */
52
+ /** Latest roll-forward row + one-time total from quarterly API payload. */
53
  function revenueSnapshotFromQuarters(quarters) {
54
  const qs = quarters || [];
55
  const cols = qs.length <= 6 ? qs : qs.slice(-6);
56
  const row = cols.length ? cols[cols.length - 1] : null;
57
  let oneTimeSum = 0;
 
 
58
  for (const q of cols) {
59
  oneTimeSum += Number(q.one_time_usd_quarter) || 0;
 
 
 
 
 
 
 
60
  }
61
+ return { row, oneTimeSum, hasData: Boolean(row) };
62
  }
63
 
64
  function dealInDashboardPeriod(deal, period) {
 
923
  </tbody>
924
  </table>
925
  </div>
926
+ {revenueBoardSnapshot.oneTimeSum === 0 ? (
927
  <p className="mt-3 text-xs text-slate-500">
928
  No one-time revenue in this scope.
929
  </p>
930
+ ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
931
  </CardShell>
932
  </div>
933
  </div>