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,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>