Add «آماده به کار» (talent) listing type — workers offering themselves
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:
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user