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,50 @@
@page
@model JobsMedical.Web.Pages.Account.LoginModel
@{
ViewData["Title"] = "ورود کادر درمان";
}
<div class="container section" style="max-width:440px;">
<div class="card card-pad">
<h1 style="margin-top:0; font-size:22px;">ورود / ثبت‌نام</h1>
<p class="muted">با شماره موبایل وارد شو تا فرصت‌های متناسب با تو را ذخیره و پیشنهاد کنیم.</p>
@if (Model.Error is not null)
{
<div class="alert" style="background:#fdeaea; color:var(--danger);">@Model.Error</div>
}
@if (!Model.CodeSent)
{
<form method="post">
<div class="filter-group">
<label>شماره موبایل</label>
<input type="tel" name="Phone" value="@Model.Phone" placeholder="۰۹۱۲ ..." dir="ltr" />
</div>
<button type="submit" asp-page-handler="RequestCode" class="btn btn-accent btn-block btn-lg">دریافت کد تأیید</button>
</form>
}
else
{
@if (Model.DevCode is not null)
{
<div class="alert alert-success">
کد تأیید (حالت توسعه): <strong dir="ltr">@Model.DevCode</strong><br />
<span style="font-size:12px;">در نسخه‌ی نهایی این کد از طریق پیامک (کاوه‌نگار/SMS.ir) ارسال می‌شود.</span>
</div>
}
<form method="post">
<input type="hidden" name="Phone" value="@Model.Phone" />
<div class="filter-group">
<label>کد تأیید پنج‌رقمی</label>
<input type="text" name="Code" placeholder="- - - - -" dir="ltr" inputmode="numeric" />
</div>
<button type="submit" asp-page-handler="Verify" class="btn btn-accent btn-block btn-lg">ورود</button>
</form>
<form method="post" style="margin-top:8px;">
<input type="hidden" name="Phone" value="@Model.Phone" />
<button type="submit" asp-page-handler="RequestCode" class="btn btn-outline btn-block">ارسال مجدد کد</button>
</form>
}
</div>
</div>
@@ -0,0 +1,100 @@
using System.Security.Claims;
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Account;
public class LoginModel : PageModel
{
private readonly AppDbContext _db;
private readonly OtpService _otp;
private readonly VisitorContext _visitor;
private readonly IConfiguration _config;
public LoginModel(AppDbContext db, OtpService otp, VisitorContext visitor, IConfiguration config)
{
_db = db;
_otp = otp;
_visitor = visitor;
_config = config;
}
[BindProperty] public string Phone { get; set; } = "";
[BindProperty] public string? Code { get; set; }
public bool CodeSent { get; private set; }
public string? DevCode { get; private set; } // shown only in dev (no SMS gateway yet)
public string? Error { get; private set; }
public void OnGet() { }
public IActionResult OnPostRequestCode()
{
var phone = OtpService.Normalize(Phone);
if (phone.Length < 10)
{
Error = "شماره موبایل معتبر وارد کنید.";
return Page();
}
Phone = phone;
DevCode = _otp.Issue(phone); // dev: surface the code; prod: SMS gateway sends it
CodeSent = true;
return Page();
}
public async Task<IActionResult> OnPostVerifyAsync(string? returnUrl)
{
var phone = OtpService.Normalize(Phone);
if (!_otp.Verify(phone, Code ?? ""))
{
Error = "کد واردشده نادرست یا منقضی شده است.";
CodeSent = true;
return Page();
}
// Find or create the user. The configured admin phone is granted the Admin role.
var user = await _db.Users.FirstOrDefaultAsync(u => u.Phone == phone);
var isAdmin = phone == OtpService.Normalize(_config["Auth:AdminPhone"] ?? "");
if (user is null)
{
user = new User { Phone = phone, IsPhoneVerified = true,
Role = isAdmin ? UserRole.Admin : UserRole.Doctor };
_db.Users.Add(user);
}
else
{
user.IsPhoneVerified = true;
if (isAdmin) user.Role = UserRole.Admin;
}
await _db.SaveChangesAsync();
// Link the anonymous visitor (and its interest history) to this account.
var vid = _visitor.VisitorId;
if (!string.IsNullOrEmpty(vid))
{
var visitor = await _db.Visitors.FirstOrDefaultAsync(v => v.Id == vid);
if (visitor is null) { visitor = new Visitor { Id = vid }; _db.Visitors.Add(visitor); }
visitor.UserId = user.Id;
await _db.SaveChangesAsync();
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.MobilePhone, user.Phone),
new(ClaimTypes.Name, user.FullName ?? user.Phone),
new(ClaimTypes.Role, user.Role.ToString()),
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity));
return LocalRedirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl);
}
}
@@ -0,0 +1,3 @@
@page
@model JobsMedical.Web.Pages.Account.LogoutModel
@* POST-only; OnGet redirects home. *@
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JobsMedical.Web.Pages.Account;
public class LogoutModel : PageModel
{
public async Task<IActionResult> OnPostAsync()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return RedirectToPage("/Index");
}
public IActionResult OnGet() => RedirectToPage("/Index");
}
@@ -0,0 +1,67 @@
@page
@model JobsMedical.Web.Pages.Account.ProfileModel
@{
ViewData["Title"] = "پروفایل من";
string RoleLabel(UserRole r) => r switch
{
UserRole.Admin => "مدیر",
UserRole.FacilityAdmin => "مدیر مرکز درمانی",
_ => "کادر درمان",
};
}
<div class="page-head">
<div class="container">
<h1>پروفایل من</h1>
<p class="muted">
📱 <span dir="ltr">@JalaliDate.ToPersianDigits(Model.CurrentUser?.Phone ?? "")</span>
— @RoleLabel(Model.CurrentUser?.Role ?? UserRole.Doctor)
</p>
</div>
</div>
<div class="container section">
<div class="rec-banner">
<div>
<h2 style="margin:0 0 4px;">علاقه‌مندی‌هایت را کامل کن</h2>
<span style="opacity:.9; font-size:14px;">تا پیشنهادهای دقیق‌تری بگیری</span>
</div>
<a class="btn btn-outline" asp-page="/Preferences/Index">تنظیم علاقه‌مندی‌ها</a>
</div>
<h2 style="font-size:20px;">شیفت‌های ذخیره‌شده</h2>
@if (Model.SavedShifts.Count == 0)
{
<div class="card empty-state">هنوز شیفتی ذخیره نکرده‌ای.</div>
}
else
{
<div class="grid grid-3">
@foreach (var s in Model.SavedShifts) { <partial name="_ShiftCard" model="s" /> }
</div>
}
<h2 style="font-size:20px; margin-top:32px;">شیفت‌هایی که اعلام تمایل کردی</h2>
@if (Model.AppliedShifts.Count == 0)
{
<div class="card empty-state">هنوز برای شیفتی اعلام تمایل نکرده‌ای.</div>
}
else
{
<div class="grid grid-3">
@foreach (var s in Model.AppliedShifts) { <partial name="_ShiftCard" model="s" /> }
</div>
}
<h2 style="font-size:20px; margin-top:32px;">موقعیت‌های استخدامی که اعلام تمایل کردی</h2>
@if (Model.AppliedJobs.Count == 0)
{
<div class="card empty-state">هنوز برای موقعیتی اعلام تمایل نکرده‌ای.</div>
}
else
{
<div class="grid grid-3">
@foreach (var j in Model.AppliedJobs) { <partial name="_JobCard" model="j" /> }
</div>
}
</div>
@@ -0,0 +1,53 @@
using System.Security.Claims;
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Account;
[Authorize]
public class ProfileModel : PageModel
{
private readonly AppDbContext _db;
public ProfileModel(AppDbContext db) => _db = db;
public User? CurrentUser { get; private set; }
public List<Shift> SavedShifts { get; private set; } = new();
public List<JobOpening> AppliedJobs { get; private set; } = new();
public List<Shift> AppliedShifts { get; private set; } = new();
public async Task OnGetAsync()
{
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
CurrentUser = await _db.Users.FindAsync(userId);
// All visitor ids this account has been linked to (across devices).
var visitorIds = await _db.Visitors.Where(v => v.UserId == userId).Select(v => v.Id).ToListAsync();
var events = await _db.InterestEvents
.Where(e => visitorIds.Contains(e.VisitorId))
.OrderByDescending(e => e.CreatedAt)
.ToListAsync();
var savedShiftIds = events.Where(e => e.EventType == InterestEventType.Save && e.ShiftId != null)
.Select(e => e.ShiftId!.Value).Distinct().ToList();
var appliedShiftIds = events.Where(e => e.EventType == InterestEventType.Apply && e.ShiftId != null)
.Select(e => e.ShiftId!.Value).Distinct().ToList();
var appliedJobIds = events.Where(e => e.EventType == InterestEventType.Apply && e.JobOpeningId != null)
.Select(e => e.JobOpeningId!.Value).Distinct().ToList();
SavedShifts = await ShiftsByIds(savedShiftIds);
AppliedShifts = await ShiftsByIds(appliedShiftIds);
AppliedJobs = await _db.JobOpenings
.Include(j => j.Facility).ThenInclude(f => f.City).Include(j => j.Role)
.Where(j => appliedJobIds.Contains(j.Id)).ToListAsync();
}
private Task<List<Shift>> ShiftsByIds(List<int> ids) => _db.Shifts
.Include(s => s.Facility).ThenInclude(f => f.City)
.Include(s => s.Facility).ThenInclude(f => f.District)
.Include(s => s.Role)
.Where(s => ids.Contains(s.Id)).ToListAsync();
}
@@ -0,0 +1,59 @@
@page
@model JobsMedical.Web.Pages.Admin.IndexModel
@{
ViewData["Title"] = "مدیریت — صف آگهی‌ها";
}
<div class="page-head">
<div class="container">
<h1>پنل مدیریت — صف آگهی‌های خام</h1>
<p class="muted">
آگهی‌های جمع‌آوری‌شده از کانال‌ها را اینجا بررسی، ساختارمند و منتشر کن.
(@JalaliDate.ToPersianDigits(Model.Queue.Count.ToString()) در انتظار بررسی)
</p>
</div>
</div>
<div class="container section">
<div class="layout-2">
<aside class="card card-pad filter-card">
<h3>افزودن آگهی خام</h3>
<form method="post">
<div class="filter-group">
<label>منبع (کانال/سایت)</label>
<input type="text" name="SourceChannel" placeholder="مثلاً کانال شیفت تهران" />
</div>
<div class="filter-group">
<label>متن آگهی</label>
<textarea name="RawText" rows="6" placeholder="متن کپی‌شده از تلگرام/بله/دیوار را اینجا بچسبان..."></textarea>
</div>
<button type="submit" asp-page-handler="Add" class="btn btn-primary btn-block">افزودن به صف</button>
</form>
<p class="muted" style="font-size:12px; margin-bottom:0;">
منتشرشده: @JalaliDate.ToPersianDigits(Model.PublishedShifts.ToString()) شیفت،
@JalaliDate.ToPersianDigits(Model.PublishedJobs.ToString()) استخدام
</p>
</aside>
<div>
@if (Model.Queue.Count == 0)
{
<div class="card empty-state">صف خالی است. آگهی جدیدی برای بررسی وجود ندارد.</div>
}
else
{
foreach (var r in Model.Queue)
{
<div class="card card-pad" style="margin-bottom:14px;">
<div class="row" style="display:flex; justify-content:space-between;">
<strong>@r.SourceChannel</strong>
<span class="muted" style="font-size:12px;">@JalaliDate.ToLongDate(DateOnly.FromDateTime(r.FetchedAt))</span>
</div>
<p style="margin:10px 0; white-space:pre-wrap;">@r.RawText</p>
<a class="btn btn-accent" asp-page="/Admin/Review" asp-route-id="@r.Id">بررسی و انتشار ←</a>
</div>
}
}
</div>
</div>
</div>
@@ -0,0 +1,48 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Admin;
[Authorize(Roles = "Admin")] // secured by the OTP-auth Admin role
public class IndexModel : PageModel
{
private readonly AppDbContext _db;
public IndexModel(AppDbContext db) => _db = db;
public List<RawListing> Queue { get; private set; } = new();
public int PublishedShifts { get; private set; }
public int PublishedJobs { get; private set; }
[BindProperty] public string? SourceChannel { get; set; }
[BindProperty] public string? RawText { get; set; }
public async Task OnGetAsync() => await LoadAsync();
public async Task<IActionResult> OnPostAddAsync()
{
if (!string.IsNullOrWhiteSpace(RawText))
{
_db.RawListings.Add(new RawListing
{
SourceChannel = string.IsNullOrWhiteSpace(SourceChannel) ? "ورود دستی" : SourceChannel.Trim(),
RawText = RawText.Trim(),
Status = RawListingStatus.New,
});
await _db.SaveChangesAsync();
}
return RedirectToPage();
}
private async Task LoadAsync()
{
Queue = await _db.RawListings
.Where(r => r.Status == RawListingStatus.New)
.OrderByDescending(r => r.FetchedAt).ToListAsync();
PublishedShifts = await _db.Shifts.CountAsync(s => s.Source != ShiftSource.Direct);
PublishedJobs = await _db.JobOpenings.CountAsync();
}
}
@@ -0,0 +1,138 @@
@page "{id:int}"
@model JobsMedical.Web.Pages.Admin.ReviewModel
@{
ViewData["Title"] = "بررسی و انتشار آگهی";
var r = Model.Raw!;
}
<div class="page-head">
<div class="container"><h1>بررسی و انتشار آگهی</h1><p class="muted">منبع: @r.SourceChannel</p></div>
</div>
<div class="container section">
<div class="detail-grid">
<div>
<div class="card card-pad">
<h3 style="margin-top:0;">متن خام</h3>
<p style="white-space:pre-wrap; margin:0;">@r.RawText</p>
</div>
@if (Model.Parsed is not null)
{
<div class="card card-pad" style="margin-top:16px;">
<h3 style="margin-top:0;">🤖 تشخیص خودکار (پارسر)</h3>
<div class="rec-reasons">
@foreach (var note in Model.Parsed.Notes)
{
<span class="rec-reason">• @note</span>
}
@if (Model.Parsed.CityName is not null) { <span class="rec-reason">• شهر: @Model.Parsed.CityName</span> }
@if (Model.Parsed.DistrictName is not null) { <span class="rec-reason">• محله: @Model.Parsed.DistrictName</span> }
@if (Model.Parsed.Phone is not null) { <span class="rec-reason">• تلفن: @Model.Parsed.Phone</span> }
</div>
<p class="muted" style="font-size:12px; margin-bottom:0;">این‌ها فقط پیشنهاد هستند؛ قبل از انتشار بررسی و اصلاح کن.</p>
</div>
}
</div>
<aside>
<form method="post" class="card card-pad">
<div class="filter-group">
<label>نوع آگهی</label>
<select name="Kind" id="kindSelect">
<option value="0" selected="@(Model.Kind == JobsMedical.Web.Models.ListingKind.Shift)">شیفت</option>
<option value="1" selected="@(Model.Kind == JobsMedical.Web.Models.ListingKind.Job)">استخدام</option>
</select>
</div>
<div class="filter-group">
<label>مرکز درمانی</label>
<select name="FacilityId">
@foreach (var f in Model.Facilities)
{
<option value="@f.Id">@f.Name — @f.City?.Name</option>
}
</select>
</div>
<div class="filter-group">
<label>نقش</label>
<select name="RoleId">
@foreach (var role in Model.Roles)
{
<option value="@role.Id" selected="@(Model.RoleId == role.Id)">@role.Name</option>
}
</select>
</div>
<div id="shiftFields">
<div class="filter-group">
<label>تاریخ شیفت (میلادی)</label>
<input type="date" name="ShiftDate" value="@Model.ShiftDate.ToString("yyyy-MM-dd")" dir="ltr" />
</div>
<div class="filter-group">
<label>نوع شیفت</label>
<select name="ShiftType">
<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" style="display:flex; gap:8px;">
<div style="flex:1;"><label>شروع</label><input type="time" name="StartTime" value="@Model.StartTime.ToString("HH:mm")" dir="ltr" /></div>
<div style="flex:1;"><label>پایان</label><input type="time" name="EndTime" value="@Model.EndTime.ToString("HH:mm")" dir="ltr" /></div>
</div>
<div class="filter-group">
<label>حقوق هر شیفت (تومان)</label>
<input type="number" name="PayAmount" value="@Model.PayAmount" dir="ltr" />
</div>
</div>
<div id="jobFields" style="display:none;">
<div class="filter-group">
<label>عنوان موقعیت</label>
<input type="text" name="Title" value="@Model.Title" />
</div>
<div class="filter-group">
<label>نوع همکاری</label>
<select name="EmploymentType">
<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>
<div class="filter-group" style="display:flex; gap:8px;">
<div style="flex:1;"><label>حقوق از</label><input type="number" name="SalaryMin" value="@Model.SalaryMin" dir="ltr" /></div>
<div style="flex:1;"><label>تا</label><input type="number" name="SalaryMax" value="@Model.SalaryMax" dir="ltr" /></div>
</div>
</div>
<div class="filter-group">
<label style="display:flex; align-items:center; gap:8px; font-weight:600;">
<input type="checkbox" name="Negotiable" value="true" style="width:auto;" checked="@Model.Negotiable" /> توافقی
</label>
</div>
<div class="filter-group">
<label>توضیحات</label>
<textarea name="Description" rows="3">@Model.Description</textarea>
</div>
<button type="submit" asp-page-handler="Publish" asp-route-id="@r.Id" class="btn btn-accent btn-block btn-lg">انتشار</button>
<button type="submit" asp-page-handler="Discard" asp-route-id="@r.Id" class="btn btn-outline btn-block" style="margin-top:8px;">رد و حذف از صف</button>
</form>
</aside>
</div>
</div>
@section Scripts {
<script>
var kind = document.getElementById('kindSelect');
function toggleKind() {
var isJob = kind.value === '1';
document.getElementById('jobFields').style.display = isJob ? 'block' : 'none';
document.getElementById('shiftFields').style.display = isJob ? 'none' : 'block';
}
kind.addEventListener('change', toggleKind);
toggleKind();
</script>
}
@@ -0,0 +1,145 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Admin;
[Authorize(Roles = "Admin")]
public class ReviewModel : PageModel
{
private readonly AppDbContext _db;
private readonly IListingParser _parser;
public ReviewModel(AppDbContext db, IListingParser parser)
{
_db = db;
_parser = parser;
}
public RawListing? Raw { get; private set; }
public ParsedListing? Parsed { get; private set; }
public List<Facility> Facilities { get; private set; } = new();
public List<Role> Roles { get; private set; } = new();
// The editable form (prefilled from the parser, admin can override everything).
[BindProperty] public ListingKind Kind { get; set; }
[BindProperty] public int FacilityId { get; set; }
[BindProperty] public int RoleId { get; set; }
[BindProperty] public string? Description { get; set; }
// Shift fields
[BindProperty] public DateOnly ShiftDate { get; set; }
[BindProperty] public ShiftType ShiftType { get; set; }
[BindProperty] public TimeOnly StartTime { get; set; }
[BindProperty] public TimeOnly EndTime { get; set; }
[BindProperty] public long? PayAmount { get; set; }
[BindProperty] public bool Negotiable { get; set; }
// Job fields
[BindProperty] public string? Title { get; set; }
[BindProperty] public EmploymentType EmploymentType { get; set; }
[BindProperty] public long? SalaryMin { get; set; }
[BindProperty] public long? SalaryMax { get; set; }
public async Task<IActionResult> OnGetAsync(int id)
{
await LoadListsAsync();
Raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id);
if (Raw is null) return NotFound();
Parsed = _parser.Parse(Raw.RawText,
Roles.Select(r => r.Name), await CityNamesAsync(), await DistrictNamesAsync());
// Prefill the form from the parser's best guess.
Kind = Parsed.Kind;
RoleId = Roles.FirstOrDefault(r => r.Name == Parsed.RoleName)?.Id ?? Roles.FirstOrDefault()?.Id ?? 0;
ShiftType = Parsed.ShiftType ?? ShiftType.Day;
EmploymentType = Parsed.EmploymentType ?? EmploymentType.FullTime;
(StartTime, EndTime) = DefaultTimes(ShiftType);
ShiftDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1);
Negotiable = Parsed.PayNegotiable;
if (Parsed.PayAmount is not null) { PayAmount = Parsed.PayAmount; SalaryMin = Parsed.PayAmount; }
Description = Raw.RawText;
Title = Parsed.RoleName is not null ? $"استخدام {Parsed.RoleName}" : "موقعیت استخدامی";
return Page();
}
public async Task<IActionResult> OnPostPublishAsync(int id)
{
Raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id);
if (Raw is null) return NotFound();
if (Kind == ListingKind.Shift)
{
var role = await _db.Roles.FindAsync(RoleId);
var shift = new Shift
{
FacilityId = FacilityId,
RoleId = RoleId,
Date = ShiftDate,
StartTime = StartTime,
EndTime = EndTime,
ShiftType = ShiftType,
SpecialtyRequired = role?.Name ?? "",
Description = Description,
PayType = Negotiable ? PayType.Negotiable : PayType.PerShift,
PayAmount = Negotiable ? null : PayAmount,
Status = ShiftStatus.Open,
Source = ShiftSource.Aggregated,
SourceUrl = Raw.SourceUrl,
};
_db.Shifts.Add(shift);
await _db.SaveChangesAsync();
Raw.Status = RawListingStatus.Normalized;
Raw.LinkedShiftId = shift.Id;
}
else
{
var job = new JobOpening
{
FacilityId = FacilityId,
RoleId = RoleId,
Title = string.IsNullOrWhiteSpace(Title) ? "موقعیت استخدامی" : Title.Trim(),
EmploymentType = EmploymentType,
SalaryMin = Negotiable ? null : SalaryMin,
SalaryMax = Negotiable ? null : SalaryMax,
Description = Description,
Status = ShiftStatus.Open,
Source = ShiftSource.Aggregated,
SourceUrl = Raw.SourceUrl,
};
_db.JobOpenings.Add(job);
Raw.Status = RawListingStatus.Normalized;
}
await _db.SaveChangesAsync();
return RedirectToPage("/Admin/Index");
}
public async Task<IActionResult> OnPostDiscardAsync(int id)
{
var raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id);
if (raw is null) return NotFound();
raw.Status = RawListingStatus.Discarded;
await _db.SaveChangesAsync();
return RedirectToPage("/Admin/Index");
}
private static (TimeOnly, TimeOnly) DefaultTimes(ShiftType t) => t switch
{
ShiftType.Day => (new TimeOnly(8, 0), new TimeOnly(14, 0)),
ShiftType.Evening => (new TimeOnly(14, 0), new TimeOnly(20, 0)),
ShiftType.Night => (new TimeOnly(20, 0), new TimeOnly(8, 0)),
_ => (new TimeOnly(8, 0), new TimeOnly(8, 0)),
};
private async Task LoadListsAsync()
{
Facilities = await _db.Facilities.Include(f => f.City).OrderBy(f => f.Name).ToListAsync();
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
}
private Task<List<string>> CityNamesAsync() => _db.Cities.Select(c => c.Name).ToListAsync();
private Task<List<string>> DistrictNamesAsync() => _db.Districts.Select(d => d.Name).ToListAsync();
}
@@ -0,0 +1,72 @@
@page
@model JobsMedical.Web.Pages.Calendar.IndexModel
@{
ViewData["Title"] = "تقویم هفتگی شیفت‌ها";
var weekEnd = Model.WeekStart.AddDays(6);
}
<div class="page-head">
<div class="container">
<h1>تقویم هفتگی شیفت‌ها</h1>
<form method="get" style="margin-top:12px; max-width:360px;">
<input type="hidden" name="WeekOffset" value="@Model.WeekOffset" />
<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>
</form>
</div>
</div>
<div class="container section">
<div class="cal-nav">
<a class="btn btn-outline" asp-page="/Calendar/Index"
asp-route-FacilityId="@Model.FacilityId" asp-route-WeekOffset="@(Model.WeekOffset - 1)">→ هفته قبل</a>
<strong>
@JalaliDate.DayOfMonth(Model.WeekStart) @JalaliDate.MonthName(Model.WeekStart)
تا
@JalaliDate.DayOfMonth(weekEnd) @JalaliDate.MonthName(weekEnd)
</strong>
<a class="btn btn-outline" asp-page="/Calendar/Index"
asp-route-FacilityId="@Model.FacilityId" asp-route-WeekOffset="@(Model.WeekOffset + 1)">هفته بعد ←</a>
</div>
<table class="cal">
<thead>
<tr>
@foreach (var (date, _) in Model.Days)
{
<th>@JalaliDate.WeekDayName(date)</th>
}
</tr>
</thead>
<tbody>
<tr>
@foreach (var (date, dayShifts) in Model.Days)
{
var isToday = date == Model.Today;
<td class="@(isToday ? "today" : "") @(dayShifts.Count == 0 ? "empty" : "")">
<div class="day-num">@JalaliDate.DayOfMonth(date)</div>
@foreach (var s in dayShifts)
{
var cls = s.ShiftType switch
{
ShiftType.Day => "day",
ShiftType.Evening => "evening",
ShiftType.Night => "night",
_ => "oncall",
};
<a class="cal-chip @cls" asp-page="/Shifts/Details" asp-route-id="@s.Id"
title="@s.Facility?.Name">
@JalaliDate.Time(s.StartTime) @s.Facility?.Name
</a>
}
</td>
}
</tr>
</tbody>
</table>
</div>
@@ -0,0 +1,46 @@
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.Calendar;
public class IndexModel : PageModel
{
private readonly AppDbContext _db;
public IndexModel(AppDbContext db) => _db = db;
[BindProperty(SupportsGet = true)] public int? FacilityId { get; set; }
[BindProperty(SupportsGet = true)] public int WeekOffset { get; set; } // 0 = current week
public List<Facility> Facilities { get; private set; } = new();
public DateOnly WeekStart { get; private set; }
public DateOnly Today { get; private set; }
/// <summary>7 days (Saturday→Friday), each with its open shifts.</summary>
public List<(DateOnly Date, List<Shift> Shifts)> Days { get; private set; } = new();
public async Task OnGetAsync()
{
Today = DateOnly.FromDateTime(DateTime.UtcNow);
Facilities = await _db.Facilities.OrderBy(f => f.Name).ToListAsync();
WeekStart = JalaliDate.StartOfPersianWeek(Today).AddDays(WeekOffset * 7);
var weekEnd = WeekStart.AddDays(6);
var q = _db.Shifts
.Include(s => s.Facility)
.Where(s => s.Status == ShiftStatus.Open && s.Date >= WeekStart && s.Date <= weekEnd);
if (FacilityId is not null) q = q.Where(s => s.FacilityId == FacilityId);
var shifts = await q.OrderBy(s => s.StartTime).ToListAsync();
Days = Enumerable.Range(0, 7)
.Select(i => WeekStart.AddDays(i))
.Select(d => (d, shifts.Where(s => s.Date == d).ToList()))
.ToList();
}
}
+26
View File
@@ -0,0 +1,26 @@
@page
@model ErrorModel
@{
ViewData["Title"] = "Error";
}
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
+20
View File
@@ -0,0 +1,20 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JobsMedical.Web.Pages;
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
[IgnoreAntiforgeryToken]
public class ErrorModel : PageModel
{
public string? RequestId { get; set; }
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
public void OnGet()
{
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
}
}
@@ -0,0 +1,43 @@
@page
@model JobsMedical.Web.Pages.Facilities.IndexModel
@{
ViewData["Title"] = "مراکز درمانی";
string TypeLabel(FacilityType t) => t switch
{
FacilityType.Hospital => "بیمارستان",
FacilityType.Clinic => "کلینیک",
_ => "درمانگاه",
};
}
<div class="page-head">
<div class="container"><h1>مراکز درمانی</h1></div>
</div>
<div class="container section">
<div class="grid grid-3">
@foreach (var row in Model.Rows)
{
<div class="card card-pad">
<div class="row" style="display:flex; justify-content:space-between; align-items:center;">
<span class="facility" style="font-weight:800; font-size:16px;">@row.Facility.Name</span>
@if (row.Facility.IsVerified)
{
<span class="badge badge-verified">✓</span>
}
</div>
<p class="muted" style="margin:8px 0;">
<span class="badge badge-type">@TypeLabel(row.Facility.Type)</span>
📍 @row.Facility.City?.Name
</p>
<div class="foot" style="display:flex; justify-content:space-between; align-items:center; border-top:1px solid var(--line); padding-top:12px;">
<span class="pay" style="color:var(--primary-dark); font-weight:800;">
@JalaliDate.ToPersianDigits(row.OpenShifts.ToString()) شیفت باز
</span>
<a class="btn btn-outline" style="padding:6px 14px;"
asp-page="/Calendar/Index" asp-route-FacilityId="@row.Facility.Id">تقویم</a>
</div>
</div>
}
</div>
</div>
@@ -0,0 +1,30 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Facilities;
public class IndexModel : PageModel
{
private readonly AppDbContext _db;
public IndexModel(AppDbContext db) => _db = db;
public record FacilityRow(Facility Facility, int OpenShifts);
public List<FacilityRow> Rows { get; private set; } = new();
public async Task OnGetAsync()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var facilities = await _db.Facilities.Include(f => f.City).OrderBy(f => f.Name).ToListAsync();
var counts = await _db.Shifts
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today)
.GroupBy(s => s.FacilityId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
Rows = facilities
.Select(f => new FacilityRow(f, counts.GetValueOrDefault(f.Id)))
.ToList();
}
}
+154
View File
@@ -0,0 +1,154 @@
@page
@model IndexModel
@{
ViewData["Title"] = null; // use default site title for the home page (best for SEO)
ViewData["Description"] = "همکادر؛ سریع‌ترین راه برای کادر درمان (پزشک، پرستار، ماما، تکنسین) جهت یافتن شیفت و موقعیت استخدامی در بیمارستان‌ها و کلینیک‌های تهران. به‌جای گشتن در کانال‌های تلگرام و بله، همه فرصت‌ها یک‌جا.";
}
<section class="hero">
<div class="container">
<h1>شیفت و شغل بعدی‌ات را در چند ثانیه پیدا کن</h1>
<p>
دیگر لازم نیست ده‌ها کانال تلگرام، بله و آگهی دیوار را زیر و رو کنی.
همه‌ی شیفت‌ها و فرصت‌های استخدامی کادر درمان تهران، دسته‌بندی‌شده بر اساس
مرکز درمانی، محل و تقویم هفتگی — یک‌جا.
</p>
<form class="search-card" method="get" asp-page="/Shifts/Index">
<div class="field">
<label>شهر</label>
<select name="cityId">
<option value="">همه شهرها</option>
@foreach (var c in Model.Cities)
{
<option value="@c.Id">@c.Name</option>
}
</select>
</div>
<div class="field">
<label>نقش</label>
<select name="roleId">
<option value="">همه نقش‌ها</option>
@foreach (var r in Model.Roles)
{
<option value="@r.Id">@r.Name</option>
}
</select>
</div>
<div class="field">
<label>نوع شیفت</label>
<select name="shiftType">
<option value="">همه</option>
<option value="0">صبح</option>
<option value="1">عصر</option>
<option value="2">شب</option>
<option value="3">آنکال</option>
</select>
</div>
<div class="field">
<label>&nbsp;</label>
<button type="submit" class="btn btn-accent btn-block btn-lg">جستجوی فرصت‌ها</button>
</div>
</form>
<div class="stat-pills">
<div class="stat-pill"><span class="n">@JalaliDate.ToPersianDigits(Model.OpenShiftCount.ToString())</span><span class="l">شیفت باز</span></div>
<div class="stat-pill"><span class="n">@JalaliDate.ToPersianDigits(Model.FacilityCount.ToString())</span><span class="l">مرکز درمانی</span></div>
<div class="stat-pill"><span class="n">@JalaliDate.ToPersianDigits(Model.CityCount.ToString())</span><span class="l">شهر فعال</span></div>
</div>
</div>
</section>
@if (Model.Recommendations.Count > 0)
{
<section class="section" style="padding-bottom:0;">
<div class="container">
@if (Model.HasPersonalization)
{
<div class="rec-banner">
<div>
<h2 style="margin:0 0 4px;">✨ پیشنهادهای ویژه شما</h2>
<span style="opacity:.9; font-size:14px;">بر اساس علاقه‌مندی‌ها و فعالیت شما انتخاب شده‌اند</span>
</div>
<a class="btn btn-outline" asp-page="/Preferences/Index">ویرایش علاقه‌مندی‌ها</a>
</div>
}
else
{
<div class="rec-banner">
<div>
<h2 style="margin:0 0 4px;">پیشنهادها را شخصی‌سازی کن</h2>
<span style="opacity:.9; font-size:14px;">نقش، شهر و نوع شیفت دلخواهت را بگو تا بهترین فرصت‌ها را برایت پیدا کنیم</span>
</div>
<a class="btn btn-outline" asp-page="/Preferences/Index">تنظیم علاقه‌مندی‌ها</a>
</div>
}
<div class="grid grid-3">
@foreach (var rec in Model.Recommendations)
{
<partial name="_RecommendationCard" model="rec" />
}
</div>
</div>
</section>
}
<section class="section">
<div class="container">
<div class="section-head">
<h2>جدیدترین شیفت‌ها</h2>
<a asp-page="/Shifts/Index">مشاهده همه ←</a>
</div>
@if (Model.LatestShifts.Count == 0)
{
<div class="empty-state">فعلاً شیفت بازی ثبت نشده است.</div>
}
else
{
<div class="grid grid-3">
@foreach (var s in Model.LatestShifts)
{
<partial name="_ShiftCard" model="s" />
}
</div>
}
</div>
</section>
@if (Model.LatestJobs.Count > 0)
{
<section class="section" style="padding-top:0;">
<div class="container">
<div class="section-head">
<h2>فرصت‌های استخدامی</h2>
<a asp-page="/Jobs/Index">مشاهده همه ←</a>
</div>
<div class="grid grid-3">
@foreach (var j in Model.LatestJobs)
{
<partial name="_JobCard" model="j" />
}
</div>
</div>
</section>
}
<section class="section" style="background: var(--surface); border-top: 1px solid var(--line);">
<div class="container">
<div class="section-head"><h2>چطور کار می‌کند؟</h2></div>
<div class="grid grid-3">
<div class="card card-pad">
<h3 style="margin-top:0;">۱. جستجو کن</h3>
<p class="muted">بر اساس شهر، بیمارستان، تاریخ و نوع شیفت، موقعیت مناسب خودت را فیلتر کن.</p>
</div>
<div class="card card-pad">
<h3 style="margin-top:0;">۲. تقویم را ببین</h3>
<p class="muted">شیفت‌های خالی هر مرکز را در یک نمای هفتگی شمسی مشاهده کن.</p>
</div>
<div class="card card-pad">
<h3 style="margin-top:0;">۳. اعلام تمایل کن</h3>
<p class="muted">روی شیفت دلخواه «اعلام تمایل» بزن تا مرکز درمانی با تو تماس بگیرد.</p>
</div>
</div>
</div>
</section>
+64
View File
@@ -0,0 +1,64 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages;
public class IndexModel : PageModel
{
private readonly AppDbContext _db;
private readonly RecommendationService _recs;
private readonly InterestService _interest;
public IndexModel(AppDbContext db, RecommendationService recs, InterestService interest)
{
_db = db;
_recs = recs;
_interest = interest;
}
public List<Recommendation> Recommendations { get; private set; } = new();
public bool HasPersonalization { get; private set; }
public List<Shift> LatestShifts { get; private set; } = new();
public List<JobOpening> LatestJobs { get; private set; } = new();
public List<City> Cities { get; private set; } = new();
public List<Role> Roles { get; private set; } = new();
public int OpenShiftCount { get; private set; }
public int FacilityCount { get; private set; }
public int CityCount { get; private set; }
public async Task OnGetAsync()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
Recommendations = await _recs.GetForVisitorAsync(6);
// "Personalized" = we actually used a signal (prefs or behavior), not just cold-start freshness.
HasPersonalization = (await _interest.GetPreferencesAsync())?.HasAny == true
|| (await _interest.RecentEventsAsync(1)).Count > 0;
LatestShifts = await _db.Shifts
.Include(s => s.Facility).ThenInclude(f => f.City)
.Include(s => s.Role)
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today)
.OrderBy(s => s.Date).ThenBy(s => s.StartTime)
.Take(6)
.ToListAsync();
LatestJobs = await _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)
.OrderByDescending(j => j.CreatedAt)
.Take(3)
.ToListAsync();
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();
OpenShiftCount = await _db.Shifts.CountAsync(s => s.Status == ShiftStatus.Open && s.Date >= today);
FacilityCount = await _db.Facilities.CountAsync();
CityCount = await _db.Cities.CountAsync(c => c.IsActive);
}
}
@@ -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();
}
}
}
@@ -0,0 +1,56 @@
@page
@model JobsMedical.Web.Pages.Preferences.IndexModel
@{
ViewData["Title"] = "علاقه‌مندی‌های شما";
}
<div class="page-head">
<div class="container">
<h1>علاقه‌مندی‌های شما</h1>
<p class="muted">بگو دنبال چه فرصتی هستی تا «همکادر» بهترین شیفت‌ها و موقعیت‌ها را برایت پیشنهاد دهد.</p>
</div>
</div>
<div class="container section" style="max-width:560px;">
<form method="post" class="card card-pad">
<div class="filter-group">
<label>نقش / رشته</label>
<select name="RoleId">
<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="CityId">
<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="PreferredShiftType">
<option value="">مهم نیست</option>
<option value="0" selected="@(Model.PreferredShiftType == ShiftType.Day)">صبح</option>
<option value="1" selected="@(Model.PreferredShiftType == ShiftType.Evening)">عصر</option>
<option value="2" selected="@(Model.PreferredShiftType == ShiftType.Night)">شب</option>
<option value="3" selected="@(Model.PreferredShiftType == ShiftType.OnCall)">آنکال</option>
</select>
</div>
<div class="filter-group">
<label>حداقل حقوق مورد انتظار (تومان)</label>
<input type="number" name="MinPay" value="@Model.MinPay" placeholder="مثلاً ۲۰۰۰۰۰۰" dir="ltr" />
</div>
<button type="submit" class="btn btn-primary btn-block btn-lg">ذخیره و دیدن پیشنهادها</button>
</form>
</div>
@@ -0,0 +1,57 @@
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.Preferences;
public class IndexModel : PageModel
{
private readonly AppDbContext _db;
private readonly InterestService _interest;
public IndexModel(AppDbContext db, InterestService interest)
{
_db = db;
_interest = interest;
}
public List<Role> Roles { get; private set; } = new();
public List<City> Cities { get; private set; } = new();
[BindProperty] public int? RoleId { get; set; }
[BindProperty] public int? CityId { get; set; }
[BindProperty] public ShiftType? PreferredShiftType { get; set; }
[BindProperty] public long? MinPay { get; set; }
public bool Saved { get; private set; }
public async Task OnGetAsync()
{
await LoadListsAsync();
var prefs = await _interest.GetPreferencesAsync();
if (prefs is not null)
{
RoleId = prefs.RoleId;
CityId = prefs.CityId;
PreferredShiftType = prefs.PreferredShiftType;
MinPay = prefs.MinPay;
}
}
public async Task<IActionResult> OnPostAsync()
{
await _interest.SavePreferencesAsync(RoleId, CityId, PreferredShiftType, MinPay);
// Back to home so the personalized feed is the immediate payoff.
TempData["prefsSaved"] = true;
return RedirectToPage("/Index");
}
private async Task LoadListsAsync()
{
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
}
}
+8
View File
@@ -0,0 +1,8 @@
@page
@model PrivacyModel
@{
ViewData["Title"] = "Privacy Policy";
}
<h1>@ViewData["Title"]</h1>
<p>Use this page to detail your site's privacy policy.</p>
@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JobsMedical.Web.Pages;
public class PrivacyModel : PageModel
{
public void OnGet()
{
}
}
@@ -0,0 +1,36 @@
@model JobsMedical.Web.Models.JobOpening
@{
string empLabel = Model.EmploymentType switch
{
EmploymentType.FullTime => "تمام‌وقت",
EmploymentType.PartTime => "پاره‌وقت",
EmploymentType.Contract => "قراردادی",
_ => "طرح",
};
string salary;
if (Model.SalaryMin is null && Model.SalaryMax is null) salary = "توافقی";
else if (Model.SalaryMin == Model.SalaryMax) salary = JalaliDate.Toman(Model.SalaryMin) + " ماهانه";
else salary = $"از {JalaliDate.ToPersianDigits((Model.SalaryMin ?? 0).ToString("#,0"))} تا {JalaliDate.Toman(Model.SalaryMax)} ماهانه";
}
<a class="card card-pad shift-card" asp-page="/Jobs/Details" asp-route-id="@Model.Id">
<div class="row" style="justify-content: space-between;">
<span class="facility">@Model.Title</span>
<span class="badge badge-job">@empLabel</span>
</div>
<div class="row">
@if (Model.Role is not null)
{
<span class="badge badge-type">@Model.Role.Name</span>
}
<span>🏥 @Model.Facility?.Name</span>
</div>
<div class="row">📍 @Model.Facility?.City?.Name@(Model.Facility?.District is not null ? "، " + Model.Facility.District.Name : "")</div>
@if (Model.DistanceKm is double km)
{
<div class="row"><span class="badge badge-distance">📍 @JalaliDate.ToPersianDigits(km.ToString("0.#")) کیلومتر از شما</span></div>
}
<div class="foot">
<span class="pay">@salary</span>
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
</div>
</a>
@@ -0,0 +1,68 @@
@{
var title = ViewData["Title"] as string;
}
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@(title is null ? "همکادر | شیفت و استخدام کادر درمان" : title + " | همکادر")</title>
<meta name="description" content="@(ViewData["Description"] as string ?? "همکادر؛ سامانه یافتن شیفت و موقعیت استخدامی برای کادر درمان (پزشک، پرستار، ماما و تکنسین) در بیمارستان‌ها و کلینیک‌های تهران.")" />
@* Preload the body-weight font so the swap from Tahoma happens fast. Vazirmatn is
self-hosted under wwwroot/fonts (@@font-face in site.css) — no external CDN. *@
<link rel="preload" href="~/fonts/Vazirmatn-Regular.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
</head>
<body>
<header class="site-header">
<div class="container header-inner">
<a class="brand" asp-page="/Index">
<span class="brand-mark">ه</span>
<span class="brand-text">همکادر</span>
</a>
<nav class="main-nav">
<a asp-page="/Index">خانه</a>
<a asp-page="/Shifts/Index">شیفت‌ها</a>
<a asp-page="/Jobs/Index">استخدام</a>
<a asp-page="/Calendar/Index">تقویم هفتگی</a>
<a asp-page="/Facilities/Index">مراکز درمانی</a>
<a asp-page="/Preferences/Index">علاقه‌مندی‌ها</a>
</nav>
<div class="header-actions">
@if (User.Identity?.IsAuthenticated == true)
{
@if (User.IsInRole("Admin"))
{
<a asp-page="/Admin/Index" style="margin-inline-end:14px; font-weight:600;">پنل مدیریت</a>
}
<a asp-page="/Account/Profile" style="margin-inline-end:10px; font-weight:600;">پروفایل</a>
<form method="post" asp-page="/Account/Logout" style="display:inline;">
<button type="submit" class="btn btn-outline" style="padding:7px 14px;">خروج</button>
</form>
}
else
{
<a class="btn btn-outline" asp-page="/Account/Login">ورود</a>
}
</div>
</div>
</header>
<main role="main">
@RenderBody()
</main>
<footer class="site-footer">
<div class="container footer-inner">
<div>
<span class="brand-mark sm">ه</span>
<strong>همکادر</strong>
<p class="muted">سامانه واسط میان کادر درمان و مراکز درمانی برای شیفت و استخدام</p>
</div>
<div class="muted">© ۱۴۰۵ همکادر — همه حقوق محفوظ است</div>
</div>
</footer>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>
@@ -0,0 +1,48 @@
/* Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
for details on configuring this project to bundle and minify static web assets. */
a.navbar-brand {
white-space: normal;
text-align: center;
word-break: break-all;
}
a {
color: #0077cc;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.border-top {
border-top: 1px solid #e5e5e5;
}
.border-bottom {
border-bottom: 1px solid #e5e5e5;
}
.box-shadow {
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05);
}
button.accept-policy {
font-size: 1rem;
line-height: inherit;
}
.footer {
position: absolute;
bottom: 0;
width: 100%;
white-space: nowrap;
line-height: 60px;
}
@@ -0,0 +1,38 @@
@model JobsMedical.Web.Services.Recommendation
@{
var s = Model.Shift;
var (badgeClass, typeLabel) = s.ShiftType switch
{
ShiftType.Day => ("badge-day", "صبح"),
ShiftType.Evening => ("badge-evening", "عصر"),
ShiftType.Night => ("badge-night", "شب"),
_ => ("badge-oncall", "آنکال"),
};
}
<a class="card card-pad shift-card" asp-page="/Shifts/Details" asp-route-id="@s.Id">
<div class="row" style="justify-content: space-between;">
<span class="facility">@s.Facility?.Name</span>
<span class="badge @badgeClass">@typeLabel</span>
</div>
<div class="row">
@if (s.Role is not null)
{
<span class="badge badge-type">@s.Role.Name</span>
}
<span>📍 @s.Facility?.City?.Name</span>
</div>
<div class="row">📅 @JalaliDate.WeekDayName(s.Date)، @JalaliDate.ToLongDate(s.Date) — 🕐 @JalaliDate.Time(s.StartTime)</div>
@* The "why" — what makes a pattern engine trustworthy: every pick is explained. *@
<div class="rec-reasons">
@foreach (var reason in Model.Reasons)
{
<span class="rec-reason">✓ @reason</span>
}
</div>
<div class="foot">
<span class="pay">@JalaliDate.Toman(s.PayAmount)</span>
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
</div>
</a>
@@ -0,0 +1,37 @@
@model JobsMedical.Web.Models.Shift
@{
var (badgeClass, typeLabel) = Model.ShiftType switch
{
ShiftType.Day => ("badge-day", "صبح"),
ShiftType.Evening => ("badge-evening", "عصر"),
ShiftType.Night => ("badge-night", "شب"),
_ => ("badge-oncall", "آنکال"),
};
}
<a class="card card-pad shift-card" asp-page="/Shifts/Details" asp-route-id="@Model.Id">
<div class="row" style="justify-content: space-between;">
<span class="facility">@Model.Facility?.Name</span>
<span class="badge @badgeClass">@typeLabel</span>
</div>
<div class="row">
@if (Model.Role is not null)
{
<span class="badge badge-type">@Model.Role.Name</span>
}
<span>📍 @Model.Facility?.City?.Name@(Model.Facility?.District is not null ? "، " + Model.Facility.District.Name : "")</span>
@if (Model.Facility?.IsVerified == true)
{
<span class="badge badge-verified">✓ تأیید شده</span>
}
</div>
@if (Model.DistanceKm is double km)
{
<div class="row"><span class="badge badge-distance">📍 @JalaliDate.ToPersianDigits(km.ToString("0.#")) کیلومتر از شما</span></div>
}
<div class="row">📅 @JalaliDate.WeekDayName(Model.Date)، @JalaliDate.ToLongDate(Model.Date)</div>
<div class="row">🕐 @JalaliDate.Time(Model.StartTime) تا @JalaliDate.Time(Model.EndTime)</div>
<div class="foot">
<span class="pay">@JalaliDate.Toman(Model.PayAmount)</span>
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
</div>
</a>
@@ -0,0 +1,2 @@
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/dist/jquery.validate.unobtrusive.min.js"></script>
@@ -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();
}
}
}
@@ -0,0 +1,5 @@
@using JobsMedical.Web
@using JobsMedical.Web.Models
@using JobsMedical.Web.Services
@namespace JobsMedical.Web.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}