Add «آماده به کار» (talent) listing type — workers offering themselves
CI/CD / CI · dotnet build (push) Successful in 1m41s
CI/CD / Deploy · hamkadr (push) Has been cancelled

Adds a third listing kind alongside Shift/Job for healthcare staff who
advertise their own availability (very common in Iranian medical
channels, e.g. "دندانپزشک آماده همکاری… ۵۰٪ تسویه"). These have no
facility; the contact phone is the key field.

- Model: TalentListing (role, person name, years, licensed, city/district,
  area note, availability, gender, comp, phone) + ListingKind.Talent +
  RawListing.LinkedTalentId + DbSet/relations/indexes + EF migration.
- Parser: detect آماده‌به‌کار/جویای کار → Kind=Talent; extract person name,
  years of experience, licensed flag, area («منطقه ۱»), phone. Facility
  name extraction now skipped for talent.
- Validator: talent path scores role + phone + medical (no facility/pay
  required).
- Ingestion auto-publish: creates a TalentListing for talent kind.
- Review (manual publish): Talent option + talent fields; publishes a
  TalentListing without a facility. Shift/Job facility now falls back to a
  shared «نامشخص / ثبت نشده» record when the ad names none — publishing
  never fails on a missing facility.
- Browse /Talent (indexable, filters: city/district/role/gender),
  details /Talent/Details (noindex — personal contact, tel: call button),
  _TalentCard, badge-talent, nav link, home section.
- Sitemap includes /Talent; robots disallows /Talent/Details. Archiver
  expires stale talent listings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-08 08:01:12 +03:30
parent bdcca5e548
commit 4e5df73cf7
24 changed files with 2327 additions and 34 deletions
@@ -60,6 +60,10 @@
{
<a class="btn btn-outline" style="padding:4px 12px; font-size:13px;" asp-page="/Shifts/Details" asp-route-id="@sid" target="_blank">مشاهده آگهی منتشرشده</a>
}
else if (r.LinkedTalentId is int tid)
{
<a class="btn btn-outline" style="padding:4px 12px; font-size:13px;" asp-page="/Talent/Details" asp-route-id="@tid" target="_blank">مشاهده «آماده به کار» منتشرشده</a>
}
</div>
}
}
+49 -4
View File
@@ -46,9 +46,10 @@
<select name="Kind" id="kindSelect">
<option value="0" selected="@(Model.Kind == JobsMedical.Web.Models.ListingKind.Shift)">شیفت</option>
<option value="1" selected="@(Model.Kind == JobsMedical.Web.Models.ListingKind.Job)">استخدام</option>
<option value="2" selected="@(Model.Kind == JobsMedical.Web.Models.ListingKind.Talent)">آماده به کار (معرفی نیرو)</option>
</select>
</div>
<div class="filter-group">
<div class="filter-group" id="facilityGroup">
<label>مرکز درمانی</label>
<select name="FacilityId">
<option value="0" selected="@(Model.FacilityId == 0)">— انتخاب نشده —</option>
@@ -123,6 +124,40 @@
</div>
</div>
<div id="talentFields" style="display:none;">
<div class="filter-group">
<label>نام فرد (اختیاری)</label>
<input type="text" name="PersonName" value="@Model.PersonName" placeholder="مثلاً دکتر سپیده علیزاده" />
</div>
<div class="filter-group">
<label>شهر</label>
<select name="TalentCityId">
@foreach (var c in Model.Cities)
{
<option value="@c.Id" selected="@(Model.TalentCityId == c.Id)">@c.Name</option>
}
</select>
</div>
<div class="filter-group" style="display:flex; gap:8px;">
<div style="flex:1;"><label>سابقه (سال)</label><input type="number" name="YearsExperience" value="@Model.YearsExperience" min="0" max="60" dir="ltr" /></div>
<div style="flex:1;"><label>محدوده کاری</label><input type="text" name="AreaNote" value="@Model.AreaNote" placeholder="مثلاً فقط منطقه ۱" /></div>
</div>
<div class="filter-group">
<label>شماره تماس</label>
<input type="text" name="Phone" value="@Model.Phone" placeholder="۰۹۱۲…" dir="ltr" />
</div>
<div class="filter-group">
<label style="display:flex; align-items:center; gap:8px; font-weight:600;">
<input type="checkbox" name="IsLicensed" value="true" style="width:auto;" checked="@Model.IsLicensed" /> پروانه‌دار
</label>
</div>
<div class="filter-group" style="display:flex; gap:8px;">
<div style="flex:1;"><label>دستمزد مدنظر (تومان)</label><input type="number" name="PayAmount" value="@Model.PayAmount" dir="ltr" /></div>
<div style="flex:1;"><label>یا سهم درآمد (٪)</label><input type="number" name="SharePercent" value="@Model.SharePercent" min="1" max="100" dir="ltr" /></div>
</div>
<p class="muted" style="font-size:11px; margin:4px 0 0;">برای «آماده به کار» نیازی به مرکز نیست؛ شماره تماس مهم‌ترین فیلد است.</p>
</div>
<div class="filter-group">
<label style="display:flex; align-items:center; gap:8px; font-weight:600;">
<input type="checkbox" name="Negotiable" value="true" style="width:auto;" checked="@Model.Negotiable" /> توافقی
@@ -143,10 +178,20 @@
@section Scripts {
<script>
var kind = document.getElementById('kindSelect');
var facilityGroup = document.getElementById('facilityGroup');
// Show one section and DISABLE the hidden ones so duplicate-named inputs
// (PayAmount/SharePercent appear in both shift and talent) aren't submitted.
function setSection(el, on) {
if (!el) return;
el.style.display = on ? 'block' : 'none';
el.querySelectorAll('input,select,textarea').forEach(function (i) { i.disabled = !on; });
}
function toggleKind() {
var isJob = kind.value === '1';
document.getElementById('jobFields').style.display = isJob ? 'block' : 'none';
document.getElementById('shiftFields').style.display = isJob ? 'none' : 'block';
var v = kind.value;
setSection(document.getElementById('shiftFields'), v === '0');
setSection(document.getElementById('jobFields'), v === '1');
setSection(document.getElementById('talentFields'), v === '2');
setSection(facilityGroup, v !== '2'); // facility only for shift/job
}
kind.addEventListener('change', toggleKind);
toggleKind();
@@ -27,6 +27,7 @@ public class ReviewModel : PageModel
public ParsedListing? Parsed { get; private set; }
public List<Facility> Facilities { get; private set; } = new();
public List<Role> Roles { get; private set; } = new();
public List<City> Cities { get; private set; } = new();
[TempData] public string? Error { get; set; }
@@ -50,6 +51,13 @@ public class ReviewModel : PageModel
[BindProperty] public EmploymentType EmploymentType { get; set; }
[BindProperty] public long? SalaryMin { get; set; }
[BindProperty] public long? SalaryMax { get; set; }
// Talent («آماده به کار») fields — no facility; contact phone is key.
[BindProperty] public int TalentCityId { get; set; }
[BindProperty] public string? PersonName { get; set; }
[BindProperty] public int? YearsExperience { get; set; }
[BindProperty] public bool IsLicensed { get; set; }
[BindProperty] public string? AreaNote { get; set; }
[BindProperty] public string? Phone { get; set; }
public async Task<IActionResult> OnGetAsync(int id)
{
@@ -74,6 +82,15 @@ public class ReviewModel : PageModel
Description = Raw.RawText;
Title = Parsed.RoleName is not null ? $"استخدام {Parsed.RoleName}" : "موقعیت استخدامی";
// Talent prefill.
Phone = Parsed.Phone;
PersonName = Parsed.PersonName;
YearsExperience = Parsed.YearsExperience;
IsLicensed = Parsed.IsLicensed;
AreaNote = Parsed.AreaNote;
TalentCityId = Cities.FirstOrDefault(c => c.Name == Parsed.CityName)?.Id
?? Cities.FirstOrDefault()?.Id ?? 0;
// Facility: try to match the listing's facility to one we already have; otherwise
// prefill the "new facility" box so publishing creates it.
if (!string.IsNullOrWhiteSpace(Parsed.FacilityName))
@@ -100,21 +117,61 @@ public class ReviewModel : PageModel
Raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id);
if (Raw is null) return NotFound();
// Resolve the facility: prefer the picked one; otherwise create from the typed name.
// This prevents FK_Shifts_Facilities_FacilityId violations when no facility is selected
// (e.g. the dropdown is empty because no facilities exist yet).
var facilityId = await ResolveFacilityIdAsync();
if (facilityId is null)
{
Error = "یک مرکز درمانی معتبر انتخاب کن، یا در کادر «نام مرکز جدید» نام مرکز را وارد کن تا ساخته شود.";
return RedirectToPage(new { id });
}
if (!await _db.Roles.AnyAsync(r => r.Id == RoleId))
{
Error = "یک نقش معتبر انتخاب کن.";
return RedirectToPage(new { id });
}
// «آماده به کار» — a worker offering themselves. No facility; publish a TalentListing.
if (Kind == ListingKind.Talent)
{
var cityId = TalentCityId > 0 && await _db.Cities.AnyAsync(c => c.Id == TalentCityId)
? TalentCityId
: await _db.Cities.OrderByDescending(c => c.IsActive).Select(c => (int?)c.Id).FirstOrDefaultAsync();
if (cityId is null)
{
Error = "شهری برای انتشار آگهی «آماده به کار» موجود نیست.";
return RedirectToPage(new { id });
}
var talent = new TalentListing
{
RoleId = RoleId,
CityId = cityId.Value,
PersonName = string.IsNullOrWhiteSpace(PersonName) ? null : PersonName.Trim(),
YearsExperience = YearsExperience,
IsLicensed = IsLicensed,
AreaNote = string.IsNullOrWhiteSpace(AreaNote) ? null : AreaNote.Trim(),
Availability = EmploymentType,
Gender = GenderRequirement,
PayType = Negotiable ? PayType.Negotiable
: (PayAmount is null && SharePercent is not null ? PayType.Percentage : PayType.PerShift),
PayAmount = Negotiable ? null : PayAmount,
SharePercent = Negotiable ? null : SharePercent,
Phone = string.IsNullOrWhiteSpace(Phone) ? null : Phone.Trim(),
Description = Description,
Status = ShiftStatus.Open,
Source = ShiftSource.Aggregated,
SourceUrl = Raw.SourceUrl,
};
_db.TalentListings.Add(talent);
await _db.SaveChangesAsync();
Raw.Status = RawListingStatus.Normalized;
Raw.LinkedTalentId = talent.Id;
await _db.SaveChangesAsync();
return RedirectToPage("/Admin/Index");
}
// Shift/Job need a facility. Resolve the picked/typed one, falling back to a single
// shared «نامشخص / ثبت نشده» record when the ad doesn't name a facility — so publishing
// never fails on a missing facility.
var facilityId = await ResolveFacilityIdAsync();
if (facilityId is null)
{
Error = "شهری برای ساخت مرکز موجود نیست؛ ابتدا یک شهر اضافه کن.";
return RedirectToPage(new { id });
}
Shift? createdShift = null;
JobOpening? createdJob = null;
if (Kind == ListingKind.Shift)
@@ -188,24 +245,27 @@ public class ReviewModel : PageModel
_ => (new TimeOnly(8, 0), new TimeOnly(8, 0)),
};
/// <summary>Placeholder facility name used when an ad doesn't name a real one.</summary>
private const string UnknownFacilityName = "نامشخص / ثبت نشده";
/// <summary>
/// Returns a valid existing FacilityId, creating a new unverified facility from
/// <see cref="NewFacilityName"/> when nothing valid is selected. Returns null when
/// neither a valid facility is picked nor a name is provided.
/// Returns a valid FacilityId. Prefers the picked facility, then the typed/parsed name
/// (reusing a fuzzy match before creating), and finally falls back to a single shared
/// «نامشخص / ثبت نشده» record so publishing never fails for a missing facility.
/// Returns null only when there are no cities at all.
/// </summary>
private async Task<int?> ResolveFacilityIdAsync()
{
if (FacilityId > 0 && await _db.Facilities.AnyAsync(f => f.Id == FacilityId))
return FacilityId;
if (string.IsNullOrWhiteSpace(NewFacilityName))
return null;
var name = NewFacilityName.Trim();
var cityId = await _db.Cities.OrderByDescending(c => c.IsActive)
.Select(c => (int?)c.Id).FirstOrDefaultAsync();
if (cityId is null) return null; // no cities seeded — cannot create a facility
// No facility named in the ad → use/create the shared placeholder.
var name = string.IsNullOrWhiteSpace(NewFacilityName) ? UnknownFacilityName : NewFacilityName.Trim();
// Reuse an existing facility that's exactly or closely the same (Persian-aware fuzzy
// match), so we don't create duplicates like «بیمارستان میلاد» vs «میلاد».
var all = await _db.Facilities.ToListAsync();
@@ -229,6 +289,7 @@ public class ReviewModel : PageModel
{
Facilities = await _db.Facilities.Include(f => f.City).OrderBy(f => f.Name).ToListAsync();
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
}
private Task<List<string>> CityNamesAsync() => _db.Cities.Select(c => c.Name).ToListAsync();
+18
View File
@@ -133,6 +133,24 @@
</section>
}
@if (Model.LatestTalent.Count > 0)
{
<section class="section" style="padding-top:0;">
<div class="container">
<div class="section-head">
<h2>کادر درمان آماده به کار</h2>
<a asp-page="/Talent/Index">مشاهده همه ←</a>
</div>
<div class="grid grid-3">
@foreach (var t in Model.LatestTalent)
{
<partial name="_TalentCard" model="t" />
}
</div>
</div>
</section>
}
<section class="section" style="background: var(--surface); border-top: 1px solid var(--line);">
<div class="container">
<div class="section-head"><h2>چطور کار می‌کند؟</h2></div>
@@ -23,6 +23,7 @@ public class IndexModel : PageModel
public bool HasPersonalization { get; private set; }
public List<Shift> LatestShifts { get; private set; } = new();
public List<JobOpening> LatestJobs { get; private set; } = new();
public List<TalentListing> LatestTalent { get; private set; } = new();
public List<City> Cities { get; private set; } = new();
public List<Role> Roles { get; private set; } = new();
public int OpenShiftCount { get; private set; }
@@ -56,6 +57,14 @@ public class IndexModel : PageModel
.Take(3)
.ToListAsync();
LatestTalent = await _db.TalentListings
.Include(t => t.City).Include(t => t.District).Include(t => t.Role)
.Where(t => t.Status == ShiftStatus.Open
&& t.CreatedAt >= JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc)
.OrderByDescending(t => t.CreatedAt)
.Take(3)
.ToListAsync();
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
OpenShiftCount = await _db.Shifts.CountAsync(s => s.Status == ShiftStatus.Open && s.Date >= today);
@@ -113,6 +113,7 @@
<a asp-page="/Index" class="@(path == "/" ? "active" : null)">خانه</a>
<a asp-page="/Shifts/Index" data-tour="shifts" class="@(path.StartsWith("/Shifts") ? "active" : null)">شیفت‌ها</a>
<a asp-page="/Jobs/Index" data-tour="jobs" class="@(path.StartsWith("/Jobs") ? "active" : null)">استخدام</a>
<a asp-page="/Talent/Index" class="@(path.StartsWith("/Talent") ? "active" : null)">آماده به کار</a>
<a asp-page="/Facilities/Index" class="@(path.StartsWith("/Facilities") ? "active" : null)">مراکز درمانی</a>
<a asp-page="/Calendar/Index" class="@(path.StartsWith("/Calendar") ? "active" : null)">تقویم هفتگی</a>
</nav>
@@ -0,0 +1,44 @@
@model JobsMedical.Web.Models.TalentListing
@{
string comp;
if (Model.PayType == JobsMedical.Web.Models.PayType.Percentage && Model.SharePercent is int sp)
comp = $"{JalaliDate.ToPersianDigits(sp.ToString())}٪ سهم درآمد";
else if (Model.PayAmount is long pa && pa > 0)
comp = JalaliDate.Toman(pa) + " مدنظر";
else
comp = "توافقی";
var heading = string.IsNullOrWhiteSpace(Model.PersonName)
? (Model.Role?.Name ?? "آماده به کار")
: Model.PersonName!;
var area = Model.District?.Name ?? Model.AreaNote;
}
<a class="card card-pad shift-card" asp-page="/Talent/Details" asp-route-id="@Model.Id">
<div class="row" style="justify-content: space-between;">
<span class="facility">@heading</span>
<span class="badge badge-talent">آماده به کار</span>
</div>
<div class="row">
@if (Model.Role is not null)
{
<span class="badge badge-type">@Model.Role.Name</span>
}
@if (Model.YearsExperience is int yrs && yrs > 0)
{
<span class="badge badge-day">@JalaliDate.ToPersianDigits(yrs.ToString()) سال سابقه</span>
}
@if (Model.IsLicensed)
{
<span class="badge badge-verified">پروانه‌دار</span>
}
@if (Model.Gender != JobsMedical.Web.Models.Gender.Any)
{
<span class="badge badge-gender">@JalaliDate.GenderLabel(Model.Gender)</span>
}
</div>
<div class="row">📍 @Model.City?.Name@(area is not null ? "، " + area : "")</div>
<div class="foot">
<span class="pay">@comp</span>
<span class="btn btn-outline" style="padding: 6px 14px;">مشاهده و تماس</span>
</div>
</a>
@@ -0,0 +1,82 @@
@page "{id:int}"
@model JobsMedical.Web.Pages.Talent.DetailsModel
@{
var t = Model.Item!;
var heading = string.IsNullOrWhiteSpace(t.PersonName) ? (t.Role?.Name ?? "آماده به کار") : t.PersonName!;
ViewData["Title"] = $"{heading} — آماده به کار";
// Personal contact info: keep this page out of search indexes.
ViewData["NoIndex"] = true;
string comp;
if (t.PayType == JobsMedical.Web.Models.PayType.Percentage && t.SharePercent is int sp)
comp = $"{JalaliDate.ToPersianDigits(sp.ToString())}٪ سهم درآمد";
else if (t.PayAmount is long pa && pa > 0)
comp = JalaliDate.Toman(pa) + " مدنظر";
else
comp = "توافقی";
string? telHref = null;
if (!string.IsNullOrWhiteSpace(t.Phone))
{
var digits = new string(t.Phone.Where(char.IsDigit).ToArray());
if (digits.Length >= 7) telHref = "tel:" + digits;
}
}
<div class="page-head">
<div class="container">
<h1>@heading</h1>
<p class="muted">آماده همکاری @(t.Role is not null ? "— " + t.Role.Name : "") · 📍 @t.City?.Name@(t.District?.Name is not null ? "، " + t.District.Name : (t.AreaNote is not null ? "، " + t.AreaNote : ""))</p>
</div>
</div>
<div class="container section">
<div class="detail-grid">
<div>
<div class="card card-pad">
<div class="row" style="gap:8px; flex-wrap:wrap;">
@if (t.Role is not null) { <span class="badge badge-type">@t.Role.Name</span> }
<span class="badge badge-talent">آماده به کار</span>
@if (t.YearsExperience is int yrs && yrs > 0) { <span class="badge badge-day">@JalaliDate.ToPersianDigits(yrs.ToString()) سال سابقه</span> }
@if (t.IsLicensed) { <span class="badge badge-verified">پروانه‌دار</span> }
@if (t.Gender != JobsMedical.Web.Models.Gender.Any) { <span class="badge badge-gender">@JalaliDate.GenderLabel(t.Gender)</span> }
@if (t.Availability is JobsMedical.Web.Models.EmploymentType emp)
{
<span class="badge badge-job">@(emp switch {
JobsMedical.Web.Models.EmploymentType.FullTime => "تمام‌وقت",
JobsMedical.Web.Models.EmploymentType.PartTime => "پاره‌وقت",
JobsMedical.Web.Models.EmploymentType.Contract => "قراردادی",
_ => "طرح" })</span>
}
</div>
@if (!string.IsNullOrWhiteSpace(t.AreaNote))
{
<p style="margin:12px 0 0;"><strong>محدوده کاری:</strong> @t.AreaNote</p>
}
<p style="margin:12px 0 0;"><strong>دستمزد مدنظر:</strong> @comp</p>
@if (!string.IsNullOrWhiteSpace(t.Description))
{
<hr style="border:none; border-top:1px solid var(--line); margin:16px 0;" />
<p style="white-space:pre-wrap; margin:0;">@t.Description</p>
}
</div>
</div>
<aside>
<div class="card card-pad">
<h3 style="margin-top:0;">تماس</h3>
@if (telHref is not null)
{
<a href="@telHref" class="btn btn-accent btn-block btn-lg" dir="ltr">📞 @t.Phone</a>
<p class="muted" style="font-size:12px; margin:10px 0 0;">با این فرد مستقیم تماس بگیرید.</p>
}
else
{
<p class="muted">شماره تماس ثبت نشده است.</p>
}
@if (!string.IsNullOrWhiteSpace(t.SourceUrl))
{
<a href="@t.SourceUrl" target="_blank" rel="nofollow noopener" class="btn btn-outline btn-block" style="margin-top:8px;">منبع آگهی ↗</a>
}
</div>
</aside>
</div>
</div>
@@ -0,0 +1,26 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Talent;
public class DetailsModel : PageModel
{
private readonly AppDbContext _db;
public DetailsModel(AppDbContext db) => _db = db;
public TalentListing? Item { get; private set; }
public async Task<IActionResult> OnGetAsync(int id)
{
Item = await _db.TalentListings
.Include(t => t.City)
.Include(t => t.District)
.Include(t => t.Role)
.FirstOrDefaultAsync(t => t.Id == id);
if (Item is null) return NotFound();
return Page();
}
}
@@ -0,0 +1,81 @@
@page
@model JobsMedical.Web.Pages.Talent.IndexModel
@{
ViewData["Title"] = "آماده به کار — کادر درمان";
ViewData["Description"] = "فهرست کادر درمان آماده همکاری (پزشک، پرستار، ماما، تکنسین و…) در تهران و سایر شهرها — مرکز درمانی می‌تواند مستقیم تماس بگیرد.";
}
<div class="page-head">
<div class="container">
<h1>آماده به کار</h1>
<p class="muted">
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) نیروی کادر درمان آماده‌ی همکاری —
مراکز درمانی می‌توانند مستقیم تماس بگیرند.
</p>
</div>
</div>
<div class="container section">
<div class="layout-2">
<aside class="card card-pad filter-card">
<h3>فیلترها</h3>
<form method="get" id="filterForm">
<div class="filter-group">
<label>شهر</label>
<select name="CityId" onchange="this.form.submit()">
<option value="">همه شهرها</option>
@foreach (var c in Model.Cities)
{
<option value="@c.Id" selected="@(Model.CityId == c.Id)">@c.Name</option>
}
</select>
</div>
<div class="filter-group">
<label>محله / منطقه</label>
<select name="DistrictId" onchange="this.form.submit()">
<option value="">همه محله‌ها</option>
@foreach (var d in Model.Districts)
{
<option value="@d.Id" selected="@(Model.DistrictId == d.Id)">@d.Name</option>
}
</select>
</div>
<div class="filter-group">
<label>نقش / رشته</label>
<select name="RoleId" onchange="this.form.submit()">
<option value="">همه نقش‌ها</option>
@foreach (var r in Model.Roles)
{
<option value="@r.Id" selected="@(Model.RoleId == r.Id)">@r.Name</option>
}
</select>
</div>
<div class="filter-group">
<label>جنسیت</label>
<select name="GenderFilter" onchange="this.form.submit()">
<option value="">فرقی نمی‌کند</option>
<option value="1" selected="@(Model.GenderFilter == JobsMedical.Web.Models.Gender.Male)">آقا</option>
<option value="2" selected="@(Model.GenderFilter == JobsMedical.Web.Models.Gender.Female)">خانم</option>
</select>
</div>
<a asp-page="/Talent/Index" class="btn btn-outline btn-block">حذف فیلترها</a>
</form>
</aside>
<div>
@if (Model.Results.Count == 0)
{
<div class="card empty-state">نیرویی با این فیلترها پیدا نشد.</div>
}
else
{
<div class="grid grid-3">
@foreach (var t in Model.Results)
{
<partial name="_TalentCard" model="t" />
}
</div>
}
</div>
</div>
</div>
@@ -0,0 +1,47 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services.Scraping;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Talent;
public class IndexModel : PageModel
{
private readonly AppDbContext _db;
public IndexModel(AppDbContext db) => _db = db;
[BindProperty(SupportsGet = true)] public int? CityId { get; set; }
[BindProperty(SupportsGet = true)] public int? DistrictId { get; set; }
[BindProperty(SupportsGet = true)] public int? RoleId { get; set; }
[BindProperty(SupportsGet = true)] public Gender? GenderFilter { get; set; }
public List<TalentListing> Results { get; private set; } = new();
public List<City> Cities { get; private set; } = new();
public List<District> Districts { get; private set; } = new();
public List<Role> Roles { get; private set; } = new();
public async Task OnGetAsync()
{
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
Districts = await _db.Districts
.Where(d => d.IsActive && (CityId == null || d.CityId == CityId))
.OrderBy(d => d.Name).ToListAsync();
var q = _db.TalentListings
.Include(t => t.City)
.Include(t => t.District)
.Include(t => t.Role)
.Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= ListingPolicy.JobCutoffUtc);
if (CityId is not null) q = q.Where(t => t.CityId == CityId);
if (DistrictId is not null) q = q.Where(t => t.DistrictId == DistrictId);
if (RoleId is not null) q = q.Where(t => t.RoleId == RoleId);
if (GenderFilter is Gender g && g != Gender.Any)
q = q.Where(t => t.Gender == Gender.Any || t.Gender == g);
Results = await q.OrderByDescending(t => t.CreatedAt).ToListAsync();
}
}