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