Skip to content

Commit f7209a9

Browse files
JacobCoffeeclaude
andcommitted
feat: finances page with Chart.js visualizations
Replaces the revenue report with a full Finances page (More > Finances) featuring Chart.js interactive charts: - Revenue Trend: stacked bar (finalized vs approved) across all years - Status Breakdown: doughnut chart for selected year - Revenue by Package: horizontal bar chart - Deal Count & Avg Size: combo bar+line chart showing trends Renames revenue -> finances throughout (URL, nav, guide, dashboard link). Sponsorship detail keeps its financial breakdown section with fee vs internal value coverage bar and per-program mini charts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent aa472df commit f7209a9

8 files changed

Lines changed: 369 additions & 219 deletions

File tree

apps/sponsors/manage/tests.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2575,34 +2575,34 @@ def test_nav_has_sponsors_link(self):
25752575
self.assertContains(response, "Sponsors")
25762576

25772577

2578-
class RevenueReportViewTests(SponsorshipReviewTestBase):
2578+
class FinancesViewTests(SponsorshipReviewTestBase):
25792579
"""Test revenue report view."""
25802580

25812581
def test_report_loads(self):
2582-
response = self.client.get(reverse("manage_revenue"))
2582+
response = self.client.get(reverse("manage_finances"))
25832583
self.assertEqual(response.status_code, 200)
25842584

25852585
def test_report_shows_revenue(self):
25862586
self.sponsorship.status = Sponsorship.FINALIZED
25872587
self.sponsorship.save(update_fields=["status"])
2588-
response = self.client.get(reverse("manage_revenue") + f"?year={self.year}")
2588+
response = self.client.get(reverse("manage_finances") + f"?year={self.year}")
25892589
self.assertContains(response, "150,000")
25902590
self.assertContains(response, "Acme Corp")
25912591

25922592
def test_report_excludes_applied(self):
25932593
"""Applied sponsorships are not counted in revenue."""
2594-
response = self.client.get(reverse("manage_revenue") + f"?year={self.year}")
2594+
response = self.client.get(reverse("manage_finances") + f"?year={self.year}")
25952595
self.assertNotContains(response, "Acme Corp")
25962596

25972597
def test_year_over_year(self):
25982598
self.sponsorship.status = Sponsorship.FINALIZED
25992599
self.sponsorship.save(update_fields=["status"])
2600-
response = self.client.get(reverse("manage_revenue") + f"?year={self.year}")
2600+
response = self.client.get(reverse("manage_finances") + f"?year={self.year}")
26012601
self.assertContains(response, str(self.year))
26022602

26032603
def test_dashboard_revenue_links_to_report(self):
26042604
response = self.client.get(reverse("manage_dashboard"))
2605-
self.assertContains(response, "manage_revenue" if False else "/sponsors/manage/revenue/")
2605+
self.assertContains(response, "/sponsors/manage/finances/")
26062606

26072607

26082608
class SponsorshipDetailFinancialTests(SponsorshipReviewTestBase):

apps/sponsors/manage/urls.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
path(
2424
"benefit-configs/<int:pk>/delete/", views.BenefitConfigDeleteView.as_view(), name="manage_benefit_config_delete"
2525
),
26-
# Revenue report
27-
path("revenue/", views.RevenueReportView.as_view(), name="manage_revenue"),
26+
# Finances
27+
path("finances/", views.FinancesView.as_view(), name="manage_finances"),
2828
# Asset browser
2929
path("assets/", views.AssetBrowserView.as_view(), name="manage_assets"),
3030
# Legal clauses

apps/sponsors/manage/views.py

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -555,20 +555,29 @@ def get_context_data(self, **kwargs):
555555
return context
556556

557557

558-
# ── Revenue Report ────────────────────────────────────────────────────
558+
# ── Finances ──────────────────────────────────────────────────────────
559559

560560

561-
class RevenueReportView(SponsorshipAdminRequiredMixin, TemplateView):
562-
"""Financial summary with revenue breakdowns and charts."""
561+
class FinancesView(SponsorshipAdminRequiredMixin, TemplateView):
562+
"""Financial overview with revenue breakdowns, trends, and charts."""
563563

564-
template_name = "sponsors/manage/revenue_report.html"
564+
template_name = "sponsors/manage/finances.html"
565565

566566
def _year_summary(self, year):
567567
"""Return revenue stats for a single year."""
568-
qs = Sponsorship.objects.filter(year=year, status__in=[Sponsorship.APPROVED, Sponsorship.FINALIZED])
569-
total = qs.aggregate(total=Sum("sponsorship_fee"))["total"] or 0
570-
count = qs.count()
571-
return {"year": year, "total": total, "count": count, "avg": total // count if count else 0}
568+
base = Sponsorship.objects.filter(year=year)
569+
committed = base.filter(status__in=[Sponsorship.APPROVED, Sponsorship.FINALIZED])
570+
total = committed.aggregate(total=Sum("sponsorship_fee"))["total"] or 0
571+
finalized = base.filter(status=Sponsorship.FINALIZED).aggregate(total=Sum("sponsorship_fee"))["total"] or 0
572+
count = committed.count()
573+
return {
574+
"year": year,
575+
"total": total,
576+
"finalized": finalized,
577+
"pending": total - finalized,
578+
"count": count,
579+
"avg": total // count if count else 0,
580+
}
572581

573582
def _package_breakdown(self, year_qs):
574583
"""Return revenue grouped by package tier."""
@@ -578,29 +587,27 @@ def _package_breakdown(self, year_qs):
578587
.order_by("-revenue")
579588
)
580589
return [
581-
{"name": r["package__name"] or "Custom / No Package", "revenue": r["revenue"] or 0, "count": r["count"]}
582-
for r in rows
590+
{"name": r["package__name"] or "Custom", "revenue": r["revenue"] or 0, "count": r["count"]} for r in rows
583591
]
584592

585593
def get_context_data(self, **kwargs):
586-
"""Return context with revenue breakdowns."""
594+
"""Return context with financial data for charts."""
587595
context = super().get_context_data(**kwargs)
596+
import json
588597

589-
# Year selection
590-
all_years = Sponsorship.objects.values_list("year", flat=True).distinct().order_by("-year")
598+
all_years = Sponsorship.objects.values_list("year", flat=True).distinct().order_by("year")
591599
all_years = [y for y in all_years if y]
592600

593601
selected_year = self.request.GET.get("year")
594602
if selected_year:
595603
selected_year = int(selected_year)
596604
elif all_years:
597-
selected_year = all_years[0]
605+
selected_year = all_years[-1]
598606

599-
# Year-over-year comparison
607+
# YoY data (chronological for charts)
600608
yoy = [self._year_summary(y) for y in all_years]
601-
max_yoy = max((s["total"] for s in yoy), default=1) or 1
602609

603-
# Current year detail
610+
# Selected year detail
604611
year_qs = Sponsorship.objects.filter(
605612
year=selected_year, status__in=[Sponsorship.APPROVED, Sponsorship.FINALIZED]
606613
)
@@ -615,7 +622,15 @@ def get_context_data(self, **kwargs):
615622

616623
# Package breakdown
617624
by_package = self._package_breakdown(year_qs)
618-
max_pkg = max((p["revenue"] for p in by_package), default=1) or 1
625+
626+
# Status breakdown (all statuses for selected year)
627+
all_year = Sponsorship.objects.filter(year=selected_year)
628+
status_counts = {
629+
"applied": all_year.filter(status=Sponsorship.APPLIED).count(),
630+
"approved": all_year.filter(status=Sponsorship.APPROVED).count(),
631+
"finalized": all_year.filter(status=Sponsorship.FINALIZED).count(),
632+
"rejected": all_year.filter(status=Sponsorship.REJECTED).count(),
633+
}
619634

620635
# Per-sponsorship detail table
621636
sponsorships = (
@@ -624,20 +639,40 @@ def get_context_data(self, **kwargs):
624639
for sp in sponsorships:
625640
sp.internal_total = sp.estimated_cost
626641

642+
# JSON data for Chart.js
643+
chart_data = {
644+
"yoy_labels": [s["year"] for s in yoy],
645+
"yoy_revenue": [s["total"] for s in yoy],
646+
"yoy_finalized": [s["finalized"] for s in yoy],
647+
"yoy_pending": [s["pending"] for s in yoy],
648+
"yoy_counts": [s["count"] for s in yoy],
649+
"yoy_avg": [s["avg"] for s in yoy],
650+
"pkg_labels": [p["name"] for p in by_package],
651+
"pkg_revenue": [p["revenue"] for p in by_package],
652+
"pkg_counts": [p["count"] for p in by_package],
653+
"status_labels": ["Applied", "Approved", "Finalized", "Rejected"],
654+
"status_counts": [
655+
status_counts["applied"],
656+
status_counts["approved"],
657+
status_counts["finalized"],
658+
status_counts["rejected"],
659+
],
660+
}
661+
627662
context.update(
628663
{
629-
"years": all_years,
664+
"years": list(reversed(all_years)),
630665
"selected_year": selected_year,
631666
"total_revenue": total_revenue,
632667
"total_count": total_count,
633668
"avg_deal": total_revenue // total_count if total_count else 0,
634669
"finalized_revenue": finalized_revenue,
635670
"approved_revenue": approved_revenue,
636671
"by_package": by_package,
637-
"max_pkg_revenue": max_pkg,
638672
"yoy": yoy,
639-
"max_yoy_revenue": max_yoy,
673+
"status_counts": status_counts,
640674
"sponsorships": sponsorships,
675+
"chart_data_json": json.dumps(chart_data),
641676
}
642677
)
643678
return context

apps/sponsors/templates/sponsors/manage/_base.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -679,7 +679,7 @@
679679
<div class="manage-nav-more" style="position:relative;">
680680
<a href="#" class="{% if active_tab == 'clone' or active_tab == 'guide' %}active{% endif %}" onclick="event.preventDefault();this.parentElement.classList.toggle('open');">More &#9662;</a>
681681
<div class="manage-nav-dropdown">
682-
<a href="{% url 'manage_revenue' %}">Revenue</a>
682+
<a href="{% url 'manage_finances' %}">Finances</a>
683683
<a href="{% url 'manage_sponsors' %}">Sponsors</a>
684684
<a href="{% url 'manage_legal_clauses' %}">Legal Clauses</a>
685685
<a href="{% url 'manage_assets' %}">Assets</a>

apps/sponsors/templates/sponsors/manage/dashboard.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
<div class="card-value">{{ total_sponsorships }}</div>
3030
<div class="card-label">Sponsorships</div>
3131
</div>
32-
<a href="{% url 'manage_revenue' %}?year={{ selected_year }}" class="manage-card gold" style="text-decoration:none;cursor:pointer;">
32+
<a href="{% url 'manage_finances' %}?year={{ selected_year }}" class="manage-card gold" style="text-decoration:none;cursor:pointer;">
3333
<div class="card-value">${{ total_revenue|floatformat:"0" }}</div>
3434
<div class="card-label">Revenue &rarr;</div>
3535
</a>

0 commit comments

Comments
 (0)