Add hiring, AI parser+admin, OTP auth, employer dashboard, profit-share pay

- Hiring (استخدام) listings: JobOpening + /Jobs browse/detail + home section
- Heuristic Persian listing-parser + admin queue (/Admin) → publish shift/job
- Phone-OTP cookie auth + visitor-history linking + profile; Admin role gate
- Employer side: self-serve facility registration, dashboard, post/manage shifts & jobs, applicants list with contact
- Compensation models: fixed / hourly / profit-share (درصدی) / negotiable / choice (به انتخاب شما); SharePercent + JalaliDate.PayLabel; parser + filter

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-03 06:26:54 +03:30
parent 2fb86a435e
commit 563a40d1f4
30 changed files with 1761 additions and 27 deletions
@@ -0,0 +1,68 @@
@page
@model JobsMedical.Web.Pages.Employer.IndexModel
@{
ViewData["Title"] = "پنل کارفرما";
string TypeLabel(FacilityType t) => t switch
{
FacilityType.Hospital => "بیمارستان",
FacilityType.Clinic => "کلینیک",
_ => "درمانگاه",
};
}
<div class="page-head">
<div class="container">
<h1>پنل مرکز درمانی</h1>
<p class="muted">شیفت‌ها و موقعیت‌های استخدامی مرکز خود را مدیریت کن.</p>
</div>
</div>
<div class="container section">
@if (Model.Facilities.Count == 0)
{
<div class="card empty-state">
<p>هنوز مرکزی ثبت نکرده‌ای.</p>
<a class="btn btn-accent btn-lg" asp-page="/Employer/RegisterFacility">ثبت مرکز درمانی</a>
</div>
}
else
{
<div class="rec-banner">
<div>
<h2 style="margin:0 0 4px;">انتشار فرصت جدید</h2>
<span style="opacity:.9; font-size:14px;">شیفت یا موقعیت استخدامی منتشر کن</span>
</div>
<div style="display:flex; gap:8px;">
<a class="btn btn-outline" asp-page="/Employer/PostShift">+ شیفت</a>
<a class="btn btn-outline" asp-page="/Employer/PostJob">+ استخدام</a>
<a class="btn btn-outline" asp-page="/Employer/RegisterFacility">+ مرکز</a>
</div>
</div>
<div class="grid grid-3">
@foreach (var c in Model.Facilities)
{
<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;">@c.Facility.Name</span>
@if (c.Facility.IsVerified)
{
<span class="badge badge-verified">✓ تأیید شده</span>
}
else
{
<span class="badge badge-type">در انتظار تأیید</span>
}
</div>
<p class="muted" style="margin:8px 0;">
@TypeLabel(c.Facility.Type) — 📍 @c.Facility.City?.Name@(c.Facility.District is not null ? "، " + c.Facility.District.Name : "")
</p>
<div class="info-row"><span class="k">شیفت‌های باز</span><span class="v">@JalaliDate.ToPersianDigits(c.OpenShifts.ToString())</span></div>
<div class="info-row"><span class="k">موقعیت‌های استخدامی</span><span class="v">@JalaliDate.ToPersianDigits(c.OpenJobs.ToString())</span></div>
<div class="info-row"><span class="k">اعلام تمایل‌ها</span><span class="v" style="color:var(--accent)">@JalaliDate.ToPersianDigits(c.Applicants.ToString())</span></div>
<a class="btn btn-outline btn-block" style="margin-top:12px;" asp-page="/Employer/Listings" asp-route-facilityId="@c.Facility.Id">مدیریت آگهی‌ها و متقاضیان</a>
</div>
}
</div>
}
</div>
@@ -0,0 +1,46 @@
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.Employer;
[Authorize]
public class IndexModel : PageModel
{
private readonly AppDbContext _db;
public IndexModel(AppDbContext db) => _db = db;
public record FacilityCard(Facility Facility, int OpenShifts, int OpenJobs, int Applicants);
public List<FacilityCard> Facilities { get; private set; } = new();
public async Task OnGetAsync()
{
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var facilities = await _db.Facilities
.Include(f => f.City).Include(f => f.District)
.Where(f => f.OwnerUserId == userId)
.OrderBy(f => f.Name).ToListAsync();
foreach (var f in facilities)
{
var openShifts = await _db.Shifts.CountAsync(s =>
s.FacilityId == f.Id && s.Status == ShiftStatus.Open && s.Date >= today);
var openJobs = await _db.JobOpenings.CountAsync(j =>
j.FacilityId == f.Id && j.Status == ShiftStatus.Open);
var shiftIds = await _db.Shifts.Where(s => s.FacilityId == f.Id).Select(s => s.Id).ToListAsync();
var jobIds = await _db.JobOpenings.Where(j => j.FacilityId == f.Id).Select(j => j.Id).ToListAsync();
var applicants = await _db.InterestEvents.CountAsync(e =>
e.EventType == InterestEventType.Apply &&
((e.ShiftId != null && shiftIds.Contains(e.ShiftId.Value)) ||
(e.JobOpeningId != null && jobIds.Contains(e.JobOpeningId.Value))));
Facilities.Add(new FacilityCard(f, openShifts, openJobs, applicants));
}
}
}
@@ -0,0 +1,128 @@
@page
@model JobsMedical.Web.Pages.Employer.ListingsModel
@{
ViewData["Title"] = "مدیریت آگهی‌ها";
string StatusLabel(ShiftStatus s) => s switch
{
ShiftStatus.Open => "باز",
ShiftStatus.Filled => "پر شده",
ShiftStatus.Expired => "منقضی",
_ => "لغو شده",
};
}
<div class="page-head">
<div class="container">
<h1>مدیریت آگهی‌ها — @Model.Facility?.Name</h1>
<p class="muted">
<a asp-page="/Employer/Index">← بازگشت به پنل</a>
· <a asp-page="/Employer/PostShift">+ شیفت</a>
· <a asp-page="/Employer/PostJob">+ استخدام</a>
</p>
</div>
</div>
<div class="container section">
<h2 style="font-size:20px;">شیفت‌ها</h2>
@if (Model.Shifts.Count == 0)
{
<div class="card empty-state">هنوز شیفتی منتشر نکرده‌ای.</div>
}
else
{
foreach (var row in Model.Shifts)
{
var s = row.Shift;
<div class="card card-pad" style="margin-bottom:12px;">
<div class="row" style="display:flex; justify-content:space-between; align-items:center;">
<strong>@s.Role?.Name — @JalaliDate.ToLongDate(s.Date) — @JalaliDate.Time(s.StartTime)</strong>
<span class="badge @(s.Status == ShiftStatus.Open ? "badge-verified" : "badge-type")">@StatusLabel(s.Status)</span>
</div>
<p class="muted" style="margin:6px 0;">@JalaliDate.Toman(s.PayAmount)</p>
<div style="border-top:1px solid var(--line); padding-top:10px; margin-top:6px;">
<strong style="font-size:14px;">متقاضیان (@JalaliDate.ToPersianDigits((row.Applicants.Count + row.Guests).ToString()))</strong>
@if (row.Applicants.Count == 0 && row.Guests == 0)
{
<p class="muted" style="font-size:13px; margin:6px 0 0;">هنوز کسی اعلام تمایل نکرده.</p>
}
else
{
<ul style="margin:6px 0 0; padding-inline-start:18px; font-size:13.5px;">
@foreach (var a in row.Applicants)
{
<li>@(a.Name ?? "کاربر") — <span dir="ltr">@JalaliDate.ToPersianDigits(a.Phone)</span></li>
}
@if (row.Guests > 0)
{
<li class="muted">@JalaliDate.ToPersianDigits(row.Guests.ToString()) بازدیدکننده‌ی مهمان (بدون ورود)</li>
}
</ul>
}
</div>
<div style="display:flex; gap:8px; margin-top:12px;">
@if (s.Status == ShiftStatus.Open)
{
<form method="post"><button asp-page-handler="CloseShift" asp-route-id="@s.Id" class="btn btn-outline" style="padding:6px 12px;">بستن (پر شد)</button></form>
}
else
{
<form method="post"><button asp-page-handler="ReopenShift" asp-route-id="@s.Id" class="btn btn-outline" style="padding:6px 12px;">بازگشایی</button></form>
}
<form method="post" onsubmit="return confirm('این شیفت حذف شود؟');"><button asp-page-handler="DeleteShift" asp-route-id="@s.Id" class="btn btn-outline" style="padding:6px 12px; color:var(--danger); border-color:var(--danger);">حذف</button></form>
</div>
</div>
}
}
<h2 style="font-size:20px; margin-top:30px;">موقعیت‌های استخدامی</h2>
@if (Model.Jobs.Count == 0)
{
<div class="card empty-state">هنوز موقعیتی منتشر نکرده‌ای.</div>
}
else
{
foreach (var row in Model.Jobs)
{
var j = row.Job;
<div class="card card-pad" style="margin-bottom:12px;">
<div class="row" style="display:flex; justify-content:space-between; align-items:center;">
<strong>@j.Title — @j.Role?.Name</strong>
<span class="badge @(j.Status == ShiftStatus.Open ? "badge-verified" : "badge-type")">@StatusLabel(j.Status)</span>
</div>
<div style="border-top:1px solid var(--line); padding-top:10px; margin-top:8px;">
<strong style="font-size:14px;">متقاضیان (@JalaliDate.ToPersianDigits((row.Applicants.Count + row.Guests).ToString()))</strong>
@if (row.Applicants.Count == 0 && row.Guests == 0)
{
<p class="muted" style="font-size:13px; margin:6px 0 0;">هنوز کسی اعلام تمایل نکرده.</p>
}
else
{
<ul style="margin:6px 0 0; padding-inline-start:18px; font-size:13.5px;">
@foreach (var a in row.Applicants)
{
<li>@(a.Name ?? "کاربر") — <span dir="ltr">@JalaliDate.ToPersianDigits(a.Phone)</span></li>
}
@if (row.Guests > 0)
{
<li class="muted">@JalaliDate.ToPersianDigits(row.Guests.ToString()) بازدیدکننده‌ی مهمان</li>
}
</ul>
}
</div>
<div style="display:flex; gap:8px; margin-top:12px;">
@if (j.Status == ShiftStatus.Open)
{
<form method="post"><button asp-page-handler="CloseJob" asp-route-id="@j.Id" class="btn btn-outline" style="padding:6px 12px;">بستن</button></form>
}
else
{
<form method="post"><button asp-page-handler="ReopenJob" asp-route-id="@j.Id" class="btn btn-outline" style="padding:6px 12px;">بازگشایی</button></form>
}
<form method="post" onsubmit="return confirm('این موقعیت حذف شود؟');"><button asp-page-handler="DeleteJob" asp-route-id="@j.Id" class="btn btn-outline" style="padding:6px 12px; color:var(--danger); border-color:var(--danger);">حذف</button></form>
</div>
</div>
}
}
</div>
@@ -0,0 +1,120 @@
using System.Security.Claims;
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.Employer;
[Authorize]
public class ListingsModel : PageModel
{
private readonly AppDbContext _db;
public ListingsModel(AppDbContext db) => _db = db;
public record Applicant(string? Name, string Phone, DateTime When);
public record ShiftRow(Shift Shift, List<Applicant> Applicants, int Guests);
public record JobRow(JobOpening Job, List<Applicant> Applicants, int Guests);
public Facility? Facility { get; private set; }
public List<ShiftRow> Shifts { get; private set; } = new();
public List<JobRow> Jobs { get; private set; } = new();
[BindProperty(SupportsGet = true)] public int FacilityId { get; set; }
public async Task<IActionResult> OnGetAsync()
{
if (!await OwnsAsync(FacilityId)) return Forbid();
await LoadAsync();
return Page();
}
// --- Lifecycle actions (all ownership-checked) ---
public Task<IActionResult> OnPostCloseShiftAsync(int id) => MutateShift(id, s => s.Status = ShiftStatus.Filled);
public Task<IActionResult> OnPostReopenShiftAsync(int id) => MutateShift(id, s => s.Status = ShiftStatus.Open);
public Task<IActionResult> OnPostDeleteShiftAsync(int id) => MutateShift(id, s => _db.Shifts.Remove(s));
public Task<IActionResult> OnPostCloseJobAsync(int id) => MutateJob(id, j => j.Status = ShiftStatus.Filled);
public Task<IActionResult> OnPostReopenJobAsync(int id) => MutateJob(id, j => j.Status = ShiftStatus.Open);
public Task<IActionResult> OnPostDeleteJobAsync(int id) => MutateJob(id, j => _db.JobOpenings.Remove(j));
private async Task<IActionResult> MutateShift(int id, Action<Shift> apply)
{
var s = await _db.Shifts.FirstOrDefaultAsync(x => x.Id == id);
if (s is null || !await OwnsAsync(s.FacilityId)) return Forbid();
apply(s);
await _db.SaveChangesAsync();
return RedirectToPage(new { FacilityId = s.FacilityId });
}
private async Task<IActionResult> MutateJob(int id, Action<JobOpening> apply)
{
var j = await _db.JobOpenings.FirstOrDefaultAsync(x => x.Id == id);
if (j is null || !await OwnsAsync(j.FacilityId)) return Forbid();
apply(j);
await _db.SaveChangesAsync();
return RedirectToPage(new { FacilityId = j.FacilityId });
}
private async Task<bool> OwnsAsync(int facilityId)
{
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
return await _db.Facilities.AnyAsync(f => f.Id == facilityId && f.OwnerUserId == userId);
}
private async Task LoadAsync()
{
Facility = await _db.Facilities.Include(f => f.City).FirstOrDefaultAsync(f => f.Id == FacilityId);
var shifts = await _db.Shifts.Include(s => s.Role)
.Where(s => s.FacilityId == FacilityId)
.OrderByDescending(s => s.Date).ToListAsync();
var jobs = await _db.JobOpenings.Include(j => j.Role)
.Where(j => j.FacilityId == FacilityId)
.OrderByDescending(j => j.CreatedAt).ToListAsync();
// Pull all "Apply" events for these listings, then resolve applicant identities.
var shiftIds = shifts.Select(s => s.Id).ToList();
var jobIds = jobs.Select(j => j.Id).ToList();
var events = await _db.InterestEvents
.Where(e => e.EventType == InterestEventType.Apply &&
((e.ShiftId != null && shiftIds.Contains(e.ShiftId.Value)) ||
(e.JobOpeningId != null && jobIds.Contains(e.JobOpeningId.Value))))
.ToListAsync();
var visitorIds = events.Select(e => e.VisitorId).Distinct().ToList();
var visitorUser = await _db.Visitors.Where(v => visitorIds.Contains(v.Id))
.ToDictionaryAsync(v => v.Id, v => v.UserId);
var userIds = visitorUser.Values.Where(u => u != null).Select(u => u!.Value).Distinct().ToList();
var users = await _db.Users.Where(u => userIds.Contains(u.Id)).ToDictionaryAsync(u => u.Id);
(List<Applicant> applicants, int guests) Resolve(IEnumerable<InterestEvent> evs)
{
var applicants = new List<Applicant>();
var guests = 0;
var seen = new HashSet<int>();
foreach (var e in evs.OrderByDescending(e => e.CreatedAt))
{
var uid = visitorUser.GetValueOrDefault(e.VisitorId);
if (uid is int id && users.TryGetValue(id, out var u))
{
if (seen.Add(id)) applicants.Add(new Applicant(u.FullName, u.Phone, e.CreatedAt));
}
else guests++;
}
return (applicants, guests);
}
Shifts = shifts.Select(s =>
{
var (a, g) = Resolve(events.Where(e => e.ShiftId == s.Id));
return new ShiftRow(s, a, g);
}).ToList();
Jobs = jobs.Select(j =>
{
var (a, g) = Resolve(events.Where(e => e.JobOpeningId == j.Id));
return new JobRow(j, a, g);
}).ToList();
}
}
@@ -0,0 +1,75 @@
@page
@model JobsMedical.Web.Pages.Employer.PostJobModel
@{
ViewData["Title"] = "انتشار موقعیت استخدامی";
}
<div class="page-head"><div class="container"><h1>انتشار موقعیت استخدامی</h1></div></div>
<div class="container section" style="max-width:560px;">
@if (Model.Error is not null)
{
<div class="alert" style="background:#fdeaea; color:var(--danger);">@Model.Error</div>
}
@if (Model.MyFacilities.Count == 0)
{
<div class="card empty-state">
ابتدا یک مرکز ثبت کن.
<a class="btn btn-accent" asp-page="/Employer/RegisterFacility">ثبت مرکز</a>
</div>
}
else
{
<form method="post" class="card card-pad">
<div class="filter-group">
<label>مرکز درمانی</label>
<select name="FacilityId">
@foreach (var f in Model.MyFacilities)
{
<option value="@f.Id">@f.Name — @f.City?.Name</option>
}
</select>
</div>
<div class="filter-group">
<label>عنوان موقعیت</label>
<input type="text" name="Title" value="@Model.Title" placeholder="مثلاً استخدام پرستار بخش اورژانس" />
</div>
<div class="filter-group">
<label>نقش</label>
<select name="RoleId">
@foreach (var r in Model.Roles)
{
<option value="@r.Id">@r.Name</option>
}
</select>
</div>
<div class="filter-group">
<label>نوع همکاری</label>
<select name="EmploymentType">
<option value="0">تمام‌وقت</option>
<option value="1">پاره‌وقت</option>
<option value="2">قراردادی</option>
<option value="3">طرح</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 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>
<div class="filter-group">
<label>شرایط احراز</label>
<textarea name="Requirements" rows="2">@Model.Requirements</textarea>
</div>
<button type="submit" class="btn btn-accent btn-block btn-lg">انتشار موقعیت</button>
</form>
}
</div>
@@ -0,0 +1,67 @@
using System.Security.Claims;
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.Employer;
[Authorize]
public class PostJobModel : PageModel
{
private readonly AppDbContext _db;
public PostJobModel(AppDbContext db) => _db = db;
public List<Facility> MyFacilities { get; private set; } = new();
public List<Role> Roles { get; private set; } = new();
public string? Error { get; private set; }
[BindProperty] public int FacilityId { get; set; }
[BindProperty] public int RoleId { get; set; }
[BindProperty] public string Title { get; set; } = "";
[BindProperty] public EmploymentType EmploymentType { get; set; }
[BindProperty] public long? SalaryMin { get; set; }
[BindProperty] public long? SalaryMax { get; set; }
[BindProperty] public bool Negotiable { get; set; }
[BindProperty] public string? Description { get; set; }
[BindProperty] public string? Requirements { get; set; }
public async Task OnGetAsync() => await LoadListsAsync();
public async Task<IActionResult> OnPostAsync()
{
await LoadListsAsync();
if (!MyFacilities.Any(f => f.Id == FacilityId))
{
Error = "این مرکز متعلق به شما نیست.";
return Page();
}
if (string.IsNullOrWhiteSpace(Title)) { Error = "عنوان موقعیت الزامی است."; return Page(); }
_db.JobOpenings.Add(new JobOpening
{
FacilityId = FacilityId,
RoleId = RoleId,
Title = Title.Trim(),
EmploymentType = EmploymentType,
SalaryMin = Negotiable ? null : SalaryMin,
SalaryMax = Negotiable ? null : SalaryMax,
Description = Description,
Requirements = Requirements,
Status = ShiftStatus.Open,
Source = ShiftSource.Direct,
});
await _db.SaveChangesAsync();
return RedirectToPage("/Employer/Index");
}
private async Task LoadListsAsync()
{
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
MyFacilities = await _db.Facilities.Include(f => f.City)
.Where(f => f.OwnerUserId == userId).OrderBy(f => f.Name).ToListAsync();
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
}
}
@@ -0,0 +1,82 @@
@page
@model JobsMedical.Web.Pages.Employer.PostShiftModel
@{
ViewData["Title"] = "انتشار شیفت";
}
<div class="page-head"><div class="container"><h1>انتشار شیفت جدید</h1></div></div>
<div class="container section" style="max-width:560px;">
@if (Model.Error is not null)
{
<div class="alert" style="background:#fdeaea; color:var(--danger);">@Model.Error</div>
}
@if (Model.MyFacilities.Count == 0)
{
<div class="card empty-state">
ابتدا یک مرکز ثبت کن.
<a class="btn btn-accent" asp-page="/Employer/RegisterFacility">ثبت مرکز</a>
</div>
}
else
{
<form method="post" class="card card-pad">
<div class="filter-group">
<label>مرکز درمانی</label>
<select name="FacilityId">
@foreach (var f in Model.MyFacilities)
{
<option value="@f.Id">@f.Name — @f.City?.Name</option>
}
</select>
</div>
<div class="filter-group">
<label>نقش مورد نیاز</label>
<select name="RoleId">
@foreach (var r in Model.Roles)
{
<option value="@r.Id">@r.Name</option>
}
</select>
</div>
<div class="filter-group">
<label>تاریخ (میلادی)</label>
<input type="date" name="Date" value="@Model.Date.ToString("yyyy-MM-dd")" dir="ltr" />
</div>
<div class="filter-group">
<label>نوع شیفت</label>
<select name="ShiftType">
<option value="0">صبح</option>
<option value="1">عصر</option>
<option value="2">شب</option>
<option value="3">آنکال</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" style="display:flex; gap:8px;">
<div style="flex:1;">
<label>مبلغ مقطوع هر شیفت (تومان)</label>
<input type="number" name="PayAmount" value="@Model.PayAmount" dir="ltr" />
</div>
<div style="flex:1;">
<label>یا سهم درآمد (٪)</label>
<input type="number" name="SharePercent" value="@Model.SharePercent" min="1" max="100" dir="ltr" placeholder="مثلاً ۵۰" />
</div>
</div>
<p class="muted" style="font-size:12px;">می‌توانی فقط مبلغ، فقط درصد، یا هر دو را وارد کنی؛ اگر هر دو پر شود به کاربر «به انتخاب شما» نمایش داده می‌شود.</p>
<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" class="btn btn-accent btn-block btn-lg">انتشار شیفت</button>
</form>
}
</div>
@@ -0,0 +1,77 @@
using System.Security.Claims;
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.Employer;
[Authorize]
public class PostShiftModel : PageModel
{
private readonly AppDbContext _db;
public PostShiftModel(AppDbContext db) => _db = db;
public List<Facility> MyFacilities { get; private set; } = new();
public List<Role> Roles { get; private set; } = new();
public string? Error { get; private set; }
[BindProperty] public int FacilityId { get; set; }
[BindProperty] public int RoleId { get; set; }
[BindProperty] public DateOnly Date { 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 int? SharePercent { get; set; } // سهم درآمد (٪)
[BindProperty] public bool Negotiable { get; set; }
[BindProperty] public string? Description { get; set; }
public async Task OnGetAsync()
{
await LoadListsAsync();
Date = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1);
StartTime = new TimeOnly(8, 0);
EndTime = new TimeOnly(14, 0);
}
public async Task<IActionResult> OnPostAsync()
{
await LoadListsAsync();
if (!MyFacilities.Any(f => f.Id == FacilityId))
{
Error = "این مرکز متعلق به شما نیست.";
return Page();
}
var role = await _db.Roles.FindAsync(RoleId);
_db.Shifts.Add(new Shift
{
FacilityId = FacilityId,
RoleId = RoleId,
Date = Date,
StartTime = StartTime,
EndTime = EndTime,
ShiftType = ShiftType,
SpecialtyRequired = role?.Name ?? "",
Description = Description,
PayType = Negotiable ? PayType.Negotiable
: (PayAmount is null && SharePercent is not null ? PayType.Percentage : PayType.PerShift),
PayAmount = Negotiable ? null : PayAmount,
SharePercent = Negotiable ? null : SharePercent,
Status = ShiftStatus.Open,
Source = ShiftSource.Direct, // posted directly by the facility
});
await _db.SaveChangesAsync();
return RedirectToPage("/Employer/Index");
}
private async Task LoadListsAsync()
{
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
MyFacilities = await _db.Facilities.Include(f => f.City)
.Where(f => f.OwnerUserId == userId).OrderBy(f => f.Name).ToListAsync();
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
}
}
@@ -0,0 +1,67 @@
@page
@model JobsMedical.Web.Pages.Employer.RegisterFacilityModel
@{
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;">
@if (Model.Error is not null)
{
<div class="alert" style="background:#fdeaea; color:var(--danger);">@Model.Error</div>
}
<form method="post" class="card card-pad">
<div class="filter-group">
<label>نام مرکز *</label>
<input type="text" name="Name" value="@Model.Name" placeholder="مثلاً بیمارستان مهر" />
</div>
<div class="filter-group">
<label>نوع مرکز</label>
<select name="Type">
<option value="0">بیمارستان</option>
<option value="1">کلینیک</option>
<option value="2">درمانگاه</option>
</select>
</div>
<div class="filter-group">
<label>شهر *</label>
<select name="CityId">
<option value="0">انتخاب کنید…</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">
<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>
<input type="text" name="Address" value="@Model.Address" />
</div>
<div class="filter-group" style="display:flex; gap:8px;">
<div style="flex:1;"><label>تلفن</label><input type="tel" name="Phone" value="@Model.Phone" dir="ltr" /></div>
<div style="flex:1;"><label>شناسه بله</label><input type="text" name="BaleId" value="@Model.BaleId" dir="ltr" /></div>
</div>
<div class="filter-group" style="display:flex; gap:8px;">
<div style="flex:1;"><label>عرض جغرافیایی (اختیاری)</label><input type="number" step="any" name="Lat" value="@Model.Lat" dir="ltr" /></div>
<div style="flex:1;"><label>طول جغرافیایی (اختیاری)</label><input type="number" step="any" name="Lng" value="@Model.Lng" dir="ltr" /></div>
</div>
<p class="muted" style="font-size:12px;">مختصات برای نمایش در فیلتر «نزدیک من» استفاده می‌شود.</p>
<button type="submit" class="btn btn-accent btn-block btn-lg">ثبت مرکز و ورود به پنل</button>
</form>
</div>
@@ -0,0 +1,84 @@
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.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Employer;
[Authorize]
public class RegisterFacilityModel : PageModel
{
private readonly AppDbContext _db;
public RegisterFacilityModel(AppDbContext db) => _db = db;
public List<City> Cities { get; private set; } = new();
public List<District> Districts { get; private set; } = new();
[BindProperty] public string Name { get; set; } = "";
[BindProperty] public FacilityType Type { get; set; }
[BindProperty] public int CityId { get; set; }
[BindProperty] public int? DistrictId { get; set; }
[BindProperty] public string? Address { get; set; }
[BindProperty] public string? Phone { get; set; }
[BindProperty] public string? BaleId { get; set; }
[BindProperty] public double? Lat { get; set; }
[BindProperty] public double? Lng { get; set; }
public string? Error { get; private set; }
public async Task OnGetAsync() => await LoadListsAsync();
public async Task<IActionResult> OnPostAsync()
{
await LoadListsAsync();
if (string.IsNullOrWhiteSpace(Name) || CityId == 0)
{
Error = "نام مرکز و شهر الزامی است.";
return Page();
}
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var facility = new Facility
{
Name = Name.Trim(),
Type = Type,
CityId = CityId,
DistrictId = DistrictId,
Address = Address?.Trim(),
Phone = Phone?.Trim(),
BaleId = BaleId?.Trim(),
Lat = Lat,
Lng = Lng,
OwnerUserId = userId,
IsVerified = false, // platform verifies later
};
_db.Facilities.Add(facility);
// Promote the user to FacilityAdmin (keep Admin if already admin) and refresh the cookie.
var user = await _db.Users.FindAsync(userId);
if (user is not null && user.Role == UserRole.Doctor)
{
user.Role = UserRole.FacilityAdmin;
await _db.SaveChangesAsync();
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
AuthHelper.BuildPrincipal(user));
}
else
{
await _db.SaveChangesAsync();
}
return RedirectToPage("/Employer/Index");
}
private async Task LoadListsAsync()
{
Cities = await _db.Cities.OrderByDescending(c => c.IsActive).ThenBy(c => c.Name).ToListAsync();
Districts = await _db.Districts.Where(d => d.IsActive).OrderBy(d => d.Name).ToListAsync();
}
}