Seth commited on
Commit ·
00a69e6
1
Parent(s): 86b80b0
update
Browse files
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 |
-
<
|
| 1898 |
-
|
| 1899 |
-
|
| 1900 |
-
|
| 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 "{ws}".</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 +
|
| 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,
|
| 71 |
}
|
| 72 |
|
| 73 |
function dealInDashboardPeriod(deal, period) {
|
|
@@ -932,30 +923,11 @@ export default function SalesDashboard() {
|
|
| 932 |
</tbody>
|
| 933 |
</table>
|
| 934 |
</div>
|
| 935 |
-
{revenueBoardSnapshot.
|
| 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>
|