Initial commit — Hamkadr (همکادر) healthcare-staffing marketplace

ASP.NET Core 10 Razor Pages + PostgreSQL/EF Core. RTL Persian, Jalali dates, self-hosted Vazirmatn, teal/coral brand.

Features:
- Shift listings: browse/filter (city, district, role, type, pay), weekly Jalali calendar, detail + interest handoff, near-me distance sort
- Hiring (استخدام) listings with employment type + salary range
- Pattern-engine recommendations + anonymous interest tracking (visitor cookie)
- Heuristic Persian listing-parser + admin queue (raw channel post → shift/job)
- Phone-OTP cookie auth + visitor-history linking + profile

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-03 01:43:55 +03:30
commit 2fb86a435e
150 changed files with 90993 additions and 0 deletions
@@ -0,0 +1,91 @@
@page "{id:int}"
@model JobsMedical.Web.Pages.Jobs.DetailsModel
@{
var j = Model.Job!;
var f = j.Facility!;
ViewData["Title"] = j.Title;
ViewData["Description"] = $"{j.Title} در {f.Name}، {f.City?.Name}. موقعیت استخدامی برای {j.Role?.Name}.";
string empLabel = j.EmploymentType switch
{
EmploymentType.FullTime => "تمام‌وقت",
EmploymentType.PartTime => "پاره‌وقت",
EmploymentType.Contract => "قراردادی",
_ => "طرح",
};
string salary;
if (j.SalaryMin is null && j.SalaryMax is null) salary = "توافقی";
else if (j.SalaryMin == j.SalaryMax) salary = JalaliDate.Toman(j.SalaryMin) + " ماهانه";
else salary = $"از {JalaliDate.ToPersianDigits((j.SalaryMin ?? 0).ToString("#,0"))} تا {JalaliDate.Toman(j.SalaryMax)} ماهانه";
}
<div class="page-head">
<div class="container">
<div class="row" style="display:flex; gap:10px; align-items:center;">
<span class="badge badge-job">@empLabel</span>
@if (j.Role is not null) { <span class="badge badge-type">@j.Role.Name</span> }
@if (f.IsVerified) { <span class="badge badge-verified">✓ مرکز تأیید شده</span> }
</div>
<h1 style="margin-top:8px;">@j.Title</h1>
<p class="muted">🏥 @f.Name — 📍 @f.City?.Name@(f.District is not null ? "، " + f.District.Name : "")</p>
</div>
</div>
<div class="container section">
<div class="detail-grid">
<div>
@if (Model.ShowContact)
{
<div class="alert alert-success">
✓ تمایل شما ثبت شد. برای پیگیری استخدام با مرکز تماس بگیرید:
<strong>@(f.Phone ?? "شماره ثبت نشده")</strong>
@if (!string.IsNullOrEmpty(f.BaleId)) { <text> — بله: @f.BaleId</text> }
</div>
}
@if (Model.Saved)
{
<div class="alert alert-success">✓ این موقعیت ذخیره شد.</div>
}
<div class="card card-pad">
<h3 style="margin-top:0;">مشخصات موقعیت</h3>
<div class="info-row"><span class="k">نوع همکاری</span><span class="v">@empLabel</span></div>
<div class="info-row"><span class="k">نقش</span><span class="v">@j.Role?.Name</span></div>
<div class="info-row"><span class="k">حقوق ماهانه</span><span class="v" style="color:var(--primary-dark)">@salary</span></div>
</div>
@if (!string.IsNullOrEmpty(j.Description))
{
<div class="card card-pad" style="margin-top:16px;">
<h3 style="margin-top:0;">شرح موقعیت</h3>
<p class="muted" style="margin:0;">@j.Description</p>
</div>
}
@if (!string.IsNullOrEmpty(j.Requirements))
{
<div class="card card-pad" style="margin-top:16px;">
<h3 style="margin-top:0;">شرایط احراز</h3>
<p class="muted" style="margin:0;">@j.Requirements</p>
</div>
}
</div>
<aside>
<div class="card card-pad">
<div class="pay" style="font-size:19px; margin-bottom:6px; color:var(--primary-dark); font-weight:800;">@salary</div>
<p class="muted" style="font-size:13px; margin-top:0;">@empLabel</p>
<form method="post">
<button type="submit" asp-page-handler="Interest" asp-route-id="@j.Id"
class="btn btn-accent btn-block btn-lg">اعلام تمایل و مشاهده راه ارتباطی</button>
</form>
<div style="display:flex; gap:8px; margin-top:8px;">
<form method="post" style="flex:1;">
<button type="submit" asp-page-handler="Save" asp-route-id="@j.Id" class="btn btn-outline btn-block">♡ ذخیره</button>
</form>
<form method="post" style="flex:1;">
<button type="submit" asp-page-handler="Dismiss" asp-route-id="@j.Id" class="btn btn-outline btn-block">✕ علاقه‌مند نیستم</button>
</form>
</div>
</div>
</aside>
</div>
</div>
@@ -0,0 +1,65 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Jobs;
public class DetailsModel : PageModel
{
private readonly AppDbContext _db;
private readonly InterestService _interest;
public DetailsModel(AppDbContext db, InterestService interest)
{
_db = db;
_interest = interest;
}
public JobOpening? Job { get; private set; }
public bool ShowContact { get; private set; }
public bool Saved { get; private set; }
public async Task<IActionResult> OnGetAsync(int id)
{
await LoadAsync(id);
if (Job is null) return NotFound();
await _interest.LogJobAsync(InterestEventType.View, id);
return Page();
}
public async Task<IActionResult> OnPostInterestAsync(int id)
{
await LoadAsync(id);
if (Job is null) return NotFound();
await _interest.LogJobAsync(InterestEventType.Apply, id);
ShowContact = true;
return Page();
}
public async Task<IActionResult> OnPostSaveAsync(int id)
{
await LoadAsync(id);
if (Job is null) return NotFound();
await _interest.LogJobAsync(InterestEventType.Save, id);
Saved = true;
return Page();
}
public async Task<IActionResult> OnPostDismissAsync(int id)
{
await _interest.LogJobAsync(InterestEventType.Dismiss, id);
return RedirectToPage("/Jobs/Index");
}
private async Task LoadAsync(int id)
{
Job = await _db.JobOpenings
.Include(j => j.Facility).ThenInclude(f => f.City)
.Include(j => j.Facility).ThenInclude(f => f.District)
.Include(j => j.Role)
.FirstOrDefaultAsync(j => j.Id == id);
}
}
+118
View File
@@ -0,0 +1,118 @@
@page
@model JobsMedical.Web.Pages.Jobs.IndexModel
@{
ViewData["Title"] = "موقعیت‌های استخدامی";
}
<div class="page-head">
<div class="container">
<h1>موقعیت‌های استخدامی</h1>
<p class="muted">
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) موقعیت شغلی پیدا شد
@if (Model.NearMeActive)
{
<span> — مرتب‌شده بر اساس نزدیک‌ترین به شما 📍</span>
}
</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">
<input type="hidden" name="Lat" value="@Model.Lat" />
<input type="hidden" name="Lng" value="@Model.Lng" />
<div class="filter-group">
@if (Model.NearMeActive)
{
<a asp-page="/Jobs/Index" asp-route-CityId="@Model.CityId" asp-route-RoleId="@Model.RoleId"
class="btn btn-accent btn-block">✓ نزدیک‌ترین‌ها — حذف</a>
}
else
{
<button type="button" id="nearMeBtn" class="btn btn-outline btn-block">📍 نزدیک من</button>
}
</div>
<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="EmploymentType" onchange="this.form.submit()">
<option value="">همه</option>
<option value="0" selected="@(Model.EmploymentType == JobsMedical.Web.Models.EmploymentType.FullTime)">تمام‌وقت</option>
<option value="1" selected="@(Model.EmploymentType == JobsMedical.Web.Models.EmploymentType.PartTime)">پاره‌وقت</option>
<option value="2" selected="@(Model.EmploymentType == JobsMedical.Web.Models.EmploymentType.Contract)">قراردادی</option>
<option value="3" selected="@(Model.EmploymentType == JobsMedical.Web.Models.EmploymentType.Plan)">طرح</option>
</select>
</div>
<a asp-page="/Jobs/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 j in Model.Results)
{
<partial name="_JobCard" model="j" />
}
</div>
}
</div>
</div>
</div>
@section Scripts {
<script>
var btn = document.getElementById('nearMeBtn');
if (btn) {
btn.addEventListener('click', function () {
if (!navigator.geolocation) { alert('مرورگر شما از موقعیت‌یابی پشتیبانی نمی‌کند.'); return; }
btn.textContent = 'در حال یافتن موقعیت شما...'; btn.disabled = true;
navigator.geolocation.getCurrentPosition(function (pos) {
var form = document.getElementById('filterForm');
form.querySelector('[name=Lat]').value = pos.coords.latitude;
form.querySelector('[name=Lng]').value = pos.coords.longitude;
form.submit();
}, function () {
alert('دسترسی به موقعیت داده نشد.'); btn.textContent = '📍 نزدیک من'; btn.disabled = false;
});
});
}
</script>
}
@@ -0,0 +1,63 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Jobs;
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 EmploymentType? EmploymentType { get; set; }
[BindProperty(SupportsGet = true)] public double? Lat { get; set; }
[BindProperty(SupportsGet = true)] public double? Lng { get; set; }
public bool NearMeActive => Lat is not null && Lng is not null;
public List<JobOpening> 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.JobOpenings
.Include(j => j.Facility).ThenInclude(f => f.City)
.Include(j => j.Facility).ThenInclude(f => f.District)
.Include(j => j.Role)
.Where(j => j.Status == ShiftStatus.Open);
if (CityId is not null) q = q.Where(j => j.Facility.CityId == CityId);
if (DistrictId is not null) q = q.Where(j => j.Facility.DistrictId == DistrictId);
if (RoleId is not null) q = q.Where(j => j.RoleId == RoleId);
if (EmploymentType is not null) q = q.Where(j => j.EmploymentType == EmploymentType);
var results = await q.ToListAsync();
if (NearMeActive)
{
foreach (var j in results)
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();
}
else
{
Results = results.OrderByDescending(j => j.CreatedAt).ToList();
}
}
}