Fix Doctor-Wise Revenue and Analytics
Link bills to consulting doctors and rebuild the analytics dashboard.
Introduction
30 Second Summary
Imagine a hospital where every bill gets credited to the person who typed it into the system, not the doctor who actually treated the patient. Your revenue reports would be completely wrong, and no one could trust the analytics dashboard.
In this project, you will fix a hospital billing system's revenue tracking by linking each bill to its consulting doctor and rebuilding the analytics dashboard from scratch with accurate data, responsive styling, and working charts.
What You'll Build
A complete fix to the hospital billing and analytics pipeline, where bills correctly track which doctor they belong to and a redesigned dashboard visualizes doctor-wise revenue.
By the end of this project, you'll have:
- A ConsultingDoctorId foreign key on the Bill model with an Entity Framework Core migration, separating bill creation from doctor revenue attribution.
- A role-based bill creation flow with a conditional doctor dropdown that auto-assigns for Doctors and requires explicit selection for other roles.
- A redesigned Analytics Dashboard built with CSS modules, summary cards, a fixed Recharts bar chart, period filters, and a doctor revenue table.
- Secret Mission: Build an admin backfill tool that lets admins assign a consulting doctor to old bills missing this data.
Are there any prerequisites?
Yes. This project builds on the Korutla Hospital RBAC and Analytics Dashboard projects. You need both the ASP.NET Core API and React frontend running locally with PostgreSQL before you begin.
Add ConsultingDoctorId to the Bill Model
The hospital's analytics currently attribute revenue to whoever created the bill. When a receptionist creates a bill on behalf of a doctor, the revenue shows up under the receptionist's name.
To fix this, you'll add a new field called ConsultingDoctorId to the Bill model. This field explicitly tracks which doctor each bill belongs to, completely separate from the existing CreatedById audit trail.
In this step, get ready to:
- Add a ConsultingDoctorId foreign key and navigation property to the Bill model.
- Configure the relationship in the database context.
- Generate and apply an EF Core migration to update the database.
Add the foreign key and configure the relationship
You need two changes across two files. First, the Bill model needs new properties. Then, the database context needs to know how to map those properties to a foreign key relationship.
Why a separate ConsultingDoctorId?
The system allows anyone (Admin, Receptionist, BillingStaff, Doctor) to create bills. CreatedById correctly tracks who entered the bill into the system.
ConsultingDoctorId tracks a different concern: which doctor the revenue belongs to. Keeping these as separate columns means analytics can report on revenue per doctor without breaking the audit trail.
- In Cursor, open HospitalApi/Models/Bill.cs.
- Add the following two properties below the existing properties in the Bill class:
// Add these properties inside the Bill class, below existing properties
public string? ConsultingDoctorId { get; set; }
public IdentityUser? ConsultingDoctor { get; set; }
What do these properties do?
- ConsultingDoctorId is a nullable string that holds the ID of the doctor this bill belongs to. It is nullable because existing bills in the database predate this field and will have null values.
- ConsultingDoctor is a navigation property that lets Entity Framework Core load the related user object when you query bills.
- Save Bill.cs.
- Now open HospitalApi/Data/HospitalDbContext.cs.
- Find the OnModelCreating method and add this relationship configuration below any existing Bill configuration:
// Add inside OnModelCreating, below existing Bill configuration
modelBuilder.Entity<Bill>()
.HasOne(b => b.ConsultingDoctor)
.WithMany()
.HasForeignKey(b => b.ConsultingDoctorId)
.OnDelete(DeleteBehavior.SetNull);
What does this configuration do?
- .HasOne(b => b.ConsultingDoctor) tells EF Core that each Bill has one related ConsultingDoctor.
- .WithMany() means a doctor can be linked to many bills, but we don't need a navigation collection on the user side.
- .OnDelete(DeleteBehavior.SetNull) sets the foreign key column to null if the referenced doctor is ever deleted. This requires the column to be nullable, which it is.
- Save HospitalDbContext.cs.
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
Here are the additions you should have in each file:
// Existing Bill class properties remain unchanged.
// Added below existing properties:
public string? ConsultingDoctorId { get; set; }
public IdentityUser? ConsultingDoctor { get; set; }
// Added inside OnModelCreating, below existing Bill configuration:
modelBuilder.Entity<Bill>()
.HasOne(b => b.ConsultingDoctor)
.WithMany()
.HasForeignKey(b => b.ConsultingDoctorId)
.OnDelete(DeleteBehavior.SetNull);
Generate and apply the EF Core migration
With the model and context updated, you need to generate a migration file that describes the database schema change, then apply it to PostgreSQL.
- Make sure your terminal is in the HospitalApi directory. Generate the migration by running:
dotnet ef migrations add AddBillConsultingDoctor
- You should see Build succeeded. followed by Done. in the output.
- Apply the migration to your database by running:
dotnet ef database update
- You should see Applying migration followed by Done. in the output.
What just happened?
EF Core generated a migration file that adds a nullable ConsultingDoctorId column to the Bills table. Because the column is nullable, all existing rows automatically get a null value. No existing data is affected.
Seeing a build error?
Make sure you saved both Bill.cs and HospitalDbContext.cs before running the migration command. EF Core compiles your project first, so any unsaved or incomplete code will cause a build failure.
Also confirm your terminal is inside the HospitalApi directory, not the solution root or frontend folder.
Help me debug my EF Core migration error.
Verify the migration in PostgreSQL
The migration reported success, but let's confirm the column actually exists in your database by querying the schema directly.
- Verify the new column exists by running this psql command:
psql -d HospitalDb -c "SELECT column_name FROM information_schema.columns WHERE table_name = 'Bills' AND column_name = 'ConsultingDoctorId';"
- You should see ConsultingDoctorId listed in the output with 1 row returned.
Getting connection refused or database not found?
Make sure your PostgreSQL service is running. If you see a database does not exist error, double check the database name matches what your project uses (the name is case-sensitive).
Help me connect to my PostgreSQL database.
Your Bills table now has a dedicated column to track which doctor each bill belongs to. Next up, you'll build an API endpoint to fetch the list of doctors and update the bill creation logic to populate this new field.
Build the Doctors API and Update Bill Creation
Your database now has the ConsultingDoctorId column ready and waiting. The schema knows about the relationship, but the API has no way to populate it yet.
In this step, you'll build a new endpoint that returns only Doctor-role users and update the bill creation logic to intelligently assign ConsultingDoctorId based on who is creating the bill.
In this step, get ready to:
- Create a DoctorsController with a GET endpoint that queries users by role.
- Update the bill creation action to set ConsultingDoctorId based on the caller's role.
- Test that auto-assignment works for Doctors and validation works for other roles.
Create the DoctorsController
The frontend will need a dropdown of doctors to choose from. To power that dropdown, you need an API endpoint that returns only users with the Doctor role.
ASP.NET Core Identity provides UserManager which has a method called GetUsersInRoleAsync that queries users by their assigned role. You'll use this to filter for Doctor-role users only.
- Create a new file called DoctorsController.cs inside the HospitalApi/Controllers/ folder.
- Paste the following code into the new file:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace HospitalApi.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class DoctorsController : ControllerBase
{
private readonly UserManager<IdentityUser> _userManager;
public DoctorsController(UserManager<IdentityUser> userManager)
{
_userManager = userManager;
}
[HttpGet]
public async Task<IActionResult> GetDoctors()
{
// Query all users assigned the Doctor role
var doctors = await _userManager.GetUsersInRoleAsync("Doctor");
// Return only the id and email for each doctor
var result = doctors.Select(d => new
{
id = d.Id,
email = d.Email
});
return Ok(result);
}
}
What does this code do?
This controller creates a GET /api/doctors endpoint that any authenticated user can call (protected by [Authorize] with no specific role requirement).
It injects UserManager<IdentityUser> through the constructor, then calls GetUsersInRoleAsync("Doctor") to retrieve only users with the Doctor role. The response is a simple list of { id, email } objects that the frontend can display in a dropdown.
- Save DoctorsController.cs.
Why return only id and email?
The IdentityUser object contains sensitive fields like password hashes and security stamps. By projecting to an anonymous object with just id and email, you expose only what the frontend needs for the dropdown.
Update the bill creation logic with role-based assignment
The current bill creation action assigns CreatedById to track who entered the bill, but it never touches ConsultingDoctorId. You need role-based logic: if a Doctor creates a bill, auto-assign them as the consulting doctor. If anyone else creates it, require them to select a doctor explicitly.
- Open BillsController.cs in HospitalApi/Controllers/.
- Add the following using directive at the top of the file with the other using statements:
using System.Security.Claims;
- Find the bill creation action method. Locate the line where the bill gets saved to the database, and add the following code above it:
// Add this logic before saving the bill to the database
var callerRole = User.FindFirstValue(ClaimTypes.Role) ?? "";
var callerId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
if (callerRole == "Doctor")
{
bill.ConsultingDoctorId = callerId;
}
else
{
if (string.IsNullOrEmpty(bill.ConsultingDoctorId))
{
return BadRequest(new { message = "ConsultingDoctorId is required when not logged in as a Doctor." });
}
}
// Keep existing CreatedById assignment
bill.CreatedById = callerId;
What does this code do?
- User.FindFirstValue(ClaimTypes.Role) extracts the caller's role from their JWT token claims.
- User.FindFirstValue(ClaimTypes.NameIdentifier) extracts the caller's user ID from the same claims.
- If the caller is a Doctor, the code auto-assigns their ID as the ConsultingDoctorId. Doctors are always the consulting doctor on their own bills.
- If the caller is a Receptionist or Admin, the code expects ConsultingDoctorId to come from the request body. If it's missing, the API returns a 400 Bad Request.
- If your existing code had a different variable name for the user ID (like userId), update the CreatedById assignment line to use callerId instead.
- Save BillsController.cs.
Seeing a compile error on FindFirstValue?
Make sure you added using System.Security.Claims; at the top of the file. Without this using directive, the compiler cannot resolve ClaimTypes.
Help me fix my BillsController compile error
Test both API paths
You need to verify two things: the doctors endpoint returns only Doctor-role users, and the bill creation logic correctly handles both roles.
- Switch back to the terminal where your API is running.
- Stop the server with Ctrl+C.
- Restart the API by running:
dotnet run
- Open your browser and log in as a Doctor user.
- Open the browser's developer console (F12 or Cmd+Option+I on macOS).
- Test the doctors endpoint by navigating to or calling GET /api/doctors. You should see a JSON array containing only users with the Doctor role, each with an id and email field.
- Create a bill while logged in as a Doctor. The bill should save successfully with ConsultingDoctorId auto-assigned to the Doctor's user ID.
- Log in as a Receptionist and attempt to create a bill without selecting a consulting doctor. You should receive a 400 response with the message ConsultingDoctorId is required when not logged in as a Doctor..
Verify with psql
You can confirm the auto-assignment worked by querying the database directly. Run this in a separate terminal:
psql -d HospitalDb -c "SELECT Id, ConsultingDoctorId, CreatedById FROM \"Bills\" ORDER BY \"CreatedAt\" DESC LIMIT 5;"
Bills created by a Doctor should show the same value in both ConsultingDoctorId and CreatedById.
GET /api/doctors returning an empty array?
- Make sure you have at least one user assigned the Doctor role in your database. Role names are case-sensitive.
- Check that your role name matches exactly. The code queries for "Doctor" with a capital D.
Help me debug why my /api/doctors endpoint returns empty
✔️ Awesome, I've got everything!
Great. Make sure you've saved both DoctorsController.cs and BillsController.cs.
ⓧ I'd like to double check the full code
Here is the full DoctorsController.cs:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace HospitalApi.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class DoctorsController : ControllerBase
{
private readonly UserManager<IdentityUser> _userManager;
public DoctorsController(UserManager<IdentityUser> userManager)
{
_userManager = userManager;
}
[HttpGet]
public async Task<IActionResult> GetDoctors()
{
var doctors = await _userManager.GetUsersInRoleAsync("Doctor");
var result = doctors.Select(d => new
{
id = d.Id,
email = d.Email
});
return Ok(result);
}
}
Here is the code you added inside the bill creation method in BillsController.cs (plus the using System.Security.Claims; at the top of the file):
// Inside the bill creation action method, before saving:
using System.Security.Claims;
// Add this logic before saving the bill to the database
var callerRole = User.FindFirstValue(ClaimTypes.Role) ?? "";
var callerId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
if (callerRole == "Doctor")
{
bill.ConsultingDoctorId = callerId;
}
else
{
if (string.IsNullOrEmpty(bill.ConsultingDoctorId))
{
return BadRequest(new { message = "ConsultingDoctorId is required when not logged in as a Doctor." });
}
}
// Keep existing CreatedById assignment
bill.CreatedById = callerId;
Your API now correctly assigns consulting doctors to bills based on role. Next up, you'll connect this to the frontend with a conditional doctor dropdown on the Create Bill page.
Add the Consulting Doctor Dropdown to the Create Bill UI
Your API now knows how to handle bill creation differently based on the caller's role. Doctors get auto-assigned, and everyone else must specify which doctor the bill belongs to.
The frontend needs to match this logic. In this step, you'll fetch the list of doctors from your new endpoint, conditionally render a dropdown for non-Doctor users, and wire the selection into the form submission.
In this step, get ready to:
- Fetch the doctors list from the API on component mount.
- Conditionally render a Consulting Doctor dropdown for non-Doctor users.
- Wire the form submission to include the selected doctor ID.
Add state and fetch doctors on mount
The Create Bill page needs to know which doctors exist so it can populate a dropdown. You'll add two pieces of state and a useEffect that calls your new GET /api/doctors endpoint when the component loads.
- In Cursor, open hospital-frontend/src/pages/CreateBillPage.tsx.
- Add the following state declarations inside your component function, below the existing state variables:
// State for the doctors list and selected doctor
const [doctors, setDoctors] = useState<{ id: string; email: string }[]>([]);
const [selectedDoctorId, setSelectedDoctorId] = useState("");
- Add this useEffect below the state declarations to fetch doctors when the page loads:
// Fetch the list of doctors on component mount
useEffect(() => {
const fetchDoctors = async () => {
try {
const res = await axiosClient.get("/api/doctors");
setDoctors(res.data);
} catch (err) {
console.error("Failed to fetch doctors", err);
}
};
fetchDoctors();
}, []);
What does this code do?
The doctors state holds an array of objects with id and email fields, matching the shape returned by your DoctorsController.
The useEffect with an empty dependency array [] runs once on mount, so the doctors list is ready before the user interacts with the form.
- Save CreateBillPage.tsx.
- Open your browser's DevTools console, navigate to the Create Bill page, and confirm you see a successful GET /api/doctors request in the Network tab returning an array of doctor objects.
Not seeing the doctors request?
Make sure you are logged in. The /api/doctors endpoint requires authentication, so an unauthenticated request returns 401.
Check that your API server is still running in its terminal. If it crashed, restart it with dotnet run from the HospitalApi directory.
Help me debug why my GET /api/doctors request isn't showing in DevTools Network tab.
Conditionally render the Consulting Doctor dropdown
Doctors should never see a dropdown because the backend auto-assigns their bills. Everyone else needs to explicitly pick a consulting doctor before submitting.
- Inside the form JSX in CreateBillPage.tsx, add the following conditional dropdown above the submit button:
{/* Show doctor dropdown only for non-Doctor users */}
{user?.role !== "Doctor" && (
<div>
<label htmlFor="consultingDoctor">Consulting Doctor</label>
<select
id="consultingDoctor"
value={selectedDoctorId}
onChange={(e) => setSelectedDoctorId(e.target.value)}
required
>
<option value="">-- Select a Doctor --</option>
{doctors.map((doc) => (
<option key={doc.id} value={doc.id}>
{doc.email}
</option>
))}
</select>
</div>
)}
What does this code do?
The user?.role !== "Doctor" check reads the role from your useAuth context. When the logged-in user is a Doctor, React skips rendering the entire <select> block.
The required attribute on the <select> gives you native HTML5 validation. The empty-string default option means the browser won't let the form submit until the user picks a real doctor.
- Save CreateBillPage.tsx and switch to your browser.
- Log in as a Receptionist. Navigate to the Create Bill page and confirm you see a Consulting Doctor dropdown populated with doctor email addresses.
- Log out and log in as a Doctor. Navigate to the Create Bill page and confirm the dropdown is not visible.
Dropdown showing but empty?
Check that you have at least one user assigned the "Doctor" role in your database. The GetUsersInRoleAsync("Doctor") call is case-sensitive and must match the role name exactly as stored.
My Consulting Doctor dropdown renders but has no options. Help me debug why the doctors array is empty.
Update form submission to include the selected doctor
The dropdown is visible and populated, but the form doesn't send the selected doctor to the API yet. You also need explicit frontend validation to prevent submission when no doctor is selected.
- In your form submission handler, add frontend validation and include consultingDoctorId in the request body by adding this logic:
// Add this validation check at the top of the submit handler
if (user?.role !== "Doctor" && !selectedDoctorId) {
// Prevent submission when dropdown is visible but no doctor selected
return;
}
// When building the request body, include consultingDoctorId for non-Doctor users
const billData = {
// ...existing form fields (patientName, totalAmount, etc.)
...(user?.role !== "Doctor" && { consultingDoctorId: selectedDoctorId }),
};
What does this code do?
The validation check stops the form from submitting when the dropdown is visible but empty. This catches the case before the request hits the backend.
The spread syntax ...(condition && { key: value }) conditionally adds consultingDoctorId to the request body only for non-Doctor users. Doctor users don't send it because the backend auto-assigns their ID.
- Save CreateBillPage.tsx.
- Log in as a Receptionist, fill in the bill form, select a doctor from the dropdown, and submit. Confirm the bill saves successfully.
- Try submitting without selecting a doctor. Confirm the form does not submit.
- Log in as a Doctor, fill in the bill form, and submit. Confirm the bill saves without a doctor dropdown appearing.
How do I verify the ConsultingDoctorId was saved correctly?
You can check the database directly with psql. Run the following to see the most recent bills and their assigned doctors:
psql -d HospitalDb -c "SELECT \"Id\", \"PatientName\", \"ConsultingDoctorId\", \"CreatedById\" FROM \"Bills\" ORDER BY \"CreatedAt\" DESC LIMIT 5;"
For Receptionist-created bills, ConsultingDoctorId should differ from CreatedById. For Doctor-created bills, both should be the same user ID.
Getting a 400 error when submitting as a Receptionist?
The backend returns 400 with the message "ConsultingDoctorId is required" when it doesn't receive the field. Check that your request body includes consultingDoctorId (camelCase) and that selectedDoctorId holds the actual doctor ID, not an empty string.
Open the Network tab in DevTools and inspect the request payload to see exactly what your frontend sends.
Help me debug a 400 error when creating a bill as a Receptionist with a consulting doctor selected.
✔️ Awesome, I've got everything!
Great. Double check you've saved CreateBillPage.tsx and confirmed both user roles work correctly.
ⓧ I'd like to double check the full code
Here is a summary of everything you added to CreateBillPage.tsx in this step. Confirm your component includes all of these additions:
// STATE: Add below existing state declarations
const [doctors, setDoctors] = useState<{ id: string; email: string }[]>([]);
const [selectedDoctorId, setSelectedDoctorId] = useState("");
// EFFECT: Fetch doctors list on mount
useEffect(() => {
const fetchDoctors = async () => {
try {
const res = await axiosClient.get("/api/doctors");
setDoctors(res.data);
} catch (err) {
console.error("Failed to fetch doctors", err);
}
};
fetchDoctors();
}, []);
// VALIDATION: Add at the top of your submit handler
if (user?.role !== "Doctor" && !selectedDoctorId) {
return;
}
// REQUEST BODY: Include consultingDoctorId for non-Doctor users
const billData = {
// ...existing form fields (patientName, totalAmount, etc.)
...(user?.role !== "Doctor" && { consultingDoctorId: selectedDoctorId }),
};
// JSX: Add this inside the form, above the submit button
{user?.role !== "Doctor" && (
<div>
<label htmlFor="consultingDoctor">Consulting Doctor</label>
<select
id="consultingDoctor"
value={selectedDoctorId}
onChange={(e) => setSelectedDoctorId(e.target.value)}
required
>
<option value="">-- Select a Doctor --</option>
{doctors.map((doc) => (
<option key={doc.id} value={doc.id}>
{doc.email}
</option>
))}
</select>
</div>
)}
Your frontend now correctly handles both roles. Next, you'll refactor the Analytics API to actually use this new ConsultingDoctorId field to calculate doctor-wise revenue accurately.
Refactor the Analytics API to Use ConsultingDoctorId
Your billing system now correctly links every new bill to its consulting doctor. The Create Bill page assigns ConsultingDoctorId automatically for doctors and via a dropdown for other roles. The data layer is solid.
The analytics endpoint still calculates revenue by CreatedById, which credits revenue to whoever typed in the bill. In this step, you will refactor the AnalyticsController to query revenue by ConsultingDoctorId instead. This gives the dashboard accurate doctor-wise revenue figures.
In this step, get ready to:
- Rewrite the GetRevenue endpoint to filter and group by consulting doctor.
- Add a TotalDoctors field to the analytics response DTO.
- Update the leaderboard endpoint to use consulting doctor data.
Update the GetRevenue base query and role filter
The existing GetRevenue method queries all bills and groups by CreatedById. You need to swap the entire method to use the new ConsultingDoctorId relationship for filtering and grouping.
- Replace the entire contents of HospitalApi/Controllers/AnalyticsController.cs with the following code:
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using HospitalApi.DTOs;
namespace HospitalApi.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(Roles = "Admin,Doctor")]
public class AnalyticsController : ControllerBase
{
private readonly HospitalDbContext _context;
public AnalyticsController(HospitalDbContext context)
{
_context = context;
}
[HttpGet("revenue")]
public async Task<IActionResult> GetRevenue([FromQuery] string period = "monthly")
{
// Extract the caller's role and ID from JWT claims
var callerRole = User.FindFirstValue(ClaimTypes.Role) ?? "";
var callerId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
// Base query: only bills with an assigned consulting doctor
var query = _context.Bills
.Include(b => b.ConsultingDoctor)
.Where(b => b.ConsultingDoctorId != null);
// Doctors only see their own bills
if (callerRole == "Doctor")
{
query = query.Where(b => b.ConsultingDoctorId == callerId);
}
var bills = await query.ToListAsync();
// Group revenue by the selected time period
var grouped = period switch
{
"daily" => bills
.Where(b => b.CreatedAt >= DateTime.UtcNow.AddDays(-30))
.GroupBy(b => b.CreatedAt.ToString("yyyy-MM-dd"))
.Select(g => new RevenueDataPointDto { Period = g.Key, Revenue = g.Sum(b => b.TotalAmount) })
.OrderBy(r => r.Period)
.ToList(),
"yearly" => bills
.GroupBy(b => b.CreatedAt.ToString("yyyy"))
.Select(g => new RevenueDataPointDto { Period = g.Key, Revenue = g.Sum(b => b.TotalAmount) })
.OrderBy(r => r.Period)
.ToList(),
_ => bills
.Where(b => b.CreatedAt >= DateTime.UtcNow.AddMonths(-12))
.GroupBy(b => b.CreatedAt.ToString("yyyy-MM"))
.Select(g => new RevenueDataPointDto { Period = g.Key, Revenue = g.Sum(b => b.TotalAmount) })
.OrderBy(r => r.Period)
.ToList(),
};
// Build the response with summary stats
var response = new AnalyticsResponseDto
{
TotalRevenue = bills.Sum(b => b.TotalAmount),
BillCount = bills.Count,
TotalDoctors = bills.Select(b => b.ConsultingDoctorId).Distinct().Count(),
RevenueOverTime = grouped,
};
// Admins get a per-doctor revenue breakdown
if (callerRole == "Admin")
{
response.DoctorBreakdown = bills
.GroupBy(b => b.ConsultingDoctor?.Email ?? "Unknown")
.Select(g => new DoctorRevenueDto
{
DoctorEmail = g.Key,
TotalRevenue = g.Sum(b => b.TotalAmount),
BillCount = g.Count(),
})
.OrderByDescending(d => d.TotalRevenue)
.ToList();
}
return Ok(response);
}
}
What does this code do?
The base query now filters with .Where(b => b.ConsultingDoctorId != null), which excludes old bills that predate the migration. It also uses .Include(b => b.ConsultingDoctor) to eager-load the doctor's user data for grouping by email.
For Doctor-role callers, an additional filter narrows results to b.ConsultingDoctorId == callerId. This replaces the old CreatedById filter so doctors see revenue for bills assigned to them, not bills they happened to type in.
The Admin-only DoctorBreakdown now groups by ConsultingDoctor?.Email and TotalDoctors counts distinct consulting doctor IDs across the filtered bills.
Why filter out null ConsultingDoctorId?
Bills created before your migration have ConsultingDoctorId = null. Including them would skew revenue totals because they cannot be attributed to any doctor. The Secret Mission later adds a backfill tool so admins can assign these orphaned bills.
The code references a TotalDoctors property that does not exist on AnalyticsResponseDto yet. You will add it in the next substep.
Add TotalDoctors to the analytics response DTO
The frontend needs to know how many unique doctors generated revenue. Adding a TotalDoctors field to the response DTO makes this value available for summary cards on the dashboard.
- Replace the contents of HospitalApi/DTOs/AnalyticsResponseDto.cs with the following code:
namespace HospitalApi.DTOs;
public class AnalyticsResponseDto
{
public decimal TotalRevenue { get; set; }
public int BillCount { get; set; }
// Count of unique consulting doctors with assigned bills
public int TotalDoctors { get; set; }
public List<RevenueDataPointDto> RevenueOverTime { get; set; } = new();
public List<DoctorRevenueDto>? DoctorBreakdown { get; set; }
}
What does this code do?
The new TotalDoctors property is an integer that the controller populates with the count of distinct ConsultingDoctorId values. The frontend will display this in a summary card alongside Total Revenue and Total Bills.
- Save AnalyticsResponseDto.cs.
Update the leaderboard endpoint and test
The leaderboard endpoint has the same problem as GetRevenue. It groups by the bill creator instead of the consulting doctor. You will apply the same pattern: filter on ConsultingDoctorId != null and group by ConsultingDoctor?.Email.
- In AnalyticsController.cs, add the following method below the closing brace of GetRevenue (but still inside the class):
[HttpGet("leaderboard")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> GetLeaderboard()
{
// Fetch only bills with an assigned consulting doctor
var bills = await _context.Bills
.Include(b => b.ConsultingDoctor)
.Where(b => b.ConsultingDoctorId != null)
.ToListAsync();
// Rank doctors by total revenue, take top 5
var leaderboard = bills
.GroupBy(b => b.ConsultingDoctor?.Email ?? "Unknown")
.Select(g => new DoctorRevenueDto
{
DoctorEmail = g.Key,
TotalRevenue = g.Sum(b => b.TotalAmount),
BillCount = g.Count(),
})
.OrderByDescending(d => d.TotalRevenue)
.Take(5)
.ToList();
return Ok(leaderboard);
}
What does this code do?
The leaderboard is restricted to Admin-only with [Authorize(Roles = "Admin")]. It queries bills where ConsultingDoctorId is not null, groups by the doctor's email, sums revenue per doctor, and returns the top 5 ranked by total revenue descending.
- Save AnalyticsController.cs.
- Stop the API server with Ctrl+C in the terminal running it.
- Restart the API by running:
dotnet run
- Open your browser console and test the revenue endpoint by navigating to the analytics page while logged in as an Admin.
- In the browser console Network tab, look for the /api/analytics/revenue request. The JSON response should include a totalDoctors field and doctorBreakdown grouped by consulting doctor emails.
Seeing a compilation error?
If dotnet run fails, check that you saved both AnalyticsResponseDto.cs and AnalyticsController.cs. The controller references TotalDoctors which must exist on the DTO for the build to succeed.
Also verify that the GetLeaderboard method is placed inside the AnalyticsController class (before the final closing brace), not outside it.
Help me debug my AnalyticsController compilation error.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the complete AnalyticsController.cs file:
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using HospitalApi.DTOs;
namespace HospitalApi.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(Roles = "Admin,Doctor")]
public class AnalyticsController : ControllerBase
{
private readonly HospitalDbContext _context;
public AnalyticsController(HospitalDbContext context)
{
_context = context;
}
[HttpGet("revenue")]
public async Task<IActionResult> GetRevenue([FromQuery] string period = "monthly")
{
var callerRole = User.FindFirstValue(ClaimTypes.Role) ?? "";
var callerId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
var query = _context.Bills
.Include(b => b.ConsultingDoctor)
.Where(b => b.ConsultingDoctorId != null);
if (callerRole == "Doctor")
{
query = query.Where(b => b.ConsultingDoctorId == callerId);
}
var bills = await query.ToListAsync();
var grouped = period switch
{
"daily" => bills
.Where(b => b.CreatedAt >= DateTime.UtcNow.AddDays(-30))
.GroupBy(b => b.CreatedAt.ToString("yyyy-MM-dd"))
.Select(g => new RevenueDataPointDto { Period = g.Key, Revenue = g.Sum(b => b.TotalAmount) })
.OrderBy(r => r.Period)
.ToList(),
"yearly" => bills
.GroupBy(b => b.CreatedAt.ToString("yyyy"))
.Select(g => new RevenueDataPointDto { Period = g.Key, Revenue = g.Sum(b => b.TotalAmount) })
.OrderBy(r => r.Period)
.ToList(),
_ => bills
.Where(b => b.CreatedAt >= DateTime.UtcNow.AddMonths(-12))
.GroupBy(b => b.CreatedAt.ToString("yyyy-MM"))
.Select(g => new RevenueDataPointDto { Period = g.Key, Revenue = g.Sum(b => b.TotalAmount) })
.OrderBy(r => r.Period)
.ToList(),
};
var response = new AnalyticsResponseDto
{
TotalRevenue = bills.Sum(b => b.TotalAmount),
BillCount = bills.Count,
TotalDoctors = bills.Select(b => b.ConsultingDoctorId).Distinct().Count(),
RevenueOverTime = grouped,
};
if (callerRole == "Admin")
{
response.DoctorBreakdown = bills
.GroupBy(b => b.ConsultingDoctor?.Email ?? "Unknown")
.Select(g => new DoctorRevenueDto
{
DoctorEmail = g.Key,
TotalRevenue = g.Sum(b => b.TotalAmount),
BillCount = g.Count(),
})
.OrderByDescending(d => d.TotalRevenue)
.ToList();
}
return Ok(response);
}
[HttpGet("leaderboard")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> GetLeaderboard()
{
var bills = await _context.Bills
.Include(b => b.ConsultingDoctor)
.Where(b => b.ConsultingDoctorId != null)
.ToListAsync();
var leaderboard = bills
.GroupBy(b => b.ConsultingDoctor?.Email ?? "Unknown")
.Select(g => new DoctorRevenueDto
{
DoctorEmail = g.Key,
TotalRevenue = g.Sum(b => b.TotalAmount),
BillCount = g.Count(),
})
.OrderByDescending(d => d.TotalRevenue)
.Take(5)
.ToList();
return Ok(leaderboard);
}
}
And here is the complete AnalyticsResponseDto.cs file:
namespace HospitalApi.DTOs;
public class AnalyticsResponseDto
{
public decimal TotalRevenue { get; set; }
public int BillCount { get; set; }
public int TotalDoctors { get; set; }
public List<RevenueDataPointDto> RevenueOverTime { get; set; } = new();
public List<DoctorRevenueDto>? DoctorBreakdown { get; set; }
}
Your analytics API now accurately calculates revenue by consulting doctor. Admins see the full breakdown across all doctors. Doctors see only their own revenue. Next up, you will rebuild the frontend dashboard to display this data with a clean, responsive design.
Redesign the Analytics Dashboard from Scratch
Your analytics API now returns revenue grouped by consulting doctor with accurate role enforcement.
The backend is solid, but the frontend still has the old dashboard with inline styles and broken chart bindings. In this step, you will rebuild the entire Analytics page from scratch using a CSS module for clean styling, fix the Recharts data binding, and add summary cards with a responsive layout.
In this step, get ready to:
- Create a CSS module with responsive styles for the analytics layout.
- Rewrite the AnalyticsPage component with corrected interfaces, summary cards, and a working bar chart.
- Test responsive behaviour and period switching.
Create the CSS module for the analytics page
CSS modules scope class names to a single component, preventing style collisions across your app. Files named *.module.css are automatically treated as CSS modules by Vite.
- Create a new file called AnalyticsPage.module.css inside hospital-frontend/src/pages/.
- Paste the following styles into the new file:
.container {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.title {
font-size: 28px;
font-weight: 700;
color: #1a1a2e;
margin: 0;
}
.periodGroup {
display: flex;
gap: 8px;
}
.periodButton {
padding: 8px 20px;
border: 1px solid #ddd;
border-radius: 6px;
background: #fff;
color: #555;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
text-transform: capitalize;
}
.periodButton:hover {
border-color: #1a1a2e;
color: #1a1a2e;
}
.periodButtonActive {
padding: 8px 20px;
border: 1px solid #1a1a2e;
border-radius: 6px;
background: #1a1a2e;
color: #fff;
font-size: 14px;
cursor: pointer;
text-transform: capitalize;
}
.cardsRow {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.card {
padding: 20px;
border: 1px solid #e8e8e8;
border-radius: 8px;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.cardLabel {
font-size: 13px;
color: #888;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.cardValue {
font-size: 28px;
font-weight: 700;
color: #1a1a2e;
}
.chartSection {
border: 1px solid #e8e8e8;
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.chartTitle {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0 0 16px 0;
}
.tableSection {
border: 1px solid #e8e8e8;
border-radius: 8px;
padding: 20px;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.tableTitle {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0 0 16px 0;
}
.table {
width: 100%;
border-collapse: collapse;
}
.th {
text-align: left;
padding: 10px 12px;
border-bottom: 2px solid #e8e8e8;
font-size: 13px;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.td {
padding: 12px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
color: #333;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 300px;
font-size: 16px;
color: #888;
}
.error {
color: #e74c3c;
margin-bottom: 16px;
font-size: 14px;
}
.emptyState {
text-align: center;
padding: 40px;
color: #888;
}
@media (max-width: 768px) {
.header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.cardsRow {
grid-template-columns: 1fr;
}
.periodGroup {
width: 100%;
}
.periodButton,
.periodButtonActive {
flex: 1;
text-align: center;
}
}
What do these styles cover?
- .container centers the page content at a max width of 1200px with consistent padding.
- .header uses flexbox to place the title and period toggle buttons on the same row.
- .cardsRow lays out three summary cards in a CSS Grid row.
- The @media query at the bottom stacks cards vertically and expands period buttons on screens below 768px.
- Save AnalyticsPage.module.css.
You won't see a change yet. The next chunk imports this module into the component.
File not saving?
Make sure the file is named exactly AnalyticsPage.module.css (not AnalyticsPage.css). The .module. part is what tells Vite to treat it as a CSS module.
Confirm the file lives in the same folder as AnalyticsPage.tsx (src/pages/).
help me debug my CSS module file placement
Rewrite the analytics page component
The existing AnalyticsPage.tsx has two problems: the TypeScript interfaces use PascalCase property names that don't match the camelCase JSON from ASP.NET Core, and the chart's dataKey props reference those wrong names. You will replace the entire file.
Why does casing matter here?
ASP.NET Core serializes C# properties to camelCase JSON by default. So Period in C# becomes period in the API response, and Revenue becomes revenue.
Recharts looks up chart values using the exact string you pass to dataKey. If you write dataKey="Period" but the JSON field is period, the chart renders empty bars.
- Open hospital-frontend/src/pages/AnalyticsPage.tsx in Cursor.
- Select all the existing content and delete it.
- Paste the following imports and interface definitions at the top of the file:
import { useEffect, useState } from "react";
import axiosClient from "../api/axiosClient";
import { useAuth } from "../context/AuthContext";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import styles from "./AnalyticsPage.module.css";
// TypeScript interfaces matching the camelCase JSON from ASP.NET Core
interface RevenueDataPoint {
period: string;
revenue: number;
}
interface DoctorRevenue {
doctorEmail: string;
totalRevenue: number;
billCount: number;
}
interface AnalyticsResponse {
totalRevenue: number;
billCount: number;
totalDoctors: number;
revenueOverTime: RevenueDataPoint[];
doctorBreakdown: DoctorRevenue[] | null;
}
What does this code do?
- The Recharts imports pull in the specific chart components you need: BarChart, Bar, axes, grid, tooltip, and a responsive wrapper.
- import styles from "./AnalyticsPage.module.css" loads the CSS module so you can reference classes as styles.container, styles.card, etc.
- All interface properties use lowercase camelCase (period, revenue, totalDoctors) to match the API response exactly.
- Now paste the component function below the interfaces:
export default function AnalyticsPage() {
const { user } = useAuth();
const [period, setPeriod] = useState("monthly");
const [data, setData] = useState<AnalyticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// Fetch analytics data whenever the selected period changes
const fetchAnalytics = async (selectedPeriod: string) => {
setLoading(true);
try {
const res = await axiosClient.get(
`/api/analytics/revenue?period=${selectedPeriod}`
);
setData(res.data);
setError("");
} catch {
setError("Failed to load analytics.");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAnalytics(period);
}, [period]);
// Show loading state while data is being fetched
if (loading) return <div className={styles.loading}>Loading analytics...</div>;
return (
<div className={styles.container}>
<div className={styles.header}>
<h1 className={styles.title}>Analytics Dashboard</h1>
<div className={styles.periodGroup}>
{["daily", "monthly", "yearly"].map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
className={
period === p ? styles.periodButtonActive : styles.periodButton
}
>
{p}
</button>
))}
</div>
</div>
{error && <p className={styles.error}>{error}</p>}
{data && (
<>
{/* Summary cards row */}
<div className={styles.cardsRow}>
<div className={styles.card}>
<div className={styles.cardLabel}>Total Revenue</div>
<div className={styles.cardValue}>
${data.totalRevenue.toFixed(2)}
</div>
</div>
<div className={styles.card}>
<div className={styles.cardLabel}>Total Doctors</div>
<div className={styles.cardValue}>{data.totalDoctors}</div>
</div>
<div className={styles.card}>
<div className={styles.cardLabel}>Total Bills</div>
<div className={styles.cardValue}>{data.billCount}</div>
</div>
</div>
{/* Bar chart section */}
<div className={styles.chartSection}>
<h3 className={styles.chartTitle}>
Revenue Over Time ({period})
</h3>
{data.revenueOverTime.length === 0 ? (
<div className={styles.emptyState}>
No revenue data for this period.
</div>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={data.revenueOverTime}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="period" />
<YAxis tickFormatter={(value) => `$${value}`} />
<Tooltip
formatter={(value: number) => [
`$${value.toFixed(2)}`,
"Revenue",
]}
/>
<Bar dataKey="revenue" fill="#3498db" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Doctor breakdown table - Admin only */}
{user?.role === "Admin" && data.doctorBreakdown && (
<div className={styles.tableSection}>
<h3 className={styles.tableTitle}>Revenue by Doctor</h3>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.th}>Doctor</th>
<th className={styles.th}>Revenue</th>
<th className={styles.th}>Bills</th>
</tr>
</thead>
<tbody>
{data.doctorBreakdown.map((d) => (
<tr key={d.doctorEmail}>
<td className={styles.td}>{d.doctorEmail}</td>
<td className={styles.td}>
${d.totalRevenue.toFixed(2)}
</td>
<td className={styles.td}>{d.billCount}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
);
}
What does this component do?
- period state drives the toggle buttons and triggers a re-fetch via useEffect whenever it changes.
- The three summary cards pull totalRevenue, totalDoctors, and billCount directly from the API response.
- dataKey="period" on XAxis and dataKey="revenue" on Bar now match the camelCase JSON, which fixes the empty-chart bug.
- The doctor breakdown table renders only when user?.role === "Admin" and the API returns a non-null doctorBreakdown array.
- Save AnalyticsPage.tsx.
- Switch to your browser and navigate to the Analytics page. You should see a styled header with "Analytics Dashboard" on the left and Daily/Monthly/Yearly toggle buttons on the right, three summary cards in a row, and a bar chart below.
Chart not showing bars?
Open the browser console and check for errors. If you see the chart container but no bars, confirm that dataKey="period" and dataKey="revenue" are lowercase in your code.
If you see "Failed to load analytics", make sure your API server is still running in the other terminal.
help me debug my Recharts bar chart not rendering
✔️ Awesome, I've got everything!
Great. Double check you have saved both AnalyticsPage.module.css and AnalyticsPage.tsx.
ⓧ I'd like to double check the full code
Here is the complete AnalyticsPage.module.css:
.container {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.title {
font-size: 28px;
font-weight: 700;
color: #1a1a2e;
margin: 0;
}
.periodGroup {
display: flex;
gap: 8px;
}
.periodButton {
padding: 8px 20px;
border: 1px solid #ddd;
border-radius: 6px;
background: #fff;
color: #555;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
text-transform: capitalize;
}
.periodButton:hover {
border-color: #1a1a2e;
color: #1a1a2e;
}
.periodButtonActive {
padding: 8px 20px;
border: 1px solid #1a1a2e;
border-radius: 6px;
background: #1a1a2e;
color: #fff;
font-size: 14px;
cursor: pointer;
text-transform: capitalize;
}
.cardsRow {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.card {
padding: 20px;
border: 1px solid #e8e8e8;
border-radius: 8px;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.cardLabel {
font-size: 13px;
color: #888;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.cardValue {
font-size: 28px;
font-weight: 700;
color: #1a1a2e;
}
.chartSection {
border: 1px solid #e8e8e8;
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.chartTitle {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0 0 16px 0;
}
.tableSection {
border: 1px solid #e8e8e8;
border-radius: 8px;
padding: 20px;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.tableTitle {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0 0 16px 0;
}
.table {
width: 100%;
border-collapse: collapse;
}
.th {
text-align: left;
padding: 10px 12px;
border-bottom: 2px solid #e8e8e8;
font-size: 13px;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.td {
padding: 12px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
color: #333;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 300px;
font-size: 16px;
color: #888;
}
.error {
color: #e74c3c;
margin-bottom: 16px;
font-size: 14px;
}
.emptyState {
text-align: center;
padding: 40px;
color: #888;
}
@media (max-width: 768px) {
.header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.cardsRow {
grid-template-columns: 1fr;
}
.periodGroup {
width: 100%;
}
.periodButton,
.periodButtonActive {
flex: 1;
text-align: center;
}
}
And the complete AnalyticsPage.tsx:
import { useEffect, useState } from "react";
import axiosClient from "../api/axiosClient";
import { useAuth } from "../context/AuthContext";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import styles from "./AnalyticsPage.module.css";
interface RevenueDataPoint {
period: string;
revenue: number;
}
interface DoctorRevenue {
doctorEmail: string;
totalRevenue: number;
billCount: number;
}
interface AnalyticsResponse {
totalRevenue: number;
billCount: number;
totalDoctors: number;
revenueOverTime: RevenueDataPoint[];
doctorBreakdown: DoctorRevenue[] | null;
}
export default function AnalyticsPage() {
const { user } = useAuth();
const [period, setPeriod] = useState("monthly");
const [data, setData] = useState<AnalyticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const fetchAnalytics = async (selectedPeriod: string) => {
setLoading(true);
try {
const res = await axiosClient.get(
`/api/analytics/revenue?period=${selectedPeriod}`
);
setData(res.data);
setError("");
} catch {
setError("Failed to load analytics.");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAnalytics(period);
}, [period]);
if (loading) return <div className={styles.loading}>Loading analytics...</div>;
return (
<div className={styles.container}>
<div className={styles.header}>
<h1 className={styles.title}>Analytics Dashboard</h1>
<div className={styles.periodGroup}>
{["daily", "monthly", "yearly"].map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
className={
period === p ? styles.periodButtonActive : styles.periodButton
}
>
{p}
</button>
))}
</div>
</div>
{error && <p className={styles.error}>{error}</p>}
{data && (
<>
<div className={styles.cardsRow}>
<div className={styles.card}>
<div className={styles.cardLabel}>Total Revenue</div>
<div className={styles.cardValue}>
${data.totalRevenue.toFixed(2)}
</div>
</div>
<div className={styles.card}>
<div className={styles.cardLabel}>Total Doctors</div>
<div className={styles.cardValue}>{data.totalDoctors}</div>
</div>
<div className={styles.card}>
<div className={styles.cardLabel}>Total Bills</div>
<div className={styles.cardValue}>{data.billCount}</div>
</div>
</div>
<div className={styles.chartSection}>
<h3 className={styles.chartTitle}>
Revenue Over Time ({period})
</h3>
{data.revenueOverTime.length === 0 ? (
<div className={styles.emptyState}>
No revenue data for this period.
</div>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={data.revenueOverTime}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="period" />
<YAxis tickFormatter={(value) => `$${value}`} />
<Tooltip
formatter={(value: number) => [
`$${value.toFixed(2)}`,
"Revenue",
]}
/>
<Bar dataKey="revenue" fill="#3498db" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
{user?.role === "Admin" && data.doctorBreakdown && (
<div className={styles.tableSection}>
<h3 className={styles.tableTitle}>Revenue by Doctor</h3>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.th}>Doctor</th>
<th className={styles.th}>Revenue</th>
<th className={styles.th}>Bills</th>
</tr>
</thead>
<tbody>
{data.doctorBreakdown.map((d) => (
<tr key={d.doctorEmail}>
<td className={styles.td}>{d.doctorEmail}</td>
<td className={styles.td}>
${d.totalRevenue.toFixed(2)}
</td>
<td className={styles.td}>{d.billCount}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
);
}
Test responsiveness and period switching
The CSS module includes a media query that rearranges the layout on smaller screens. Confirming this works now saves debugging time later.
- In your browser, open the Analytics page at full desktop width.
- Confirm you see the three summary cards (Total Revenue, Total Doctors, Total Bills) in a single row above the bar chart.
- Click the Daily button. The chart should refresh and show daily data points.
- Click Yearly. The chart should update again with yearly aggregates.
- Open your browser's DevTools with Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Toggle the device toolbar (the phone/tablet icon at the top of DevTools) and set the width to 375px.
- Confirm the three cards stack vertically and the period buttons expand to fill the row.
What should I see if logged in as Admin?
Admin users see an additional "Revenue by Doctor" table below the bar chart. This table lists each doctor's email, their total revenue, and their bill count.
Doctor users only see the summary cards and chart for their own revenue. The table is hidden because the API returns doctorBreakdown: null for non-Admin roles.
Cards not stacking on mobile view?
Check that the media query in your CSS file uses @media (max-width: 768px) and that .cardsRow changes to grid-template-columns: 1fr inside it.
Make sure the DevTools device toolbar is active and the viewport width is actually below 768px.
help me debug my CSS media query for the responsive layout
Your analytics dashboard is fully rebuilt with clean, modular styles and a working chart. Next up: the Secret Mission, where you will build a backfill tool that lets Admins assign a consulting doctor to old bills that predate the new field.
Secret mission
Build an Admin Backfill Tool for Old Bills
Old bills created before the ConsultingDoctorId migration have null values and are completely invisible to the analytics dashboard. Can you build a backfill interface that lets Admins assign a consulting doctor to these orphaned bills, bringing them back into the revenue picture?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Running API server (HospitalApi) and frontend dev server (hospital-frontend).
- PostgreSQL database HospitalDb with updated schema (including the new ConsultingDoctorId column).
- New EF Core migration file (AddBillConsultingDoctor) in the project.
Keep everything running
No action needed. Choose this if you're still actively building on this project or plan to continue right away.
- Your API and frontend servers stay running in their terminal windows.
- Your HospitalDb database keeps all the data you created during this project.
- All code changes remain saved in your project folders.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your code and database stay intact for when you return.
- Switch to the terminal running your API server and press Ctrl+C to stop it.
- Switch to the terminal running your frontend server and press Ctrl+C to stop it.
- When you're ready to resume, start the API with dotnet run from the HospitalApi directory and the frontend with npm start from the hospital-frontend directory.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your database and project files.
- Stop both servers by pressing Ctrl+C in each terminal window.
- Drop the database by running:
dropdb HospitalDb
- Delete the project folders. On macOS/Linux:
rm -rf HospitalApi hospital-frontend
- On Windows:
rmdir /s /q HospitalApi
rmdir /s /q hospital-frontend
Nice Work!
Nice Work!
Nice work! You just fixed a broken hospital analytics pipeline from end to end. Bills now correctly track which doctor they belong to, and the analytics dashboard accurately visualizes doctor-wise revenue with a clean, responsive design.
You've learned how to:
- Added a ConsultingDoctorId foreign key with an Entity Framework Core migration, separating bill creation audit trails from doctor revenue attribution.
- Built role-based bill creation logic with a doctors API endpoint that auto-assigns for Doctors and requires explicit selection for other roles.
- Redesigned the Analytics Dashboard from scratch with a CSS module, responsive layout, summary cards, a fixed Recharts bar chart, period selector, and doctor revenue table.
- Secret Mission: Built an admin backfill tool to assign consulting doctors to old bills with null ConsultingDoctorId.
Ready to quiz yourself?