From 5fcdb8599f05756936e651d2fc5052887614120b Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 23 Jun 2026 19:27:41 +0330 Subject: [PATCH] Add pagination to the Jobs / Shifts / Talent list pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list pages loaded EVERY matching listing into one page (/Jobs was a ~2.6MB page with 1000+ cards) — no pagination at all. Add server-side paging (24/page, DB Skip/Take; near-me still sorts all by distance then paginates in memory). The header count now shows the true total, and a shared _Pager partial renders prev/next + a windowed page list that preserves all active filters in the URL. Co-Authored-By: Claude Opus 4.8 --- src/JobsMedical.Web/Pages/Jobs/Index.cshtml | 3 +- .../Pages/Jobs/Index.cshtml.cs | 21 ++++++-- .../Pages/Shared/_Pager.cshtml | 51 +++++++++++++++++++ src/JobsMedical.Web/Pages/Shifts/Index.cshtml | 3 +- .../Pages/Shifts/Index.cshtml.cs | 25 ++++++--- src/JobsMedical.Web/Pages/Talent/Index.cshtml | 3 +- .../Pages/Talent/Index.cshtml.cs | 11 +++- src/JobsMedical.Web/wwwroot/css/site.css | 11 ++++ 8 files changed, 111 insertions(+), 17 deletions(-) create mode 100644 src/JobsMedical.Web/Pages/Shared/_Pager.cshtml diff --git a/src/JobsMedical.Web/Pages/Jobs/Index.cshtml b/src/JobsMedical.Web/Pages/Jobs/Index.cshtml index 5d6ec4f..c669581 100644 --- a/src/JobsMedical.Web/Pages/Jobs/Index.cshtml +++ b/src/JobsMedical.Web/Pages/Jobs/Index.cshtml @@ -9,7 +9,7 @@

@Model.PageHeading

- @JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) موقعیت شغلی پیدا شد + @JalaliDate.ToPersianDigits(Model.TotalCount.ToString()) موقعیت شغلی پیدا شد @if (Model.NearMeActive) { — مرتب‌شده بر اساس نزدیک‌ترین به شما 📍 @@ -117,6 +117,7 @@ } + } diff --git a/src/JobsMedical.Web/Pages/Jobs/Index.cshtml.cs b/src/JobsMedical.Web/Pages/Jobs/Index.cshtml.cs index af1289a..69547c8 100644 --- a/src/JobsMedical.Web/Pages/Jobs/Index.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Jobs/Index.cshtml.cs @@ -24,6 +24,12 @@ public class IndexModel : PageModel [BindProperty(SupportsGet = true)] public string? RoleSlug { get; set; } [BindProperty(SupportsGet = true)] public string? CitySlug { get; set; } + [BindProperty(SupportsGet = true)] public int Page { get; set; } = 1; + private const int PageSize = 24; + public int TotalCount { get; private set; } + public int TotalPages { get; private set; } + public int CurrentPage { get; private set; } + public bool NearMeActive => Lat is not null && Lng is not null; public List Results { get; private set; } = new(); @@ -78,19 +84,24 @@ public class IndexModel : PageModel if (GenderFilter is Gender g && g != Gender.Any) q = q.Where(j => j.GenderRequirement == Gender.Any || j.GenderRequirement == g); - var results = await q.ToListAsync(); + TotalCount = await q.CountAsync(); + TotalPages = Math.Max(1, (int)Math.Ceiling(TotalCount / (double)PageSize)); + CurrentPage = Math.Clamp(Page, 1, TotalPages); + var skip = (CurrentPage - 1) * PageSize; if (NearMeActive) { - foreach (var j in results) + // Distance sort needs all rows in memory; paginate after sorting. + var all = await q.ToListAsync(); + foreach (var j in all) if (j.Facility.Lat is double flat && j.Facility.Lng is double flng) j.DistanceKm = Geo.DistanceKm(Lat!.Value, Lng!.Value, flat, flng); - Results = results.OrderBy(j => j.DistanceKm ?? double.MaxValue) - .ThenByDescending(j => j.CreatedAt).ToList(); + Results = all.OrderBy(j => j.DistanceKm ?? double.MaxValue) + .ThenByDescending(j => j.CreatedAt).Skip(skip).Take(PageSize).ToList(); } else { - Results = results.OrderByDescending(j => j.CreatedAt).ToList(); + Results = await q.OrderByDescending(j => j.CreatedAt).Skip(skip).Take(PageSize).ToListAsync(); } SetSeo(Roles.FirstOrDefault(r => r.Id == RoleId)?.Name, Cities.FirstOrDefault(c => c.Id == CityId)?.Name); diff --git a/src/JobsMedical.Web/Pages/Shared/_Pager.cshtml b/src/JobsMedical.Web/Pages/Shared/_Pager.cshtml new file mode 100644 index 0000000..6eaf8b5 --- /dev/null +++ b/src/JobsMedical.Web/Pages/Shared/_Pager.cshtml @@ -0,0 +1,51 @@ +@model (int Current, int Total) +@{ + var (cur, total) = Model; +} +@if (total > 1) +{ + @* Build a page URL that preserves every current filter in the query string. *@ + Func pageUrl = p => + { + var parts = Context.Request.Query + .Where(kv => !string.Equals(kv.Key, "Page", StringComparison.OrdinalIgnoreCase)) + .Select(kv => Uri.EscapeDataString(kv.Key) + "=" + Uri.EscapeDataString(kv.Value.ToString())) + .ToList(); + parts.Add("Page=" + p); + return Context.Request.Path + "?" + string.Join("&", parts); + }; + var from = Math.Max(1, cur - 2); + var to = Math.Min(total, cur + 2); + +

+} diff --git a/src/JobsMedical.Web/Pages/Shifts/Index.cshtml b/src/JobsMedical.Web/Pages/Shifts/Index.cshtml index 503e500..56ef4ae 100644 --- a/src/JobsMedical.Web/Pages/Shifts/Index.cshtml +++ b/src/JobsMedical.Web/Pages/Shifts/Index.cshtml @@ -9,7 +9,7 @@

@Model.PageHeading

- @JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) شیفت باز پیدا شد + @JalaliDate.ToPersianDigits(Model.TotalCount.ToString()) شیفت باز پیدا شد @if (Model.NearMeActive) { — مرتب‌شده بر اساس نزدیک‌ترین به شما 📍 @@ -145,6 +145,7 @@ } + } diff --git a/src/JobsMedical.Web/Pages/Shifts/Index.cshtml.cs b/src/JobsMedical.Web/Pages/Shifts/Index.cshtml.cs index faa9fb9..3b5d1be 100644 --- a/src/JobsMedical.Web/Pages/Shifts/Index.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Shifts/Index.cshtml.cs @@ -29,6 +29,12 @@ public class IndexModel : PageModel [BindProperty(SupportsGet = true)] public string? RoleSlug { get; set; } [BindProperty(SupportsGet = true)] public string? CitySlug { get; set; } + [BindProperty(SupportsGet = true)] public int Page { get; set; } = 1; + private const int PageSize = 24; + public int TotalCount { get; private set; } + public int TotalPages { get; private set; } + public int CurrentPage { get; private set; } + public bool NearMeActive => Lat is not null && Lng is not null; public List Results { get; private set; } = new(); @@ -91,24 +97,27 @@ public class IndexModel : PageModel if (GenderFilter is Gender g && g != Gender.Any) q = q.Where(s => s.GenderRequirement == Gender.Any || s.GenderRequirement == g); - var results = await q.ToListAsync(); + TotalCount = await q.CountAsync(); + TotalPages = Math.Max(1, (int)Math.Ceiling(TotalCount / (double)PageSize)); + CurrentPage = Math.Clamp(Page, 1, TotalPages); + var skip = (CurrentPage - 1) * PageSize; if (NearMeActive) { - // Compute distance to each facility, then nearest-first (shifts without coords last). - foreach (var s in results) - { + // Distance sort needs all rows in memory; paginate after sorting (shifts without coords last). + var all = await q.ToListAsync(); + foreach (var s in all) if (s.Facility.Lat is double flat && s.Facility.Lng is double flng) s.DistanceKm = Geo.DistanceKm(Lat!.Value, Lng!.Value, flat, flng); - } - Results = results + Results = all .OrderBy(s => s.DistanceKm ?? double.MaxValue) .ThenBy(s => s.Date).ThenBy(s => s.StartTime) - .ToList(); + .Skip(skip).Take(PageSize).ToList(); } else { - Results = results.OrderBy(s => s.Date).ThenBy(s => s.StartTime).ToList(); + Results = await q.OrderBy(s => s.Date).ThenBy(s => s.StartTime) + .Skip(skip).Take(PageSize).ToListAsync(); } SetSeo(Roles.FirstOrDefault(r => r.Id == RoleId)?.Name, Cities.FirstOrDefault(c => c.Id == CityId)?.Name); diff --git a/src/JobsMedical.Web/Pages/Talent/Index.cshtml b/src/JobsMedical.Web/Pages/Talent/Index.cshtml index 1633a1e..6e20f45 100644 --- a/src/JobsMedical.Web/Pages/Talent/Index.cshtml +++ b/src/JobsMedical.Web/Pages/Talent/Index.cshtml @@ -9,7 +9,7 @@

@Model.PageHeading

- @JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) نیروی کادر درمان آماده‌ی همکاری — + @JalaliDate.ToPersianDigits(Model.TotalCount.ToString()) نیروی کادر درمان آماده‌ی همکاری — مراکز درمانی می‌توانند مستقیم تماس بگیرند.

@@ -83,6 +83,7 @@ } + } diff --git a/src/JobsMedical.Web/Pages/Talent/Index.cshtml.cs b/src/JobsMedical.Web/Pages/Talent/Index.cshtml.cs index c12a8c2..d451d77 100644 --- a/src/JobsMedical.Web/Pages/Talent/Index.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Talent/Index.cshtml.cs @@ -17,6 +17,11 @@ public class IndexModel : PageModel [BindProperty(SupportsGet = true)] public int? RoleId { get; set; } [BindProperty(SupportsGet = true)] public Gender? GenderFilter { get; set; } [BindProperty(SupportsGet = true)] public string? Q { get; set; } // deep search + [BindProperty(SupportsGet = true)] public int Page { get; set; } = 1; + private const int PageSize = 24; + public int TotalCount { get; private set; } + public int TotalPages { get; private set; } + public int CurrentPage { get; private set; } public List Results { get; private set; } = new(); public List Cities { get; private set; } = new(); @@ -60,7 +65,11 @@ public class IndexModel : PageModel EF.Functions.ILike(t.City.Name, like)); } - Results = await q.OrderByDescending(t => t.CreatedAt).ToListAsync(); + TotalCount = await q.CountAsync(); + TotalPages = Math.Max(1, (int)Math.Ceiling(TotalCount / (double)PageSize)); + CurrentPage = Math.Clamp(Page, 1, TotalPages); + Results = await q.OrderByDescending(t => t.CreatedAt) + .Skip((CurrentPage - 1) * PageSize).Take(PageSize).ToListAsync(); var role = Roles.FirstOrDefault(r => r.Id == RoleId)?.Name; var city = Cities.FirstOrDefault(c => c.Id == CityId)?.Name; diff --git a/src/JobsMedical.Web/wwwroot/css/site.css b/src/JobsMedical.Web/wwwroot/css/site.css index 479e086..f096a6a 100644 --- a/src/JobsMedical.Web/wwwroot/css/site.css +++ b/src/JobsMedical.Web/wwwroot/css/site.css @@ -80,6 +80,17 @@ a { color: inherit; text-decoration: none; } .nav-more-menu a { color: var(--text); font-weight: 600; font-size: 14px; padding: 9px 12px; border-radius: 8px; white-space: nowrap; } .nav-more-menu a:hover, .nav-more-menu a.active { background: var(--primary-soft); color: var(--primary-dark); } +/* Pagination */ +.pager { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 6px; margin: 26px 0 4px; } +.pager-btn, .pager-num { + display: inline-flex; align-items: center; justify-content: center; min-width: 38px; height: 38px; + padding: 0 12px; border: 1px solid var(--line); border-radius: 9px; background: #fff; + color: var(--text); font-weight: 600; font-size: 14px; text-decoration: none; transition: all .15s; +} +.pager-btn:hover, .pager-num:hover { border-color: var(--primary); color: var(--primary-dark); } +.pager-num.active { background: var(--primary); border-color: var(--primary); color: #fff; cursor: default; } +.pager-gap { padding: 0 2px; color: var(--muted); } + .cta-post { white-space: nowrap; box-shadow: 0 2px 8px rgba(240,132,62,.35); } .header-actions { display: flex; align-items: center; gap: 12px; margin-inline-start: auto; } .nav-action { font-weight: 600; font-size: 15px; color: var(--muted); white-space: nowrap; transition: color .15s; }