Initial commit — Hamkadr (همکادر) healthcare-staffing marketplace

ASP.NET Core 10 Razor Pages + PostgreSQL/EF Core. RTL Persian, Jalali dates, self-hosted Vazirmatn, teal/coral brand.

Features:
- Shift listings: browse/filter (city, district, role, type, pay), weekly Jalali calendar, detail + interest handoff, near-me distance sort
- Hiring (استخدام) listings with employment type + salary range
- Pattern-engine recommendations + anonymous interest tracking (visitor cookie)
- Heuristic Persian listing-parser + admin queue (raw channel post → shift/job)
- Phone-OTP cookie auth + visitor-history linking + profile

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-03 01:43:55 +03:30
commit 2fb86a435e
150 changed files with 90993 additions and 0 deletions
@@ -0,0 +1,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();
}