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