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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user