Seth commited on
Commit
86b80b0
·
1 Parent(s): 0a4bb6a
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 (same presets as the sales dashboard).
 
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
- return build_quarterly_board(deals_payload, max_quarters=max_quarters)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- /** Latest roll-forward row from API + merged one-time (calendar buckets); UI labels column by date filter, not Q2 2026. */
603
- const revenueBoardSnapshot = useMemo(() => {
604
- const qs = quarterlyBoard?.quarters || [];
605
- const cols = qs.length <= 6 ? qs : qs.slice(-6);
606
- const row = cols.length ? cols[cols.length - 1] : null;
607
- let oneTimeSum = 0;
608
- const mergedDeals = [];
609
- const seen = new Set();
610
- for (const q of cols) {
611
- oneTimeSum += Number(q.one_time_usd_quarter) || 0;
612
- for (const d of q.one_time_deals || []) {
613
- const id = d.deal_id;
614
- if (id != null && !seen.has(id)) {
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
- ) : revenueBoardSnapshot.hasData ? (
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-2">
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
- </div>
826
- <CardShell
827
- title="One-time revenue"
828
- hint="PO lines marked One-time; USD ($). Total summed for the filtered date range."
829
- className="min-h-0"
830
- >
831
- <div className="overflow-x-auto -m-1 p-1">
832
- <table className="w-full min-w-[260px] text-sm">
833
- <thead>
834
- <tr className="border-b border-slate-200 text-left text-xs font-semibold text-slate-600">
835
- <th className="py-2 pr-3">Scope</th>
836
- <th className="py-2 text-right">One-time ($)</th>
837
- </tr>
838
- </thead>
839
- <tbody className="tabular-nums">
840
- <tr className="border-b border-slate-100">
841
- <td className="py-2 pr-3 text-slate-800">{dashboardPeriodLabel}</td>
842
- <td className="py-2 text-right">
843
- {fmtMoney(revenueBoardSnapshot.oneTimeSum)}
844
- </td>
845
- </tr>
846
- </tbody>
847
- </table>
848
- </div>
849
- {revenueBoardSnapshot.mergedDeals.length === 0 ? (
850
- <p className="mt-3 text-xs text-slate-500">
851
- No one-time revenue in this scope.
852
- </p>
853
- ) : (
854
- <div className="mt-4 rounded-lg border border-slate-100 bg-slate-50/80 px-3 py-2 text-xs text-slate-700">
855
- <p className="font-semibold text-slate-800">
856
- Detail ({dashboardPeriodLabel}
857
- {isAdmin && ownerFilter !== 'all'
858
- ? ` · ${ownerFilterLabel}`
859
- : ''}
860
- )
861
- </p>
862
- <ul className="mt-2 list-disc space-y-1 pl-5">
863
- {revenueBoardSnapshot.mergedDeals.map((d) => (
864
- <li key={d.deal_id}>
865
- <span className="font-medium">{d.name || 'Deal'}</span>
866
- {' · '}
867
- {fmtMoney(d.amount_usd)}
868
- </li>
869
- ))}
870
- </ul>
871
  </div>
872
- )}
873
- </CardShell>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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">