Add pagination to the Jobs / Shifts / Talent list pages
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 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@
|
|||||||
<partial name="_Breadcrumbs" model="Model.Breadcrumbs" />
|
<partial name="_Breadcrumbs" model="Model.Breadcrumbs" />
|
||||||
<h1>@Model.PageHeading</h1>
|
<h1>@Model.PageHeading</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) موقعیت شغلی پیدا شد
|
@JalaliDate.ToPersianDigits(Model.TotalCount.ToString()) موقعیت شغلی پیدا شد
|
||||||
@if (Model.NearMeActive)
|
@if (Model.NearMeActive)
|
||||||
{
|
{
|
||||||
<span> — مرتبشده بر اساس نزدیکترین به شما 📍</span>
|
<span> — مرتبشده بر اساس نزدیکترین به شما 📍</span>
|
||||||
@@ -117,6 +117,7 @@
|
|||||||
<partial name="_JobCard" model="j" />
|
<partial name="_JobCard" model="j" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<partial name="_Pager" model="(Model.CurrentPage, Model.TotalPages)" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ public class IndexModel : PageModel
|
|||||||
[BindProperty(SupportsGet = true)] public string? RoleSlug { get; set; }
|
[BindProperty(SupportsGet = true)] public string? RoleSlug { get; set; }
|
||||||
[BindProperty(SupportsGet = true)] public string? CitySlug { 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 bool NearMeActive => Lat is not null && Lng is not null;
|
||||||
|
|
||||||
public List<JobOpening> Results { get; private set; } = new();
|
public List<JobOpening> Results { get; private set; } = new();
|
||||||
@@ -78,19 +84,24 @@ public class IndexModel : PageModel
|
|||||||
if (GenderFilter is Gender g && g != Gender.Any)
|
if (GenderFilter is Gender g && g != Gender.Any)
|
||||||
q = q.Where(j => j.GenderRequirement == Gender.Any || j.GenderRequirement == g);
|
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)
|
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)
|
if (j.Facility.Lat is double flat && j.Facility.Lng is double flng)
|
||||||
j.DistanceKm = Geo.DistanceKm(Lat!.Value, Lng!.Value, flat, flng);
|
j.DistanceKm = Geo.DistanceKm(Lat!.Value, Lng!.Value, flat, flng);
|
||||||
Results = results.OrderBy(j => j.DistanceKm ?? double.MaxValue)
|
Results = all.OrderBy(j => j.DistanceKm ?? double.MaxValue)
|
||||||
.ThenByDescending(j => j.CreatedAt).ToList();
|
.ThenByDescending(j => j.CreatedAt).Skip(skip).Take(PageSize).ToList();
|
||||||
}
|
}
|
||||||
else
|
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);
|
SetSeo(Roles.FirstOrDefault(r => r.Id == RoleId)?.Name, Cities.FirstOrDefault(c => c.Id == CityId)?.Name);
|
||||||
|
|||||||
@@ -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<int, string> 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);
|
||||||
|
|
||||||
|
<nav class="pager" aria-label="صفحهبندی">
|
||||||
|
@if (cur > 1)
|
||||||
|
{
|
||||||
|
<a class="pager-btn" href="@pageUrl(cur - 1)" rel="prev">→ قبلی</a>
|
||||||
|
}
|
||||||
|
@if (from > 1)
|
||||||
|
{
|
||||||
|
<a class="pager-num" href="@pageUrl(1)">@JalaliDate.ToPersianDigits("1")</a>
|
||||||
|
@if (from > 2) { <span class="pager-gap">…</span> }
|
||||||
|
}
|
||||||
|
@for (var p = from; p <= to; p++)
|
||||||
|
{
|
||||||
|
if (p == cur)
|
||||||
|
{
|
||||||
|
<span class="pager-num active" aria-current="page">@JalaliDate.ToPersianDigits(p.ToString())</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="pager-num" href="@pageUrl(p)">@JalaliDate.ToPersianDigits(p.ToString())</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (to < total)
|
||||||
|
{
|
||||||
|
@if (to < total - 1) { <span class="pager-gap">…</span> }
|
||||||
|
<a class="pager-num" href="@pageUrl(total)">@JalaliDate.ToPersianDigits(total.ToString())</a>
|
||||||
|
}
|
||||||
|
@if (cur < total)
|
||||||
|
{
|
||||||
|
<a class="pager-btn" href="@pageUrl(cur + 1)" rel="next">بعدی ←</a>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<partial name="_Breadcrumbs" model="Model.Breadcrumbs" />
|
<partial name="_Breadcrumbs" model="Model.Breadcrumbs" />
|
||||||
<h1>@Model.PageHeading</h1>
|
<h1>@Model.PageHeading</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) شیفت باز پیدا شد
|
@JalaliDate.ToPersianDigits(Model.TotalCount.ToString()) شیفت باز پیدا شد
|
||||||
@if (Model.NearMeActive)
|
@if (Model.NearMeActive)
|
||||||
{
|
{
|
||||||
<span> — مرتبشده بر اساس نزدیکترین به شما 📍</span>
|
<span> — مرتبشده بر اساس نزدیکترین به شما 📍</span>
|
||||||
@@ -145,6 +145,7 @@
|
|||||||
<partial name="_ShiftCard" model="s" />
|
<partial name="_ShiftCard" model="s" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<partial name="_Pager" model="(Model.CurrentPage, Model.TotalPages)" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ public class IndexModel : PageModel
|
|||||||
[BindProperty(SupportsGet = true)] public string? RoleSlug { get; set; }
|
[BindProperty(SupportsGet = true)] public string? RoleSlug { get; set; }
|
||||||
[BindProperty(SupportsGet = true)] public string? CitySlug { 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 bool NearMeActive => Lat is not null && Lng is not null;
|
||||||
|
|
||||||
public List<Shift> Results { get; private set; } = new();
|
public List<Shift> Results { get; private set; } = new();
|
||||||
@@ -91,24 +97,27 @@ public class IndexModel : PageModel
|
|||||||
if (GenderFilter is Gender g && g != Gender.Any)
|
if (GenderFilter is Gender g && g != Gender.Any)
|
||||||
q = q.Where(s => s.GenderRequirement == Gender.Any || s.GenderRequirement == g);
|
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)
|
if (NearMeActive)
|
||||||
{
|
{
|
||||||
// Compute distance to each facility, then nearest-first (shifts without coords last).
|
// Distance sort needs all rows in memory; paginate after sorting (shifts without coords last).
|
||||||
foreach (var s in results)
|
var all = await q.ToListAsync();
|
||||||
{
|
foreach (var s in all)
|
||||||
if (s.Facility.Lat is double flat && s.Facility.Lng is double flng)
|
if (s.Facility.Lat is double flat && s.Facility.Lng is double flng)
|
||||||
s.DistanceKm = Geo.DistanceKm(Lat!.Value, Lng!.Value, flat, flng);
|
s.DistanceKm = Geo.DistanceKm(Lat!.Value, Lng!.Value, flat, flng);
|
||||||
}
|
Results = all
|
||||||
Results = results
|
|
||||||
.OrderBy(s => s.DistanceKm ?? double.MaxValue)
|
.OrderBy(s => s.DistanceKm ?? double.MaxValue)
|
||||||
.ThenBy(s => s.Date).ThenBy(s => s.StartTime)
|
.ThenBy(s => s.Date).ThenBy(s => s.StartTime)
|
||||||
.ToList();
|
.Skip(skip).Take(PageSize).ToList();
|
||||||
}
|
}
|
||||||
else
|
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);
|
SetSeo(Roles.FirstOrDefault(r => r.Id == RoleId)?.Name, Cities.FirstOrDefault(c => c.Id == CityId)?.Name);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>@Model.PageHeading</h1>
|
<h1>@Model.PageHeading</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) نیروی کادر درمان آمادهی همکاری —
|
@JalaliDate.ToPersianDigits(Model.TotalCount.ToString()) نیروی کادر درمان آمادهی همکاری —
|
||||||
مراکز درمانی میتوانند مستقیم تماس بگیرند.
|
مراکز درمانی میتوانند مستقیم تماس بگیرند.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,6 +83,7 @@
|
|||||||
<partial name="_TalentCard" model="t" />
|
<partial name="_TalentCard" model="t" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<partial name="_Pager" model="(Model.CurrentPage, Model.TotalPages)" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ public class IndexModel : PageModel
|
|||||||
[BindProperty(SupportsGet = true)] public int? RoleId { get; set; }
|
[BindProperty(SupportsGet = true)] public int? RoleId { get; set; }
|
||||||
[BindProperty(SupportsGet = true)] public Gender? GenderFilter { get; set; }
|
[BindProperty(SupportsGet = true)] public Gender? GenderFilter { get; set; }
|
||||||
[BindProperty(SupportsGet = true)] public string? Q { get; set; } // deep search
|
[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<TalentListing> Results { get; private set; } = new();
|
public List<TalentListing> Results { get; private set; } = new();
|
||||||
public List<City> Cities { get; private set; } = new();
|
public List<City> Cities { get; private set; } = new();
|
||||||
@@ -60,7 +65,11 @@ public class IndexModel : PageModel
|
|||||||
EF.Functions.ILike(t.City.Name, like));
|
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 role = Roles.FirstOrDefault(r => r.Id == RoleId)?.Name;
|
||||||
var city = Cities.FirstOrDefault(c => c.Id == CityId)?.Name;
|
var city = Cities.FirstOrDefault(c => c.Id == CityId)?.Name;
|
||||||
|
|||||||
@@ -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 { 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); }
|
.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); }
|
.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; }
|
.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; }
|
.nav-action { font-weight: 600; font-size: 15px; color: var(--muted); white-space: nowrap; transition: color .15s; }
|
||||||
|
|||||||
Reference in New Issue
Block a user