Medicare Doctor Search, Filtering, Sorting & Pagination

Business Scenario

In this lab, we will enhance the Medicare application by implementing a doctor search and discovery feature for patients.

Pre-Lab Preparation

Module:

1) React Basics & Backend Integration (Spring Boot)​

Phase 12D: Doctor Search, Filtering, Sorting & Pagination

  • This phase directly implements the patient requirement to choose/search doctors by specialization, maximum consultation fee, and live doctor name, while showing the doctor's photo, name, experience, and fee.
  • Phase 12D — What We Build
Patient
   ↓
Doctors Page
   ↓
Search by Name
Filter Specialization
Filter Maximum Fee
Sort Doctors
Pagination
   ↓
doctorService.js
   ↓
GET /api/doctors
   ↓
Spring Boot
  • The backend endpoint we already built accepts:

     specialization, maxFee, name, page,size sortBy,direction

Create doctorService.js

1

  • Create:
import api, { getAuthConfig } from "../api/api";

const getDoctors = async ({
    specialization = "",
    maxFee = "",
    name = "",
    page = 0,
    size = 6,
    sortBy = "consultationFee",
    direction = "asc"
}) => {
    const params = {
        page,
        size,
src/services/doctorService.js
        sortBy,
        direction
    };

    if (specialization) {
        params.specialization = specialization;
    }

    if (maxFee !== "" && Number(maxFee) > 0) {
        params.maxFee = maxFee;
    }

    if (name.trim()) {
        params.name = name.trim();
    }

    const response = await api.get("/doctors", {
        ...getAuthConfig(),
        params
    });

    return response.data;
};

const getDoctorById = async (doctorId) => {
    const response = await api.get(
        `/doctors/${doctorId}`,
        getAuthConfig()
    );
    return response.data;
};

const doctorService = {
    getDoctors,
    getDoctorById
};

export default doctorService;

Create DoctorCard

2

  • Create:
src/components/doctor/DoctorCard.jsx
function DoctorCard({ doctor }) {
    return (
        <div className="card h-100 shadow-sm">
            {doctor.photoUrl ? (
                <img src={doctor.photoUrl} className="card-img-top"
                    alt={doctor.name} style={{height: "220px",objectFit: "cover"}}
                />
            ) : (
                <div className="bg-light d-flex align-items-center 
                    justify-content-center text-secondary"
                    style={{ height: "220px" }}>
                    No Photo
                </div>
            )}

            <div className="card-body">
                <h5 className="card-title">{doctor.name}</h5>

                <p className="text-primary fw-semibold mb-2">
                    {doctor.specialization}
                </p>

                <p className="card-text mb-2">
                    <strong>Experience:</strong>{" "}
                    {doctor.experienceYears} years
                </p>

                <p className="card-text mb-3">
                    <strong>Consultation Fee:</strong>{" "}
                    {doctor.consultationFee}
                </p>

                <button className="btn btn-primary w-100" disabled>
                    Book Appointment
                </button>
            </div>
        </div>
    );
}

export default DoctorCard;
  • For now: Book Appointment is disabled
  • That's intentional.
  • The next phase will connect it to:
Doctor
 ↓
Select date
 ↓
Select slot
 ↓
Book appointment

Create DoctorFilter

3

  • Create:
src/components/doctor/DoctorFilter.jsx
function DoctorFilter({
    specialization,
    setSpecialization,
    maxFee,
    setMaxFee,
    name,
    setName,
    sortBy,
    setSortBy,
    direction,
    setDirection,
    resetFilters
}) {
    return (
        <div className="card shadow-sm mb-4">
            <div className="card-body">
                <div className="row g-3">
                    {/* DOCTOR NAME */}
                    <div className="col-md-6 col-lg-4">
                        <label className="form-label">Doctor Name</label>
                        <input
                            type="text"
                            className="form-control"
                            placeholder="Search doctor..."
                            value={name}
                            onChange={(event) => setName(event.target.value)}
                        />
                    </div>

                    {/* SPECIALIZATION */}
                    <div className="col-md-6 col-lg-4">
                        <label className="form-label">Specialization</label>
                        <select
                            className="form-select"
                            value={specialization}
                            onChange={(event) => 
                            setSpecialization(event.target.value)} >
                            <option value="">All Specializations</option>
                            <option value="Orthopedic">Orthopedic</option>
                            <option value="Dentist">Dentist</option>
                            <option value="General Physician">
                            General Physician</option>
                            <option value="Cardiologist">Cardiologist</option>
                        </select>
                    </div>

                    {/* MAX FEE */}
                    <div className="col-md-6 col-lg-4">
                        <label className="form-label">
                            Maximum Fee: {maxFee === "5000" ? "Any" : maxFee}
                        </label>
                        <input
                            type="range"
                            className="form-range"
                            min="500"
                            max="5000"
                            step="100"
                            value={maxFee}
                            onChange={(event) => setMaxFee(event.target.value)}
                        />
                        <div className="d-flex justify-content-between">
                            <small className="text-secondary">500</small>
                            <small className="text-secondary">5000+</small>
                        </div>
                    </div>

                    {/* SORT */}
                    <div className="col-md-6 col-lg-4">
                        <label className="form-label">Sort By</label>
                        <select
                            className="form-select"
                            value={sortBy}
                            onChange={(event) => setSortBy(event.target.value)}>
                            <option value="consultationFee">
                            Consultation Fee</option>
                            <option value="name">Doctor Name</option>
                            <option value="experienceYears">Experience</option>
                            <option value="specialization">Specialization</option>
                        </select>
                    </div>

                    {/* DIRECTION */}
                    <div className="col-md-6 col-lg-4">
                        <label className="form-label">Direction</label>
                        <select
                            className="form-select"
                            value={direction}
                            onChange={(event) => 
                            setDirection(event.target.value)}>
                            <option value="asc">Ascending</option>
                            <option value="desc">Descending</option>
                        </select>
                    </div>

                    {/* RESET */}
                    <div className="col-md-6 col-lg-4 d-flex align-items-end">
                        <button
                            type="button"
                            className="btn btn-outline-secondary w-100"
                            onClick={resetFilters}>
                            Reset Filters
                        </button>
                    </div>
            </div>
        </div>
    );
}

export default DoctorFilter;
  • We're using: 500 → 5000+ as the frontend slider range.
  • When the slider is at: 5000 we'll treat it as: No maximum fee filter

Create Doctors Page

4

  • Create:
src/pages/patient/Doctors.jsx
import { useEffect, useState } from "react";
import doctorService from "../../services/doctorService";
import DoctorCard from "../../components/doctor/DoctorCard";
import DoctorFilter from "../../components/doctor/DoctorFilter";

const MAX_FEE = "5000";

function Doctors() {
    const [doctors, setDoctors] = useState([]);
    const [specialization, setSpecialization] = useState("");
    const [maxFee, setMaxFee] = useState(MAX_FEE);
    const [name, setName] = useState("");
    const [sortBy, setSortBy] = useState("consultationFee");
    const [direction, setDirection] = useState("asc");
    const [page, setPage] = useState(0);
    const [totalPages, setTotalPages] = useState(0);
    const [totalElements, setTotalElements] = useState(0);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState("");

    // Reset pagination whenever a filter or sorting option changes.
    useEffect(() => {
        setPage(0);
    }, [specialization, maxFee, name, sortBy, direction]);

    // Load doctors with a 400 ms debounce for live name search.
    useEffect(() => {
        const timer = setTimeout(() => {
            loadDoctors();
        }, 400);

        return () => clearTimeout(timer);
    }, [specialization, maxFee, name, sortBy, direction, page]);

    const loadDoctors = async () => {
        try {
            setLoading(true);
            setError("");

            const data = await doctorService.getDoctors({
                specialization,
                maxFee: maxFee === MAX_FEE ? "" : maxFee,
                name,
                page,
                size: 6,
                sortBy,
                direction
            });

            setDoctors(data.doctors);
            setTotalPages(data.totalPages);
            setTotalElements(data.totalElements);
        } catch (error) {
            setError(
                error.response?.data?.message ||
                "Unable to load doctors"
            );
        } finally {
            setLoading(false);
        }
    };
    const resetFilters = () => {
        setSpecialization("");
        setMaxFee(MAX_FEE);
        setName("");
        setSortBy("consultationFee");
        setDirection("asc");
        setPage(0);
    };

    const previousPage = () => {
        if (page > 0) {
            setPage(page - 1);
        }
    };

    const nextPage = () => {
        if (page < totalPages - 1) {
            setPage(page + 1);
        }
    };

    return (
        <div className="container py-5">
            {/* PAGE HEADING */}
            <div className="mb-4">
                <h2>Find a Doctor</h2>
                <p className="text-secondary">
                    Search and filter doctors based on your healthcare needs.
                </p>
            </div>
            {/* FILTERS */}
            <DoctorFilter
                specialization={specialization}
                setSpecialization={setSpecialization}
                maxFee={maxFee}
                setMaxFee={setMaxFee}
                name={name}
                setName={setName}
                sortBy={sortBy}
                setSortBy={setSortBy}
                direction={direction}
                setDirection={setDirection}
                resetFilters={resetFilters}
            />

            {/* RESULT COUNT */}
            {!loading && !error && (
                <p className="text-secondary">{totalElements} doctor(s) found</p>
            )}

            {/* ERROR */}
            {error && (
                <div className="alert alert-danger">{error}</div>
            )}

            {/* LOADING */}
            {loading && (
                <div className="text-center py-5">
                    <div className="spinner-border text-primary"></div>
                    <p className="text-secondary mt-3">Loading doctors...</p>
                </div>
            )}

            {/* NO RESULT */}
            {!loading && !error && doctors.length === 0 && (
                <div className="alert alert-info">
                    No doctors found matching your filters.
                </div>
            )}

            {/* DOCTOR CARDS */}
            {!loading && !error && doctors.length > 0 && (
                <>
                    <div className="row g-4">
                        {doctors.map((doctor) => (
                            <div className="col-md-6 col-lg-4" key={doctor.id}>
                                <DoctorCard doctor={doctor} />
                            </div>
                        ))}
                    </div>

                    {/* PAGINATION */}
                    <div className="d-flex justify-content-center 
                    align-items-center gap-3 mt-5">
                        <button
                            className="btn btn-outline-primary"
                            onClick={previousPage}
                            disabled={page === 0} >
                            Previous
                        </button>
                        <span>
                            Page <strong>{page + 1}</strong> of{" "}
                            <strong>{totalPages}</strong>
                        </span>

                        <button
                            className="btn btn-outline-primary"
                            onClick={nextPage}
                            disabled={page >= totalPages - 1}
                        >
                            Next
                        </button>
                    </div>
                </>
            )}
        </div>
    );
}

export default Doctors;

What Is the 400 ms Timer?

  • Our specification asks for a live doctor name search.
  • Without the timer:
A
 ↓
API call

An
 ↓
API call

Ana
 ↓
API call

Anan
 ↓
API call
  • Instead:
setTimeout(() => {
    loadDoctors();
}, 400);
  • means:
User types
   ↓
wait 400 milliseconds
   ↓
If user types again,
cancel old timer
   ↓
New timer starts
   ↓
API request
  • This:
return () => {
    clearTimeout(timer);
};
  • Cancels the previous timer.
  • So we have implemented a basic debounce without installing another library.

Update Router

5

  • Open: src/routes/router.jsx
  • Add:
import Doctors from "../pages/patient/Doctors";
  • Then inside MainLayout's children, add:
{
    path: "patient/doctors",

    element: (

        <ProtectedRoute allowedRoles={["PATIENT"]}>
            <Doctors />
        </ProtectedRoute>
    )
},
  • So the relevant patient routes become:
/patient/dashboard

/patient/doctors

/patient/profile

Update Navbar

6

  • Open : src/components/navbar/Navbar.jsx
  • Inside the: user?.role === "PATIENT" section, add:
<li className="nav-item">
    <Link className="nav-link" to="/patient/doctors"> Doctors </Link>
</li>
  • Your PATIENT section becomes approximately:
<>
    <li className="nav-item">
        <Link className="nav-link" to="/patient/dashboard">Dashboard</Link>
    </li>
    <li className="nav-item">
        <Link className="nav-link" to="/patient/doctors">Doctors</Link>
    </li>
    <li className="nav-item">
        <Link className="nav-link" to="/patient/profile">My Profile</Link>
    </li>
</>

Update Patient Dashboard

7

  • Currently the Find Doctor card has:


<button className="btn btn-primary" disabled>
    Coming Next
</button>
  • Replace it with:
<Link to="/patient/doctors" className="btn btn-primary">
    Find Doctor
</Link>
  • Now:
Patient Dashboard
       ↓
Find Doctor
       ↓
/patient/doctors

How the Complete Request Works

  • Suppose the patient selects:
Specialization: Cardiologist

Maximum Fee: 1500

Doctor Name: an

Page: 0

Sort: consultationFee ASC
  • React calls: GET /api/doctors with approximately:
/api/doctors
?specialization=Cardiologist
&maxFee=1500
&name=an
&page=0
&size=6
&sortBy=consultationFee
&direction=asc
  • And sends:
Authorization: Bearer <patient-jwt>
  • Backend performs:
DoctorController
      ↓
DoctorService
      ↓
JPQL Search
      ↓
Pageable
      ↓
MySQL
  • Then returns:
{
    "doctors": [
        {
            "id": 3,
            "name": "Dr. Ananya",
            "specialization": "Cardiologist",
            "experienceYears": 8,
            "consultationFee": 1200,
            "photoUrl": "..."
        }
    ],
    "pageNumber": 0,
    "pageSize": 6,
    "totalElements": 1,
    "totalPages": 1,
    "last": true
}

Test Normal Doctor Listing

8

  • Login as a PATIENT.
  • Open: http://localhost:5173/patient/doctors
  • Initially the call should effectively be:
GET /api/doctors
?page=0
&size=6
&sortBy=consultationFee
&direction=asc
  • You should see your existing doctors.

Test Specialization

9

  • Select: Cardiologist
  • React sends specialization=Cardiologist
  • Only cardiologists should appear.

Test Fee Filter

10

  • Move the maximum fee slider from: 5000+ to:1500
  • React sends: maxFee=1500
  • Now only doctors with: consultationFee <= 1500 should appear.
  • Move it back to: 5000+
  • React does not send maxFee.
  • Therefore: No fee restriction

Combined Filtering

11

  • Use:
Specialization: Cardiologist
Name: an
Maximum Fee: 1500
  • The backend receives all three
  • A doctor must satisfy the applicable filters.

Test Sorting

12

  • Try:
Sort By: Consultation Fee
Direction: Ascending
  • Expected:
Lower fee
   ↓
Higher fee
  • Then :
Direction:
Descending
  • Expected:
Higher fee --> Lower fee

Test Pagination

13

  • Because: size: 6 we display: 6 doctors per page
  • Suppose database contains: 14 doctors
  • Then:
Page 1 → 6
Page 2 → 6
Page 3 → 2
  • Controls:
Previous   Page 1 of 3   Next
  • On first page: Previous = disabled
  • On last page: Next = disabled

Reset Filters

14

  • Click: Reset Filters
  • We restore:
Specialization = All
Maximum Fee = Any
Name = empty
Sort = Consultation Fee
Direction = Ascending
Page = 1
  • And doctors reload automatically.

We are done with this lab. The latest source code has been uploaded to GitHub. You can access the latest commit using the link below: 

 

Great job!

You have successfully implemented the Medicare Doctor Search feature, allowing patients to find doctors based on their healthcare needs.

Checkpoint

   Git Push

git push origin branchName

Next-Lab Preparation

Module:

1) React Basics & Backend Integration (Spring Boot)​

SpringBoot Lab - 16

By Content ITV

SpringBoot Lab - 16

  • 39