Seth commited on
Commit
73f93b9
·
1 Parent(s): 1a77d3d
Files changed (1) hide show
  1. frontend/src/pages/SalesDashboard.jsx +92 -87
frontend/src/pages/SalesDashboard.jsx CHANGED
@@ -599,10 +599,25 @@ export default function SalesDashboard() {
599
  ? 'This year'
600
  : 'All time';
601
 
602
- const quarterColumns = useMemo(() => {
 
603
  const qs = quarterlyBoard?.quarters || [];
604
- if (qs.length <= 6) return qs;
605
- return qs.slice(-6);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
606
  }, [quarterlyBoard]);
607
 
608
  const dateRangeSub =
@@ -673,7 +688,7 @@ export default function SalesDashboard() {
673
  <Select value={dashboardPeriod} onValueChange={setDashboardPeriod}>
674
  <SelectTrigger
675
  className="h-9 w-[min(100%,10.5rem)] border-slate-200 bg-white text-sm shadow-sm"
676
- aria-label="Date range: deal created"
677
  >
678
  <span className="truncate">{dashboardPeriodLabel}</span>
679
  </SelectTrigger>
@@ -717,50 +732,53 @@ export default function SalesDashboard() {
717
  <Loader2 className="h-5 w-5 animate-spin shrink-0" aria-hidden />
718
  Loading quarterly metrics…
719
  </div>
720
- ) : quarterColumns.length > 0 ? (
721
  <div className="space-y-4">
 
 
 
 
 
 
 
 
 
 
 
722
  <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
723
  <CardShell
724
  title="Annual recurring revenue (ARR)"
725
- hint="USD ($). From won PO lines: monthly ×12, quarterly ×4, annual ×1; one-time lines excluded. Total / Existing / New roll-forward by quarter. Respects team + date range filters."
726
  className="min-h-0"
727
  >
728
  <div className="overflow-x-auto -m-1 p-1">
729
- <table className="w-full min-w-[340px] text-sm">
730
  <thead>
731
  <tr className="border-b border-slate-200 text-left text-xs font-semibold text-slate-600">
732
  <th className="py-2 pr-3"> </th>
733
- {quarterColumns.map((q) => (
734
- <th key={q.label} className="px-2 py-2 text-right whitespace-nowrap">
735
- {q.label}
736
- </th>
737
- ))}
738
  </tr>
739
  </thead>
740
  <tbody className="tabular-nums">
741
  <tr className="border-b border-slate-100">
742
  <td className="py-2 pr-3 font-medium text-slate-800">TOTAL</td>
743
- {quarterColumns.map((q) => (
744
- <td key={q.label} className="px-2 py-2 text-right font-semibold">
745
- {fmtMoney(q.arr?.total)}
746
- </td>
747
- ))}
748
  </tr>
749
  <tr className="border-b border-slate-100">
750
  <td className="py-2 pr-3 text-slate-600">EXISTING</td>
751
- {quarterColumns.map((q) => (
752
- <td key={q.label} className="px-2 py-2 text-right text-slate-700">
753
- {fmtMoney(q.arr?.existing)}
754
- </td>
755
- ))}
756
  </tr>
757
  <tr>
758
  <td className="py-2 pr-3 text-slate-600">NEW</td>
759
- {quarterColumns.map((q) => (
760
- <td key={q.label} className="px-2 py-2 text-right text-slate-700">
761
- {fmtMoney(q.arr?.new)}
762
- </td>
763
- ))}
764
  </tr>
765
  </tbody>
766
  </table>
@@ -768,45 +786,37 @@ export default function SalesDashboard() {
768
  </CardShell>
769
  <CardShell
770
  title="Recurring contracts"
771
- hint="Won deals with at least one recurring PO line (not one-time only). Respects team + date filters."
772
  className="min-h-0"
773
  >
774
  <div className="overflow-x-auto -m-1 p-1">
775
- <table className="w-full min-w-[340px] text-sm">
776
  <thead>
777
  <tr className="border-b border-slate-200 text-left text-xs font-semibold text-slate-600">
778
  <th className="py-2 pr-3"> </th>
779
- {quarterColumns.map((q) => (
780
- <th key={q.label} className="px-2 py-2 text-right whitespace-nowrap">
781
- {q.label}
782
- </th>
783
- ))}
784
  </tr>
785
  </thead>
786
  <tbody className="tabular-nums">
787
  <tr className="border-b border-slate-100">
788
  <td className="py-2 pr-3 font-medium text-slate-800">TOTAL</td>
789
- {quarterColumns.map((q) => (
790
- <td key={q.label} className="px-2 py-2 text-right font-semibold">
791
- {q.contracts?.total ?? '—'}
792
- </td>
793
- ))}
794
  </tr>
795
  <tr className="border-b border-slate-100">
796
  <td className="py-2 pr-3 text-slate-600">EXISTING</td>
797
- {quarterColumns.map((q) => (
798
- <td key={q.label} className="px-2 py-2 text-right text-slate-700">
799
- {q.contracts?.existing ?? '—'}
800
- </td>
801
- ))}
802
  </tr>
803
  <tr>
804
  <td className="py-2 pr-3 text-slate-600">NEW</td>
805
- {quarterColumns.map((q) => (
806
- <td key={q.label} className="px-2 py-2 text-right text-slate-700">
807
- {q.contracts?.new ?? '—'}
808
- </td>
809
- ))}
810
  </tr>
811
  </tbody>
812
  </table>
@@ -815,56 +825,51 @@ export default function SalesDashboard() {
815
  </div>
816
  <CardShell
817
  title="One-time revenue"
818
- hint="Sum of PO lines marked One-time in the win popover; recognized in the quarter the deal was marked won. USD ($). Same filters as above."
819
  className="min-h-0"
820
  >
821
  <div className="overflow-x-auto -m-1 p-1">
822
- <table className="w-full min-w-[280px] text-sm">
823
  <thead>
824
  <tr className="border-b border-slate-200 text-left text-xs font-semibold text-slate-600">
825
- <th className="py-2 pr-3">Quarter</th>
826
  <th className="py-2 text-right">One-time ($)</th>
827
  </tr>
828
  </thead>
829
  <tbody className="tabular-nums">
830
- {quarterColumns.map((q) => (
831
- <tr key={q.label} className="border-b border-slate-100">
832
- <td className="py-2 pr-3 text-slate-800">{q.label}</td>
833
- <td className="py-2 text-right">
834
- {fmtMoney(q.one_time_usd_quarter)}
835
- </td>
836
- </tr>
837
- ))}
838
  </tbody>
839
  </table>
840
  </div>
841
- {(() => {
842
- const last = quarterColumns[quarterColumns.length - 1];
843
- const deals = last?.one_time_deals || [];
844
- if (!deals.length) {
845
- return (
846
- <p className="mt-3 text-xs text-slate-500">
847
- No one-time deals in the latest shown quarter.
848
- </p>
849
- );
850
- }
851
- return (
852
- <div className="mt-4 rounded-lg border border-slate-100 bg-slate-50/80 px-3 py-2 text-xs text-slate-700">
853
- <p className="font-semibold text-slate-800">
854
- Latest quarter detail ({last?.label})
855
- </p>
856
- <ul className="mt-2 list-disc space-y-1 pl-5">
857
- {deals.map((d) => (
858
- <li key={d.deal_id}>
859
- <span className="font-medium">{d.name || 'Deal'}</span>
860
- {' · '}
861
- {fmtMoney(d.amount_usd)}
862
- </li>
863
- ))}
864
- </ul>
865
- </div>
866
- );
867
- })()}
868
  </CardShell>
869
  </div>
870
  ) : (
 
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 =
 
688
  <Select value={dashboardPeriod} onValueChange={setDashboardPeriod}>
689
  <SelectTrigger
690
  className="h-9 w-[min(100%,10.5rem)] border-slate-200 bg-white text-sm shadow-sm"
691
+ aria-label="Date range for dashboard"
692
  >
693
  <span className="truncate">{dashboardPeriodLabel}</span>
694
  </SelectTrigger>
 
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
+ {' '}
742
+ · <strong className="font-medium text-slate-700">{ownerFilterLabel}</strong>
743
+ </>
744
+ ) : null}
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."
752
  className="min-h-0"
753
  >
754
  <div className="overflow-x-auto -m-1 p-1">
755
+ <table className="w-full min-w-[260px] text-sm">
756
  <thead>
757
  <tr className="border-b border-slate-200 text-left text-xs font-semibold text-slate-600">
758
  <th className="py-2 pr-3"> </th>
759
+ <th className="px-2 py-2 text-right whitespace-nowrap">
760
+ {dashboardPeriodLabel}
761
+ </th>
 
 
762
  </tr>
763
  </thead>
764
  <tbody className="tabular-nums">
765
  <tr className="border-b border-slate-100">
766
  <td className="py-2 pr-3 font-medium text-slate-800">TOTAL</td>
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>
 
786
  </CardShell>
787
  <CardShell
788
  title="Recurring contracts"
789
+ hint="Won deals with at least one recurring PO line. Same filters."
790
  className="min-h-0"
791
  >
792
  <div className="overflow-x-auto -m-1 p-1">
793
+ <table className="w-full min-w-[260px] text-sm">
794
  <thead>
795
  <tr className="border-b border-slate-200 text-left text-xs font-semibold text-slate-600">
796
  <th className="py-2 pr-3"> </th>
797
+ <th className="px-2 py-2 text-right whitespace-nowrap">
798
+ {dashboardPeriodLabel}
799
+ </th>
 
 
800
  </tr>
801
  </thead>
802
  <tbody className="tabular-nums">
803
  <tr className="border-b border-slate-100">
804
  <td className="py-2 pr-3 font-medium text-slate-800">TOTAL</td>
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>
 
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
  ) : (