[Alerts] Customizable job alerts + Help capabilities showcase
CI/CD / CI · dotnet build (push) Successful in 1m8s
CI/CD / Deploy · hamkadr (push) Successful in 1m7s

Job alerts (هشدار شغلی): users save what they want — scope (shift/job/both), role, city, shift type, employment type, minimum pay — and get notified when an employer posts a match. New JobAlert model + AlertScope enum + DbContext (user-cascade, role set-null, IsActive index) + migration. /Me/Alerts page to create/pause/delete alerts; entry point added to the کارجو panel. NotificationService.NotifyNewShift/Job now unions preference matches with active-alert matches (deduped) so alert owners are notified on publish. Help page gains an 'امکانات همکادر' capability showcase grid (with a 'ساخت هشدار شغلی' CTA) so the page demonstrates what the app does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 18:17:56 +03:30
parent 42deac1261
commit 213faadf55
11 changed files with 1727 additions and 3 deletions
+134
View File
@@ -0,0 +1,134 @@
@page
@model JobsMedical.Web.Pages.Me.AlertsModel
@{
ViewData["Title"] = "هشدارهای شغلی";
string ScopeLabel(JobsMedical.Web.Models.AlertScope s) => s switch
{
JobsMedical.Web.Models.AlertScope.Shifts => "شیفت",
JobsMedical.Web.Models.AlertScope.Jobs => "استخدام",
_ => "شیفت و استخدام",
};
string ShiftLabel(JobsMedical.Web.Models.ShiftType t) => t switch
{
JobsMedical.Web.Models.ShiftType.Day => "صبح",
JobsMedical.Web.Models.ShiftType.Evening => "عصر",
JobsMedical.Web.Models.ShiftType.Night => "شب",
_ => "آنکال",
};
string EmpLabel(JobsMedical.Web.Models.EmploymentType t) => t switch
{
JobsMedical.Web.Models.EmploymentType.FullTime => "تمام‌وقت",
JobsMedical.Web.Models.EmploymentType.PartTime => "پاره‌وقت",
JobsMedical.Web.Models.EmploymentType.Contract => "قراردادی",
_ => "طرح",
};
}
<div class="page-head">
<div class="container">
<h1>🔎 هشدارهای شغلی</h1>
<p class="muted">بگو دنبال چه فرصتی هستی؛ هر وقت کارفرمایی آگهی متناسب ثبت کرد، فوری باخبرت می‌کنیم. <a asp-page="/Me/Index">← پنل کارجو</a></p>
</div>
</div>
<div class="container section" style="max-width:760px;">
@if (Model.Msg is not null) { <div class="alert alert-success">@Model.Msg</div> }
<div class="card card-pad" style="margin-bottom:16px;">
<h3 style="margin-top:0;">ساخت هشدار جدید</h3>
<form method="post" asp-page-handler="Create">
<div class="filter-group">
<label>نوع فرصت</label>
<select name="Scope">
<option value="0">شیفت و استخدام (هر دو)</option>
<option value="1">فقط شیفت</option>
<option value="2">فقط استخدام</option>
</select>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<div class="filter-group" style="flex:1; min-width:160px;">
<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="filter-group" style="flex:1; min-width:160px;">
<label>شهر</label>
<select name="CityId">
<option value="">هر شهری</option>
@foreach (var c in Model.Cities) { <option value="@c.Id">@c.Name</option> }
</select>
</div>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<div class="filter-group" style="flex:1; min-width:160px;">
<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="filter-group" style="flex:1; min-width:160px;">
<label>نوع همکاری (برای استخدام)</label>
<select name="EmploymentType">
<option value="">فرقی نمی‌کند</option>
<option value="0">تمام‌وقت</option>
<option value="1">پاره‌وقت</option>
<option value="2">قراردادی</option>
<option value="3">طرح</option>
</select>
</div>
</div>
<div class="filter-group">
<label>حداقل حقوق/دستمزد مورد انتظار (تومان)</label>
<input type="number" name="MinPay" min="0" step="100000" dir="ltr" placeholder="مثلاً ۲۰۰۰۰۰۰" />
<p class="muted" style="font-size:12px; margin:4px 0 0;">برای شیفت: مبلغ هر شیفت؛ برای استخدام: حقوق ماهانه. خالی = بدون محدودیت.</p>
</div>
<div class="filter-group">
<label>برچسب (اختیاری)</label>
<input type="text" name="Label" placeholder="مثلاً پرستار شب تهران" />
</div>
<button type="submit" class="btn btn-accent btn-block btn-lg">ساخت هشدار</button>
</form>
</div>
<h3>هشدارهای من (@JalaliDate.ToPersianDigits(Model.Alerts.Count.ToString()))</h3>
@if (Model.Alerts.Count == 0)
{
<div class="card empty-state">هنوز هشداری نساخته‌ای. اولین هشدار را بالا بساز تا فرصت‌ها از دستت نروند.</div>
}
else
{
foreach (var a in Model.Alerts)
{
<div class="card card-pad" style="margin-bottom:10px; @(a.IsActive ? "" : "opacity:.6;")">
<div class="row" style="display:flex; justify-content:space-between; align-items:flex-start; gap:10px;">
<div>
<strong>@(a.Label ?? "هشدار شغلی")</strong>
@if (!a.IsActive) { <span class="badge badge-type">غیرفعال</span> }
<div class="muted" style="font-size:13px; margin-top:4px;">
<span class="badge badge-type">@ScopeLabel(a.Scope)</span>
@if (a.Role is not null) { <span class="badge badge-type">@a.Role.Name</span> }
@if (a.City is not null) { <span>📍 @a.City.Name</span> }
@if (a.ShiftType is not null) { <span class="badge badge-day">@ShiftLabel(a.ShiftType.Value)</span> }
@if (a.EmploymentType is not null) { <span class="badge badge-job">@EmpLabel(a.EmploymentType.Value)</span> }
@if (a.MinPay is not null) { <span>حداقل @JalaliDate.ToPersianDigits(a.MinPay.Value.ToString("#,0")) تومان</span> }
</div>
</div>
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<form method="post" asp-page-handler="Toggle" asp-route-id="@a.Id" style="display:inline;">
<button type="submit" class="btn btn-outline" style="padding:5px 12px;">@(a.IsActive ? "توقف" : "فعال‌سازی")</button>
</form>
<form method="post" asp-page-handler="Delete" asp-route-id="@a.Id" style="display:inline;" onsubmit="return confirm('این هشدار حذف شود؟');">
<button type="submit" class="btn btn-outline" style="padding:5px 12px; color:var(--danger); border-color:var(--danger);">حذف</button>
</form>
</div>
</div>
</div>
}
}
</div>
@@ -0,0 +1,79 @@
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.Me;
/// <summary>Job alerts (هشدار شغلی) — saved searches that notify the user on a new matching listing.</summary>
[Authorize]
public class AlertsModel : PageModel
{
private readonly AppDbContext _db;
public AlertsModel(AppDbContext db) => _db = db;
public List<Role> Roles { get; private set; } = new();
public List<City> Cities { get; private set; } = new();
public List<JobAlert> Alerts { get; private set; } = new();
[TempData] public string? Msg { get; set; }
[BindProperty] public string? Label { get; set; }
[BindProperty] public AlertScope Scope { get; set; } = AlertScope.Any;
[BindProperty] public int? RoleId { get; set; }
[BindProperty] public int? CityId { get; set; }
[BindProperty] public ShiftType? ShiftType { get; set; }
[BindProperty] public EmploymentType? EmploymentType { get; set; }
[BindProperty] public long? MinPay { get; set; }
private int Uid => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public async Task OnGetAsync() => await LoadAsync();
public async Task<IActionResult> OnPostCreateAsync()
{
if (await _db.JobAlerts.CountAsync(a => a.UserId == Uid) >= 20)
{
Msg = "حداکثر تعداد هشدار شغلی ساخته شده است.";
return RedirectToPage();
}
_db.JobAlerts.Add(new JobAlert
{
UserId = Uid,
Label = string.IsNullOrWhiteSpace(Label) ? null : Label.Trim(),
Scope = Scope,
RoleId = RoleId,
CityId = CityId,
ShiftType = Scope == AlertScope.Jobs ? null : ShiftType,
EmploymentType = Scope == AlertScope.Shifts ? null : EmploymentType,
MinPay = MinPay is > 0 ? MinPay : null,
});
await _db.SaveChangesAsync();
Msg = "هشدار شغلی ساخته شد. به‌محض ثبت آگهی متناسب، باخبر می‌شوی.";
return RedirectToPage();
}
public async Task<IActionResult> OnPostToggleAsync(int id)
{
var a = await _db.JobAlerts.FirstOrDefaultAsync(x => x.Id == id && x.UserId == Uid);
if (a is not null) { a.IsActive = !a.IsActive; await _db.SaveChangesAsync(); }
return RedirectToPage();
}
public async Task<IActionResult> OnPostDeleteAsync(int id)
{
var a = await _db.JobAlerts.FirstOrDefaultAsync(x => x.Id == id && x.UserId == Uid);
if (a is not null) { _db.JobAlerts.Remove(a); await _db.SaveChangesAsync(); }
return RedirectToPage();
}
private async Task LoadAsync()
{
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();
Alerts = await _db.JobAlerts.Include(a => a.Role).Include(a => a.City)
.Where(a => a.UserId == Uid).OrderByDescending(a => a.CreatedAt).ToListAsync();
}
}
+4 -1
View File
@@ -35,7 +35,10 @@
}
</span>
</div>
<a class="btn btn-outline" asp-page="/Preferences/Index">تنظیم علاقه‌مندی‌ها</a>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<a class="btn btn-outline" asp-page="/Me/Alerts">🔎 هشدارهای شغلی</a>
<a class="btn btn-outline" asp-page="/Preferences/Index">تنظیم علاقه‌مندی‌ها</a>
</div>
</div>
@if (Model.Recommendations.Count > 0)