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,119 @@
@page "{id:int}"
@model JobsMedical.Web.Pages.Shifts.DetailsModel
@{
var s = Model.Shift!;
var f = s.Facility!;
ViewData["Title"] = $"شیفت {s.SpecialtyRequired} - {f.Name}";
ViewData["Description"] = $"شیفت {s.SpecialtyRequired} در {f.Name}، {f.City?.Name}، تاریخ {JalaliDate.ToLongDate(s.Date)} از ساعت {JalaliDate.Time(s.StartTime)}.";
var (badgeClass, typeLabel) = s.ShiftType switch
{
ShiftType.Day => ("badge-day", "شیفت صبح"),
ShiftType.Evening => ("badge-evening", "شیفت عصر"),
ShiftType.Night => ("badge-night", "شیفت شب"),
_ => ("badge-oncall", "آنکال"),
};
}
<div class="page-head">
<div class="container">
<div class="row" style="display:flex; gap:10px; align-items:center;">
<span class="badge @badgeClass">@typeLabel</span>
@if (f.IsVerified)
{
<span class="badge badge-verified">✓ مرکز تأیید شده</span>
}
</div>
<h1 style="margin-top:8px;">@s.SpecialtyRequired — @f.Name</h1>
<p class="muted">📍 @f.City?.Name @(string.IsNullOrEmpty(f.Address) ? "" : "، " + f.Address)</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>
}
<div class="card card-pad">
<h3 style="margin-top:0;">جزئیات شیفت</h3>
<div class="info-row"><span class="k">تاریخ</span><span class="v">@JalaliDate.WeekDayName(s.Date)، @JalaliDate.ToLongDate(s.Date)</span></div>
<div class="info-row"><span class="k">ساعت</span><span class="v">@JalaliDate.Time(s.StartTime) تا @JalaliDate.Time(s.EndTime)</span></div>
<div class="info-row"><span class="k">مدت</span><span class="v">@JalaliDate.ToPersianDigits(s.DurationHours.ToString("0.#")) ساعت</span></div>
<div class="info-row"><span class="k">نقش مورد نیاز</span><span class="v">@(s.Role?.Name ?? s.SpecialtyRequired)</span></div>
<div class="info-row"><span class="k">حقوق</span><span class="v" style="color:var(--primary-dark)">@JalaliDate.Toman(s.PayAmount)</span></div>
</div>
@if (!string.IsNullOrEmpty(s.Description))
{
<div class="card card-pad" style="margin-top:16px;">
<h3 style="margin-top:0;">توضیحات</h3>
<p class="muted" style="margin:0;">@s.Description</p>
</div>
}
@if (Model.MoreAtFacility.Count > 0)
{
<h3 style="margin:26px 0 14px;">شیفت‌های دیگر این مرکز</h3>
<div class="grid grid-3">
@foreach (var more in Model.MoreAtFacility)
{
<partial name="_ShiftCard" model="more" />
}
</div>
}
</div>
<aside>
<div class="card card-pad">
<div class="pay" style="font-size:20px; margin-bottom:6px; color:var(--primary-dark); font-weight:800;">
@JalaliDate.Toman(s.PayAmount)
</div>
<p class="muted" style="font-size:13px; margin-top:0;">@(s.PayType == PayType.Negotiable ? "توافقی با مرکز درمانی" : "برای هر شیفت")</p>
@if (Model.Saved)
{
<div class="alert alert-success" style="margin-bottom:12px;">✓ این فرصت ذخیره شد و در پیشنهادهای شما لحاظ می‌شود.</div>
}
<form method="post">
<button type="submit" asp-page-handler="Interest" asp-route-id="@s.Id"
class="btn btn-accent btn-block btn-lg">اعلام تمایل و مشاهده راه ارتباطی</button>
</form>
<p class="muted center" style="font-size:12px; margin:8px 0;">با اعلام تمایل، اطلاعات تماس مرکز نمایش داده می‌شود.</p>
<div style="display:flex; gap:8px;">
<form method="post" style="flex:1;">
<button type="submit" asp-page-handler="Save" asp-route-id="@s.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="@s.Id"
class="btn btn-outline btn-block">✕ علاقه‌مند نیستم</button>
</form>
</div>
</div>
<div class="card card-pad" style="margin-top:16px;">
<h3 style="margin-top:0;">موقعیت مکانی</h3>
@if (f.Lat is not null && f.Lng is not null)
{
<div style="background:var(--primary-soft); border-radius:10px; height:170px; display:grid; place-items:center; color:var(--primary-dark); text-align:center; padding:10px;">
🗺️<br />نقشه نشان/بلد<br />
<small class="muted">@f.Lat، @f.Lng</small>
</div>
<p class="muted" style="font-size:12px; margin-bottom:0;">نقشه تعاملی در فاز بعد اضافه می‌شود (Neshan/Balad).</p>
}
else
{
<p class="muted" style="margin:0;">مختصات این مرکز هنوز ثبت نشده است.</p>
}
</div>
</aside>
</div>
</div>
@@ -0,0 +1,78 @@
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.Shifts;
public class DetailsModel : PageModel
{
private readonly AppDbContext _db;
private readonly InterestService _interest;
public DetailsModel(AppDbContext db, InterestService interest)
{
_db = db;
_interest = interest;
}
public Shift? Shift { get; private set; }
public List<Shift> MoreAtFacility { get; private set; } = new();
// Set after the visitor taps "interested" — reveals the facility contact (handoff model).
public bool ShowContact { get; private set; }
public bool Saved { get; private set; }
public async Task<IActionResult> OnGetAsync(int id)
{
await LoadAsync(id);
if (Shift is null) return NotFound();
await _interest.LogAsync(InterestEventType.View, id); // behavioral signal for recommendations
return Page();
}
public async Task<IActionResult> OnPostInterestAsync(int id)
{
await LoadAsync(id);
if (Shift is null) return NotFound();
await _interest.LogAsync(InterestEventType.Apply, id);
ShowContact = true; // MVP handoff: reveal contact. Records an Application once auth lands.
return Page();
}
public async Task<IActionResult> OnPostSaveAsync(int id)
{
await LoadAsync(id);
if (Shift is null) return NotFound();
await _interest.LogAsync(InterestEventType.Save, id);
Saved = true;
return Page();
}
public async Task<IActionResult> OnPostDismissAsync(int id)
{
await _interest.LogAsync(InterestEventType.Dismiss, id);
return RedirectToPage("/Shifts/Index"); // not interested → back to the list
}
private async Task LoadAsync(int id)
{
Shift = await _db.Shifts
.Include(s => s.Facility).ThenInclude(f => f.City)
.Include(s => s.Role)
.FirstOrDefaultAsync(s => s.Id == id);
if (Shift is not null)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
MoreAtFacility = await _db.Shifts
.Include(s => s.Facility).ThenInclude(f => f.City)
.Include(s => s.Role)
.Where(s => s.FacilityId == Shift.FacilityId && s.Id != id
&& s.Status == ShiftStatus.Open && s.Date >= today)
.OrderBy(s => s.Date).Take(3).ToListAsync();
}
}
}
@@ -0,0 +1,145 @@
@page
@model JobsMedical.Web.Pages.Shifts.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">
@* Preserves the visitor's coordinates across filter changes when "near me" is on. *@
<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="/Shifts/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="FacilityId" onchange="this.form.submit()">
<option value="">همه مراکز</option>
@foreach (var f in Model.Facilities)
{
<option value="@f.Id" selected="@(Model.FacilityId == f.Id)">@f.Name</option>
}
</select>
</div>
<div class="filter-group">
<label>نوع شیفت</label>
<select name="ShiftType" onchange="this.form.submit()">
<option value="">همه</option>
<option value="0" selected="@(Model.ShiftType == JobsMedical.Web.Models.ShiftType.Day)">صبح</option>
<option value="1" selected="@(Model.ShiftType == JobsMedical.Web.Models.ShiftType.Evening)">عصر</option>
<option value="2" selected="@(Model.ShiftType == JobsMedical.Web.Models.ShiftType.Night)">شب</option>
<option value="3" selected="@(Model.ShiftType == JobsMedical.Web.Models.ShiftType.OnCall)">آنکال</option>
</select>
</div>
<div class="filter-group">
<label style="display:flex; align-items:center; gap:8px; font-weight:600;">
<input type="checkbox" name="PaidOnly" value="true" style="width:auto;"
onchange="this.form.submit()" checked="@Model.PaidOnly" />
فقط شیفت‌های با حقوق مشخص
</label>
</div>
<a asp-page="/Shifts/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 s in Model.Results)
{
<partial name="_ShiftCard" model="s" />
}
</div>
}
</div>
</div>
</div>
@section Scripts {
<script>
// "نزدیک من": ask the browser for the visitor's location, then re-run the search
// sorted by distance. Coordinates are sent only as query params for this request.
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,80 @@
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.Shifts;
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 int? FacilityId { get; set; }
[BindProperty(SupportsGet = true)] public ShiftType? ShiftType { get; set; }
[BindProperty(SupportsGet = true)] public bool PaidOnly { get; set; }
// "Near me": the browser sends the visitor's coordinates and we sort by distance.
[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<Shift> 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 List<Facility> Facilities { get; private set; } = new();
public async Task OnGetAsync()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
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();
Facilities = await _db.Facilities
.Where(f => CityId == null || f.CityId == CityId)
.OrderBy(f => f.Name).ToListAsync();
var q = _db.Shifts
.Include(s => s.Facility).ThenInclude(f => f.City)
.Include(s => s.Facility).ThenInclude(f => f.District)
.Include(s => s.Role)
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today);
if (CityId is not null) q = q.Where(s => s.Facility.CityId == CityId);
if (DistrictId is not null) q = q.Where(s => s.Facility.DistrictId == DistrictId);
if (RoleId is not null) q = q.Where(s => s.RoleId == RoleId);
if (FacilityId is not null) q = q.Where(s => s.FacilityId == FacilityId);
if (ShiftType is not null) q = q.Where(s => s.ShiftType == ShiftType);
if (PaidOnly) q = q.Where(s => s.PayAmount != null);
var results = await q.ToListAsync();
if (NearMeActive)
{
// Compute distance to each facility, then nearest-first (shifts without coords last).
foreach (var s in results)
{
if (s.Facility.Lat is double flat && s.Facility.Lng is double flng)
s.DistanceKm = Geo.DistanceKm(Lat!.Value, Lng!.Value, flat, flng);
}
Results = results
.OrderBy(s => s.DistanceKm ?? double.MaxValue)
.ThenBy(s => s.Date).ThenBy(s => s.StartTime)
.ToList();
}
else
{
Results = results.OrderBy(s => s.Date).ThenBy(s => s.StartTime).ToList();
}
}
}