[Alerts] Customizable job alerts + Help capabilities showcase
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:
@@ -25,6 +25,7 @@ public class AppDbContext : DbContext
|
|||||||
public DbSet<Notification> Notifications => Set<Notification>();
|
public DbSet<Notification> Notifications => Set<Notification>();
|
||||||
public DbSet<Report> Reports => Set<Report>();
|
public DbSet<Report> Reports => Set<Report>();
|
||||||
public DbSet<FacilityDocument> FacilityDocuments => Set<FacilityDocument>();
|
public DbSet<FacilityDocument> FacilityDocuments => Set<FacilityDocument>();
|
||||||
|
public DbSet<JobAlert> JobAlerts => Set<JobAlert>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder b)
|
protected override void OnModelCreating(ModelBuilder b)
|
||||||
{
|
{
|
||||||
@@ -83,6 +84,15 @@ public class AppDbContext : DbContext
|
|||||||
.HasOne(d => d.Facility).WithMany(f => f.Documents)
|
.HasOne(d => d.Facility).WithMany(f => f.Documents)
|
||||||
.HasForeignKey(d => d.FacilityId).OnDelete(DeleteBehavior.Cascade);
|
.HasForeignKey(d => d.FacilityId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
// Job alerts belong to a user; remove with the user. Don't cascade from Role.
|
||||||
|
b.Entity<JobAlert>()
|
||||||
|
.HasOne(a => a.User).WithMany()
|
||||||
|
.HasForeignKey(a => a.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
b.Entity<JobAlert>()
|
||||||
|
.HasOne(a => a.Role).WithMany()
|
||||||
|
.HasForeignKey(a => a.RoleId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
b.Entity<JobAlert>().HasIndex(a => a.IsActive);
|
||||||
|
|
||||||
// Don't delete shifts/profiles just because a Role is removed.
|
// Don't delete shifts/profiles just because a Role is removed.
|
||||||
b.Entity<Shift>()
|
b.Entity<Shift>()
|
||||||
.HasOne(s => s.Role).WithMany(r => r.Shifts)
|
.HasOne(s => s.Role).WithMany(r => r.Shifts)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class JobAlerts : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "JobAlerts",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
UserId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Label = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||||
|
Scope = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
RoleId = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
CityId = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
DistrictId = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
ShiftType = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
EmploymentType = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
MinPay = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_JobAlerts", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_JobAlerts_Cities_CityId",
|
||||||
|
column: x => x.CityId,
|
||||||
|
principalTable: "Cities",
|
||||||
|
principalColumn: "Id");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_JobAlerts_Roles_RoleId",
|
||||||
|
column: x => x.RoleId,
|
||||||
|
principalTable: "Roles",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_JobAlerts_Users_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_JobAlerts_CityId",
|
||||||
|
table: "JobAlerts",
|
||||||
|
column: "CityId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_JobAlerts_IsActive",
|
||||||
|
table: "JobAlerts",
|
||||||
|
column: "IsActive");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_JobAlerts_RoleId",
|
||||||
|
table: "JobAlerts",
|
||||||
|
column: "RoleId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_JobAlerts_UserId",
|
||||||
|
table: "JobAlerts",
|
||||||
|
column: "UserId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "JobAlerts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -438,6 +438,61 @@ namespace JobsMedical.Web.Migrations
|
|||||||
b.ToTable("InterestEvents");
|
b.ToTable("InterestEvents");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("JobsMedical.Web.Models.JobAlert", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int?>("CityId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int?>("DistrictId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("EmploymentType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<long?>("MinPay")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<int?>("RoleId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Scope")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("ShiftType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("UserId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CityId");
|
||||||
|
|
||||||
|
b.HasIndex("IsActive");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("JobAlerts");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b =>
|
modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -993,6 +1048,30 @@ namespace JobsMedical.Web.Migrations
|
|||||||
b.Navigation("Visitor");
|
b.Navigation("Visitor");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("JobsMedical.Web.Models.JobAlert", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("JobsMedical.Web.Models.City", "City")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CityId");
|
||||||
|
|
||||||
|
b.HasOne("JobsMedical.Web.Models.Role", "Role")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("JobsMedical.Web.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("City");
|
||||||
|
|
||||||
|
b.Navigation("Role");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b =>
|
modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("JobsMedical.Web.Models.Facility", "Facility")
|
b.HasOne("JobsMedical.Web.Models.Facility", "Facility")
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ public enum ListingKind
|
|||||||
Job = 1
|
Job = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Which listing types a job alert watches.</summary>
|
||||||
|
public enum AlertScope
|
||||||
|
{
|
||||||
|
Any = 0, // هر دو (شیفت و استخدام)
|
||||||
|
Shifts = 1, // فقط شیفت
|
||||||
|
Jobs = 2 // فقط استخدام
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gender. On a listing it's the requirement (Any = فرقی نمیکند); on a person it's their gender
|
/// Gender. On a listing it's the requirement (Any = فرقی نمیکند); on a person it's their gender
|
||||||
/// (Any = unspecified).
|
/// (Any = unspecified).
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A saved job alert ("هشدار شغلی") — the user describes what they're after (kind, role, city,
|
||||||
|
/// shift type, employment type, minimum pay). When an employer publishes a matching shift/job,
|
||||||
|
/// the matching engine notifies the owner. A user can keep several alerts; each can be paused.
|
||||||
|
/// </summary>
|
||||||
|
public class JobAlert
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int UserId { get; set; }
|
||||||
|
public User User { get; set; } = null!;
|
||||||
|
|
||||||
|
[MaxLength(120)] public string? Label { get; set; } // optional friendly name
|
||||||
|
|
||||||
|
public AlertScope Scope { get; set; } = AlertScope.Any;
|
||||||
|
|
||||||
|
public int? RoleId { get; set; } // null = any role
|
||||||
|
public Role? Role { get; set; }
|
||||||
|
|
||||||
|
public int? CityId { get; set; } // null = any city
|
||||||
|
public City? City { get; set; }
|
||||||
|
|
||||||
|
public int? DistrictId { get; set; } // null = any district
|
||||||
|
|
||||||
|
public ShiftType? ShiftType { get; set; } // for shifts; null = any
|
||||||
|
public EmploymentType? EmploymentType { get; set; } // for jobs; null = any
|
||||||
|
|
||||||
|
/// <summary>Minimum acceptable pay (تومان): per-shift amount for shifts, monthly salary for jobs.</summary>
|
||||||
|
public long? MinPay { get; set; }
|
||||||
|
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -23,6 +23,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 style="margin:6px 0 12px;">امکانات همکادر</h2>
|
||||||
|
<div class="grid grid-3" style="margin-bottom:18px;">
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div style="font-size:26px;">🗓️</div>
|
||||||
|
<strong>شیفت و استخدام</strong>
|
||||||
|
<p class="muted" style="font-size:13px; margin:6px 0 0;">فرصتهای شیفت کاری و موقعیتهای استخدامی کادر درمان، یکجا.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div style="font-size:26px;">🎯</div>
|
||||||
|
<strong>پیشنهاد هوشمند</strong>
|
||||||
|
<p class="muted" style="font-size:13px; margin:6px 0 0;">بر اساس نقش، شهر و علاقهمندیات، بهترین فرصتها را میبینی.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div style="font-size:26px;">📍</div>
|
||||||
|
<strong>نزدیک من</strong>
|
||||||
|
<p class="muted" style="font-size:13px; margin:6px 0 0;">فرصتها را بر اساس فاصله از موقعیت فعلیات مرتب کن.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card card-pad" style="border:1px solid var(--accent);">
|
||||||
|
<div style="font-size:26px;">🔎</div>
|
||||||
|
<strong>هشدار شغلی</strong>
|
||||||
|
<p class="muted" style="font-size:13px; margin:6px 0 8px;">بگو دنبال چه فرصتی هستی؛ تا آگهی متناسب ثبت شد، فوری باخبر شو.</p>
|
||||||
|
<a class="btn btn-accent" style="padding:6px 14px;" asp-page="/Me/Alerts">ساخت هشدار شغلی</a>
|
||||||
|
</div>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div style="font-size:26px;">🔔</div>
|
||||||
|
<strong>اعلان زنده</strong>
|
||||||
|
<p class="muted" style="font-size:13px; margin:6px 0 0;">اعلان درونبرنامهای که در ایران بدون سرویسهای خارجی کار میکند.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div style="font-size:26px;">✓</div>
|
||||||
|
<strong>مراکز تأییدشده</strong>
|
||||||
|
<p class="muted" style="font-size:13px; margin:6px 0 0;">نشان «تأیید شده» روی مراکزی که مدارکشان بررسی شده است.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div style="font-size:26px;">📲</div>
|
||||||
|
<strong>نصب بهصورت اپ (PWA)</strong>
|
||||||
|
<p class="muted" style="font-size:13px; margin:6px 0 0;">روی اندروید، iOS، ویندوز و وب نصب میشود. <a asp-page="/Download">دریافت اپ</a></p>
|
||||||
|
</div>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div style="font-size:26px;">🛡️</div>
|
||||||
|
<strong>گزارش و شکایت</strong>
|
||||||
|
<p class="muted" style="font-size:13px; margin:6px 0 0;">آگهی نادرست یا مرکز متخلف را گزارش کن تا بررسی شود.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card card-pad legal" style="margin-bottom:14px;">
|
<div class="card card-pad legal" style="margin-bottom:14px;">
|
||||||
<h2>شروع سریع (۳ گام)</h2>
|
<h2>شروع سریع (۳ گام)</h2>
|
||||||
<ol style="padding-inline-start:20px; line-height:2;">
|
<ol style="padding-inline-start:20px; line-height:2;">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,10 @@
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
@if (Model.Recommendations.Count > 0)
|
@if (Model.Recommendations.Count > 0)
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ public class NotificationService
|
|||||||
.FirstOrDefaultAsync(x => x.Id == shiftId);
|
.FirstOrDefaultAsync(x => x.Id == shiftId);
|
||||||
if (s is null) return;
|
if (s is null) return;
|
||||||
|
|
||||||
var users = await MatchingUserIdsAsync(s.RoleId, s.Facility.CityId, s.ShiftType);
|
var prefUsers = await MatchingUserIdsAsync(s.RoleId, s.Facility.CityId, s.ShiftType);
|
||||||
|
var alertUsers = await ShiftAlertUserIdsAsync(s);
|
||||||
|
var users = prefUsers.Union(alertUsers).Distinct().ToList();
|
||||||
var title = $"شیفت جدید: {s.Role.Name}";
|
var title = $"شیفت جدید: {s.Role.Name}";
|
||||||
var body = $"{s.Facility.Name} — {JalaliDate.WeekDayName(s.Date)} {JalaliDate.Time(s.StartTime)}";
|
var body = $"{s.Facility.Name} — {JalaliDate.WeekDayName(s.Date)} {JalaliDate.Time(s.StartTime)}";
|
||||||
await AddAsync(users, title, body, $"/Shifts/Details/{s.Id}");
|
await AddAsync(users, title, body, $"/Shifts/Details/{s.Id}");
|
||||||
@@ -57,7 +59,9 @@ public class NotificationService
|
|||||||
.FirstOrDefaultAsync(x => x.Id == jobId);
|
.FirstOrDefaultAsync(x => x.Id == jobId);
|
||||||
if (j is null) return;
|
if (j is null) return;
|
||||||
|
|
||||||
var users = await MatchingUserIdsAsync(j.RoleId, j.Facility.CityId, null);
|
var prefUsers = await MatchingUserIdsAsync(j.RoleId, j.Facility.CityId, null);
|
||||||
|
var alertUsers = await JobAlertUserIdsAsync(j);
|
||||||
|
var users = prefUsers.Union(alertUsers).Distinct().ToList();
|
||||||
await AddAsync(users, $"استخدام جدید: {j.Title}", j.Facility.Name, $"/Jobs/Details/{j.Id}");
|
await AddAsync(users, $"استخدام جدید: {j.Title}", j.Facility.Name, $"/Jobs/Details/{j.Id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +79,36 @@ public class NotificationService
|
|||||||
return await q.Distinct().ToListAsync();
|
return await q.Distinct().ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Owners of active job alerts that match this shift.</summary>
|
||||||
|
private async Task<List<int>> ShiftAlertUserIdsAsync(Shift s)
|
||||||
|
{
|
||||||
|
var cityId = s.Facility.CityId;
|
||||||
|
var districtId = s.Facility.DistrictId;
|
||||||
|
return await _db.JobAlerts.Where(a => a.IsActive
|
||||||
|
&& a.Scope != AlertScope.Jobs
|
||||||
|
&& (a.RoleId == null || a.RoleId == s.RoleId)
|
||||||
|
&& (a.CityId == null || a.CityId == cityId)
|
||||||
|
&& (a.DistrictId == null || a.DistrictId == districtId)
|
||||||
|
&& (a.ShiftType == null || a.ShiftType == s.ShiftType)
|
||||||
|
&& (a.MinPay == null || (s.PayAmount != null && s.PayAmount >= a.MinPay)))
|
||||||
|
.Select(a => a.UserId).Distinct().ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Owners of active job alerts that match this hiring opening.</summary>
|
||||||
|
private async Task<List<int>> JobAlertUserIdsAsync(JobOpening j)
|
||||||
|
{
|
||||||
|
var cityId = j.Facility.CityId;
|
||||||
|
var districtId = j.Facility.DistrictId;
|
||||||
|
return await _db.JobAlerts.Where(a => a.IsActive
|
||||||
|
&& a.Scope != AlertScope.Shifts
|
||||||
|
&& (a.RoleId == null || a.RoleId == j.RoleId)
|
||||||
|
&& (a.CityId == null || a.CityId == cityId)
|
||||||
|
&& (a.DistrictId == null || a.DistrictId == districtId)
|
||||||
|
&& (a.EmploymentType == null || a.EmploymentType == j.EmploymentType)
|
||||||
|
&& (a.MinPay == null || ((j.SalaryMax ?? j.SalaryMin) != null && (j.SalaryMax ?? j.SalaryMin) >= a.MinPay)))
|
||||||
|
.Select(a => a.UserId).Distinct().ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task AddAsync(List<int> userIds, string title, string? body, string url)
|
private async Task AddAsync(List<int> userIds, string title, string? body, string url)
|
||||||
{
|
{
|
||||||
if (userIds.Count == 0) return;
|
if (userIds.Count == 0) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user