Seth commited on
Commit ·
86b80b0
1
Parent(s): 0a4bb6a
update
Browse files- backend/app/main.py +78 -2
- frontend/src/pages/SalesDashboard.jsx +157 -70
backend/app/main.py
CHANGED
|
@@ -577,6 +577,53 @@ def _dashboard_period_bounds_utc(period: str) -> Optional[tuple[datetime, dateti
|
|
| 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
|
|
@@ -2849,7 +2896,8 @@ async def quarterly_recurring_metrics(
|
|
| 2849 |
):
|
| 2850 |
"""
|
| 2851 |
Won deals → ARR (USD) roll-forward by calendar quarter from PO lines (interval per line + one-time lines).
|
| 2852 |
-
Optional period=all|month|quarter|year filters by won_at
|
|
|
|
| 2853 |
"""
|
| 2854 |
db = t.db
|
| 2855 |
q = db.query(CrmDeal).filter(
|
|
@@ -2876,7 +2924,35 @@ async def quarterly_recurring_metrics(
|
|
| 2876 |
"won_at": wa,
|
| 2877 |
}
|
| 2878 |
)
|
| 2879 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2880 |
|
| 2881 |
|
| 2882 |
@app.post("/api/deals")
|
|
|
|
| 577 |
return None
|
| 578 |
|
| 579 |
|
| 580 |
+
def _previous_period_bounds_utc(period: str) -> Optional[tuple[datetime, datetime]]:
|
| 581 |
+
"""Calendar window immediately before the current preset (month → prior month, etc.)."""
|
| 582 |
+
p = (period or "all").strip().lower()
|
| 583 |
+
if p in ("", "all"):
|
| 584 |
+
return None
|
| 585 |
+
now = datetime.utcnow()
|
| 586 |
+
if p == "month":
|
| 587 |
+
if now.month == 1:
|
| 588 |
+
y, m = now.year - 1, 12
|
| 589 |
+
else:
|
| 590 |
+
y, m = now.year, now.month - 1
|
| 591 |
+
start = datetime(y, m, 1)
|
| 592 |
+
last = monthrange(y, m)[1]
|
| 593 |
+
end = datetime(y, m, last, 23, 59, 59)
|
| 594 |
+
return start, end
|
| 595 |
+
if p == "quarter":
|
| 596 |
+
qi = (now.month - 1) // 3
|
| 597 |
+
if qi == 0:
|
| 598 |
+
y = now.year - 1
|
| 599 |
+
sm = 10
|
| 600 |
+
else:
|
| 601 |
+
y = now.year
|
| 602 |
+
sm = (qi - 1) * 3 + 1
|
| 603 |
+
start = datetime(y, sm, 1)
|
| 604 |
+
em = sm + 2
|
| 605 |
+
if em == 12:
|
| 606 |
+
end = datetime(y, 12, 31, 23, 59, 59)
|
| 607 |
+
else:
|
| 608 |
+
end = datetime(y, em + 1, 1) - timedelta(microseconds=1)
|
| 609 |
+
return start, end
|
| 610 |
+
if p == "year":
|
| 611 |
+
py = now.year - 1
|
| 612 |
+
return datetime(py, 1, 1), datetime(py, 12, 31, 23, 59, 59)
|
| 613 |
+
return None
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
def _comparison_period_label(period: str) -> str:
|
| 617 |
+
p = (period or "all").strip().lower()
|
| 618 |
+
if p == "month":
|
| 619 |
+
return "Last month"
|
| 620 |
+
if p == "quarter":
|
| 621 |
+
return "Last quarter"
|
| 622 |
+
if p == "year":
|
| 623 |
+
return "Last year"
|
| 624 |
+
return ""
|
| 625 |
+
|
| 626 |
+
|
| 627 |
def _won_at_within_bounds(won_at: Optional[datetime], bounds: Optional[tuple[datetime, datetime]]) -> bool:
|
| 628 |
if bounds is None:
|
| 629 |
return True
|
|
|
|
| 2896 |
):
|
| 2897 |
"""
|
| 2898 |
Won deals → ARR (USD) roll-forward by calendar quarter from PO lines (interval per line + one-time lines).
|
| 2899 |
+
Optional period=all|month|quarter|year filters by won_at.
|
| 2900 |
+
When period is not all, includes comparison roll-forward for the prior calendar period (last month / quarter / year).
|
| 2901 |
"""
|
| 2902 |
db = t.db
|
| 2903 |
q = db.query(CrmDeal).filter(
|
|
|
|
| 2924 |
"won_at": wa,
|
| 2925 |
}
|
| 2926 |
)
|
| 2927 |
+
base = build_quarterly_board(deals_payload, max_quarters=max_quarters)
|
| 2928 |
+
pnorm = (period or "all").strip().lower()
|
| 2929 |
+
if pnorm in ("", "all"):
|
| 2930 |
+
return {**base, "comparison": None}
|
| 2931 |
+
|
| 2932 |
+
bounds_prev = _previous_period_bounds_utc(period)
|
| 2933 |
+
deals_prev: list[dict] = []
|
| 2934 |
+
if bounds_prev:
|
| 2935 |
+
for r in rows:
|
| 2936 |
+
wa = getattr(r, "won_at", None)
|
| 2937 |
+
if not _won_at_within_bounds(wa, bounds_prev):
|
| 2938 |
+
continue
|
| 2939 |
+
deals_prev.append(
|
| 2940 |
+
{
|
| 2941 |
+
"id": r.id,
|
| 2942 |
+
"name": r.name or "",
|
| 2943 |
+
"revenue_type": (getattr(r, "revenue_type", None) or "arr"),
|
| 2944 |
+
"won_line_items": _won_line_items_list(r),
|
| 2945 |
+
"won_at": wa,
|
| 2946 |
+
}
|
| 2947 |
+
)
|
| 2948 |
+
comp = build_quarterly_board(deals_prev, max_quarters=max_quarters)
|
| 2949 |
+
return {
|
| 2950 |
+
**base,
|
| 2951 |
+
"comparison": {
|
| 2952 |
+
"period_label": _comparison_period_label(period),
|
| 2953 |
+
"quarters": comp.get("quarters", []),
|
| 2954 |
+
},
|
| 2955 |
+
}
|
| 2956 |
|
| 2957 |
|
| 2958 |
@app.post("/api/deals")
|
frontend/src/pages/SalesDashboard.jsx
CHANGED
|
@@ -49,6 +49,27 @@ function startOfQuarter(d) {
|
|
| 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 =
|
|
@@ -599,26 +620,19 @@ export default function SalesDashboard() {
|
|
| 599 |
? 'This year'
|
| 600 |
: 'All time';
|
| 601 |
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
seen.add(id);
|
| 616 |
-
mergedDeals.push(d);
|
| 617 |
-
}
|
| 618 |
-
}
|
| 619 |
-
}
|
| 620 |
-
return { row, oneTimeSum, mergedDeals, hasData: Boolean(row) };
|
| 621 |
-
}, [quarterlyBoard]);
|
| 622 |
|
| 623 |
const dateRangeSub =
|
| 624 |
dashboardPeriod === DASHBOARD_PERIOD_ALL
|
|
@@ -732,10 +746,19 @@ export default function SalesDashboard() {
|
|
| 732 |
<Loader2 className="h-5 w-5 animate-spin shrink-0" aria-hidden />
|
| 733 |
Loading quarterly metrics…
|
| 734 |
</div>
|
| 735 |
-
) :
|
| 736 |
<div className="space-y-4">
|
| 737 |
<p className="text-xs text-slate-500">
|
| 738 |
Values reflect <strong className="font-medium text-slate-700">{dashboardPeriodLabel}</strong>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 739 |
{isAdmin && ownerFilter !== 'all' ? (
|
| 740 |
<>
|
| 741 |
{' '}
|
|
@@ -745,7 +768,7 @@ export default function SalesDashboard() {
|
|
| 745 |
. ARR roll-forward row is the latest period in range; one-time total sums wins across that
|
| 746 |
range.
|
| 747 |
</p>
|
| 748 |
-
<div className="grid grid-cols-1 gap-4 xl:grid-cols-
|
| 749 |
<CardShell
|
| 750 |
title="Annual recurring revenue (ARR)"
|
| 751 |
hint="USD ($). PO lines: monthly ×12, quarterly ×4, annual ×1; one-time lines excluded. Column matches your date + team filters."
|
|
@@ -759,6 +782,11 @@ export default function SalesDashboard() {
|
|
| 759 |
<th className="px-2 py-2 text-right whitespace-nowrap">
|
| 760 |
{dashboardPeriodLabel}
|
| 761 |
</th>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 762 |
</tr>
|
| 763 |
</thead>
|
| 764 |
<tbody className="tabular-nums">
|
|
@@ -767,18 +795,33 @@ export default function SalesDashboard() {
|
|
| 767 |
<td className="px-2 py-2 text-right font-semibold">
|
| 768 |
{fmtMoney(revenueBoardSnapshot.row?.arr?.total)}
|
| 769 |
</td>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 770 |
</tr>
|
| 771 |
<tr className="border-b border-slate-100">
|
| 772 |
<td className="py-2 pr-3 text-slate-600">EXISTING</td>
|
| 773 |
<td className="px-2 py-2 text-right text-slate-700">
|
| 774 |
{fmtMoney(revenueBoardSnapshot.row?.arr?.existing)}
|
| 775 |
</td>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 776 |
</tr>
|
| 777 |
<tr>
|
| 778 |
<td className="py-2 pr-3 text-slate-600">NEW</td>
|
| 779 |
<td className="px-2 py-2 text-right text-slate-700">
|
| 780 |
{fmtMoney(revenueBoardSnapshot.row?.arr?.new)}
|
| 781 |
</td>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 782 |
</tr>
|
| 783 |
</tbody>
|
| 784 |
</table>
|
|
@@ -797,6 +840,11 @@ export default function SalesDashboard() {
|
|
| 797 |
<th className="px-2 py-2 text-right whitespace-nowrap">
|
| 798 |
{dashboardPeriodLabel}
|
| 799 |
</th>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 800 |
</tr>
|
| 801 |
</thead>
|
| 802 |
<tbody className="tabular-nums">
|
|
@@ -805,72 +853,111 @@ export default function SalesDashboard() {
|
|
| 805 |
<td className="px-2 py-2 text-right font-semibold">
|
| 806 |
{revenueBoardSnapshot.row?.contracts?.total ?? '—'}
|
| 807 |
</td>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 808 |
</tr>
|
| 809 |
<tr className="border-b border-slate-100">
|
| 810 |
<td className="py-2 pr-3 text-slate-600">EXISTING</td>
|
| 811 |
<td className="px-2 py-2 text-right text-slate-700">
|
| 812 |
{revenueBoardSnapshot.row?.contracts?.existing ?? '—'}
|
| 813 |
</td>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 814 |
</tr>
|
| 815 |
<tr>
|
| 816 |
<td className="py-2 pr-3 text-slate-600">NEW</td>
|
| 817 |
<td className="px-2 py-2 text-right text-slate-700">
|
| 818 |
{revenueBoardSnapshot.row?.contracts?.new ?? '—'}
|
| 819 |
</td>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 820 |
</tr>
|
| 821 |
</tbody>
|
| 822 |
</table>
|
| 823 |
</div>
|
| 824 |
</CardShell>
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
</
|
| 869 |
-
|
| 870 |
-
</
|
| 871 |
</div>
|
| 872 |
-
|
| 873 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 874 |
</div>
|
| 875 |
) : (
|
| 876 |
<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">
|
|
|
|
| 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) {
|
| 74 |
if (period === DASHBOARD_PERIOD_ALL) return true;
|
| 75 |
const iso =
|
|
|
|
| 620 |
? 'This year'
|
| 621 |
: 'All time';
|
| 622 |
|
| 623 |
+
const revenueBoardSnapshot = useMemo(
|
| 624 |
+
() => revenueSnapshotFromQuarters(quarterlyBoard?.quarters),
|
| 625 |
+
[quarterlyBoard]
|
| 626 |
+
);
|
| 627 |
+
const previousBoardSnapshot = useMemo(
|
| 628 |
+
() => revenueSnapshotFromQuarters(quarterlyBoard?.comparison?.quarters),
|
| 629 |
+
[quarterlyBoard]
|
| 630 |
+
);
|
| 631 |
+
const showPeriodComparison =
|
| 632 |
+
dashboardPeriod !== DASHBOARD_PERIOD_ALL && Boolean(quarterlyBoard?.comparison?.period_label);
|
| 633 |
+
const comparisonPeriodLabel = quarterlyBoard?.comparison?.period_label || '';
|
| 634 |
+
|
| 635 |
+
const revenueSectionHasData = revenueBoardSnapshot.hasData || previousBoardSnapshot.hasData;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
|
| 637 |
const dateRangeSub =
|
| 638 |
dashboardPeriod === DASHBOARD_PERIOD_ALL
|
|
|
|
| 746 |
<Loader2 className="h-5 w-5 animate-spin shrink-0" aria-hidden />
|
| 747 |
Loading quarterly metrics…
|
| 748 |
</div>
|
| 749 |
+
) : revenueSectionHasData ? (
|
| 750 |
<div className="space-y-4">
|
| 751 |
<p className="text-xs text-slate-500">
|
| 752 |
Values reflect <strong className="font-medium text-slate-700">{dashboardPeriodLabel}</strong>
|
| 753 |
+
{showPeriodComparison ? (
|
| 754 |
+
<>
|
| 755 |
+
{' '}
|
| 756 |
+
vs{' '}
|
| 757 |
+
<strong className="font-medium text-slate-700">
|
| 758 |
+
{comparisonPeriodLabel}
|
| 759 |
+
</strong>
|
| 760 |
+
</>
|
| 761 |
+
) : null}
|
| 762 |
{isAdmin && ownerFilter !== 'all' ? (
|
| 763 |
<>
|
| 764 |
{' '}
|
|
|
|
| 768 |
. ARR roll-forward row is the latest period in range; one-time total sums wins across that
|
| 769 |
range.
|
| 770 |
</p>
|
| 771 |
+
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
| 772 |
<CardShell
|
| 773 |
title="Annual recurring revenue (ARR)"
|
| 774 |
hint="USD ($). PO lines: monthly ×12, quarterly ×4, annual ×1; one-time lines excluded. Column matches your date + team filters."
|
|
|
|
| 782 |
<th className="px-2 py-2 text-right whitespace-nowrap">
|
| 783 |
{dashboardPeriodLabel}
|
| 784 |
</th>
|
| 785 |
+
{showPeriodComparison ? (
|
| 786 |
+
<th className="px-2 py-2 text-right whitespace-nowrap">
|
| 787 |
+
{comparisonPeriodLabel}
|
| 788 |
+
</th>
|
| 789 |
+
) : null}
|
| 790 |
</tr>
|
| 791 |
</thead>
|
| 792 |
<tbody className="tabular-nums">
|
|
|
|
| 795 |
<td className="px-2 py-2 text-right font-semibold">
|
| 796 |
{fmtMoney(revenueBoardSnapshot.row?.arr?.total)}
|
| 797 |
</td>
|
| 798 |
+
{showPeriodComparison ? (
|
| 799 |
+
<td className="px-2 py-2 text-right font-semibold">
|
| 800 |
+
{fmtMoney(previousBoardSnapshot.row?.arr?.total)}
|
| 801 |
+
</td>
|
| 802 |
+
) : null}
|
| 803 |
</tr>
|
| 804 |
<tr className="border-b border-slate-100">
|
| 805 |
<td className="py-2 pr-3 text-slate-600">EXISTING</td>
|
| 806 |
<td className="px-2 py-2 text-right text-slate-700">
|
| 807 |
{fmtMoney(revenueBoardSnapshot.row?.arr?.existing)}
|
| 808 |
</td>
|
| 809 |
+
{showPeriodComparison ? (
|
| 810 |
+
<td className="px-2 py-2 text-right text-slate-700">
|
| 811 |
+
{fmtMoney(previousBoardSnapshot.row?.arr?.existing)}
|
| 812 |
+
</td>
|
| 813 |
+
) : null}
|
| 814 |
</tr>
|
| 815 |
<tr>
|
| 816 |
<td className="py-2 pr-3 text-slate-600">NEW</td>
|
| 817 |
<td className="px-2 py-2 text-right text-slate-700">
|
| 818 |
{fmtMoney(revenueBoardSnapshot.row?.arr?.new)}
|
| 819 |
</td>
|
| 820 |
+
{showPeriodComparison ? (
|
| 821 |
+
<td className="px-2 py-2 text-right text-slate-700">
|
| 822 |
+
{fmtMoney(previousBoardSnapshot.row?.arr?.new)}
|
| 823 |
+
</td>
|
| 824 |
+
) : null}
|
| 825 |
</tr>
|
| 826 |
</tbody>
|
| 827 |
</table>
|
|
|
|
| 840 |
<th className="px-2 py-2 text-right whitespace-nowrap">
|
| 841 |
{dashboardPeriodLabel}
|
| 842 |
</th>
|
| 843 |
+
{showPeriodComparison ? (
|
| 844 |
+
<th className="px-2 py-2 text-right whitespace-nowrap">
|
| 845 |
+
{comparisonPeriodLabel}
|
| 846 |
+
</th>
|
| 847 |
+
) : null}
|
| 848 |
</tr>
|
| 849 |
</thead>
|
| 850 |
<tbody className="tabular-nums">
|
|
|
|
| 853 |
<td className="px-2 py-2 text-right font-semibold">
|
| 854 |
{revenueBoardSnapshot.row?.contracts?.total ?? '—'}
|
| 855 |
</td>
|
| 856 |
+
{showPeriodComparison ? (
|
| 857 |
+
<td className="px-2 py-2 text-right font-semibold">
|
| 858 |
+
{previousBoardSnapshot.row?.contracts?.total ?? '—'}
|
| 859 |
+
</td>
|
| 860 |
+
) : null}
|
| 861 |
</tr>
|
| 862 |
<tr className="border-b border-slate-100">
|
| 863 |
<td className="py-2 pr-3 text-slate-600">EXISTING</td>
|
| 864 |
<td className="px-2 py-2 text-right text-slate-700">
|
| 865 |
{revenueBoardSnapshot.row?.contracts?.existing ?? '—'}
|
| 866 |
</td>
|
| 867 |
+
{showPeriodComparison ? (
|
| 868 |
+
<td className="px-2 py-2 text-right text-slate-700">
|
| 869 |
+
{previousBoardSnapshot.row?.contracts?.existing ?? '—'}
|
| 870 |
+
</td>
|
| 871 |
+
) : null}
|
| 872 |
</tr>
|
| 873 |
<tr>
|
| 874 |
<td className="py-2 pr-3 text-slate-600">NEW</td>
|
| 875 |
<td className="px-2 py-2 text-right text-slate-700">
|
| 876 |
{revenueBoardSnapshot.row?.contracts?.new ?? '—'}
|
| 877 |
</td>
|
| 878 |
+
{showPeriodComparison ? (
|
| 879 |
+
<td className="px-2 py-2 text-right text-slate-700">
|
| 880 |
+
{previousBoardSnapshot.row?.contracts?.new ?? '—'}
|
| 881 |
+
</td>
|
| 882 |
+
) : null}
|
| 883 |
</tr>
|
| 884 |
</tbody>
|
| 885 |
</table>
|
| 886 |
</div>
|
| 887 |
</CardShell>
|
| 888 |
+
<CardShell
|
| 889 |
+
title="One-time revenue"
|
| 890 |
+
hint="PO lines marked One-time; USD ($). Total summed for the filtered date range."
|
| 891 |
+
className="min-h-0"
|
| 892 |
+
>
|
| 893 |
+
<div className="overflow-x-auto -m-1 p-1">
|
| 894 |
+
<table className="w-full min-w-[260px] text-sm">
|
| 895 |
+
<thead>
|
| 896 |
+
<tr className="border-b border-slate-200 text-left text-xs font-semibold text-slate-600">
|
| 897 |
+
<th className="py-2 pr-3">Scope</th>
|
| 898 |
+
{showPeriodComparison ? (
|
| 899 |
+
<>
|
| 900 |
+
<th className="py-2 px-2 text-right whitespace-nowrap">
|
| 901 |
+
{dashboardPeriodLabel}
|
| 902 |
+
</th>
|
| 903 |
+
<th className="py-2 px-2 text-right whitespace-nowrap">
|
| 904 |
+
{comparisonPeriodLabel}
|
| 905 |
+
</th>
|
| 906 |
+
</>
|
| 907 |
+
) : (
|
| 908 |
+
<th className="py-2 text-right">One-time ($)</th>
|
| 909 |
+
)}
|
| 910 |
+
</tr>
|
| 911 |
+
</thead>
|
| 912 |
+
<tbody className="tabular-nums">
|
| 913 |
+
<tr className="border-b border-slate-100">
|
| 914 |
+
<td className="py-2 pr-3 text-slate-800">
|
| 915 |
+
{showPeriodComparison ? 'One-time' : dashboardPeriodLabel}
|
| 916 |
+
</td>
|
| 917 |
+
<td
|
| 918 |
+
className={
|
| 919 |
+
showPeriodComparison
|
| 920 |
+
? 'py-2 px-2 text-right'
|
| 921 |
+
: 'py-2 text-right'
|
| 922 |
+
}
|
| 923 |
+
>
|
| 924 |
+
{fmtMoney(revenueBoardSnapshot.oneTimeSum)}
|
| 925 |
+
</td>
|
| 926 |
+
{showPeriodComparison ? (
|
| 927 |
+
<td className="py-2 px-2 text-right">
|
| 928 |
+
{fmtMoney(previousBoardSnapshot.oneTimeSum)}
|
| 929 |
+
</td>
|
| 930 |
+
) : null}
|
| 931 |
+
</tr>
|
| 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>
|
| 962 |
) : (
|
| 963 |
<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">
|