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
@@ -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();
}
}