[Applications] Applicant pipeline: employer accept/reject + status to applicant
CI/CD / CI · dotnet build (push) Successful in 43s
CI/CD / Deploy · hamkadr (push) Successful in 43s

InterestEvent gains a Status (ApplicationStatus: Interested→Accepted/Rejected; migration, default Interested). Employer/Listings shows each applicant's status with پذیرفتن/رد buttons (ownership-checked handlers update the status and notify the applicant via bell/SSE/push linking to the listing). The کارجو panel (/Me) now shows a status badge (در انتظار بررسی / پذیرفته شد / رد شد) on each applied shift/job. Reusable _ApplicantRow partial for the employer list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 21:27:53 +03:30
parent 60c1997642
commit 167d263560
10 changed files with 1373 additions and 7 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class ApplicationStatus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Status",
table: "InterestEvents",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Status",
table: "InterestEvents");
}
}
}
@@ -438,6 +438,9 @@ namespace JobsMedical.Web.Migrations
b.Property<int?>("ShiftId")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("VisitorId")
.IsRequired()
.HasColumnType("character varying(36)");
@@ -31,5 +31,9 @@ public class InterestEvent
public InterestEventType EventType { get; set; }
/// <summary>For Apply events: the application status the employer/applicant sees
/// (Interested = new/pending → Accepted/Rejected by the employer, Withdrawn by the applicant).</summary>
public ApplicationStatus Status { get; set; } = ApplicationStatus.Interested;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -51,7 +51,7 @@
<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>
<partial name="_ApplicantRow" model="a" />
}
@if (row.Guests > 0)
{
@@ -102,7 +102,7 @@
<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>
<partial name="_ApplicantRow" model="a" />
}
@if (row.Guests > 0)
{
@@ -1,6 +1,7 @@
using System.Security.Claims;
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;
@@ -12,9 +13,14 @@ namespace JobsMedical.Web.Pages.Employer;
public class ListingsModel : PageModel
{
private readonly AppDbContext _db;
public ListingsModel(AppDbContext db) => _db = db;
private readonly NotificationService _notify;
public ListingsModel(AppDbContext db, NotificationService notify)
{
_db = db;
_notify = notify;
}
public record Applicant(string? Name, string Phone, DateTime When);
public record Applicant(string? Name, string Phone, DateTime When, long EventId, int UserId, ApplicationStatus Status);
public record ShiftRow(Shift Shift, List<Applicant> Applicants, int Guests);
public record JobRow(JobOpening Job, List<Applicant> Applicants, int Guests);
@@ -57,6 +63,37 @@ public class ListingsModel : PageModel
return RedirectToPage(new { FacilityId = j.FacilityId });
}
// --- Applicant decisions ---
public Task<IActionResult> OnPostAcceptAsync(long eventId) => SetStatus(eventId, ApplicationStatus.Accepted, true);
public Task<IActionResult> OnPostRejectAsync(long eventId) => SetStatus(eventId, ApplicationStatus.Rejected, false);
private async Task<IActionResult> SetStatus(long eventId, ApplicationStatus status, bool accepted)
{
var ev = await _db.InterestEvents
.Include(e => e.Shift).ThenInclude(s => s!.Facility)
.Include(e => e.Shift).ThenInclude(s => s!.Role)
.Include(e => e.JobOpening).ThenInclude(j => j!.Facility)
.FirstOrDefaultAsync(e => e.Id == eventId && e.EventType == InterestEventType.Apply);
if (ev is null) return NotFound();
var facilityId = ev.Shift?.FacilityId ?? ev.JobOpening?.FacilityId ?? 0;
if (!await OwnsAsync(facilityId)) return Forbid();
ev.Status = status;
await _db.SaveChangesAsync();
// Notify the applicant (only when they're a registered user we can reach).
var applicantId = await _db.Visitors.Where(v => v.Id == ev.VisitorId).Select(v => v.UserId).FirstOrDefaultAsync();
if (applicantId is int uid)
{
var (title, url) = ev.JobOpening is not null
? (ev.JobOpening.Title, $"/Jobs/Details/{ev.JobOpeningId}")
: ($"شیفت {ev.Shift?.Role?.Name} — {ev.Shift?.Facility?.Name}", $"/Shifts/Details/{ev.ShiftId}");
await _notify.NotifyApplicantStatusAsync(uid, title, accepted, url);
}
return RedirectToPage(new { FacilityId = facilityId });
}
private async Task<bool> OwnsAsync(int facilityId)
{
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
@@ -99,7 +136,7 @@ public class ListingsModel : PageModel
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));
if (seen.Add(id)) applicants.Add(new Applicant(u.FullName, u.Phone, e.CreatedAt, e.Id, id, e.Status));
}
else guests++;
}
@@ -0,0 +1,26 @@
@model JobsMedical.Web.Pages.Employer.ListingsModel.Applicant
@{
var s = Model.Status;
}
<li style="margin-bottom:8px;">
<span>@(Model.Name ?? "کاربر") — <span dir="ltr">@JalaliDate.ToPersianDigits(Model.Phone)</span></span>
@if (s == JobsMedical.Web.Models.ApplicationStatus.Accepted)
{
<span class="badge badge-verified">✓ پذیرفته شد</span>
}
else if (s == JobsMedical.Web.Models.ApplicationStatus.Rejected)
{
<span class="badge badge-gender">رد شد</span>
}
else
{
<span style="display:inline-flex; gap:6px; margin-inline-start:8px; vertical-align:middle;">
<form method="post" asp-page-handler="Accept" asp-route-eventId="@Model.EventId" style="display:inline;">
<button type="submit" class="btn btn-accent" style="padding:3px 12px; font-size:12px;">پذیرفتن</button>
</form>
<form method="post" asp-page-handler="Reject" asp-route-eventId="@Model.EventId" style="display:inline;">
<button type="submit" class="btn btn-outline" style="padding:3px 12px; font-size:12px; color:var(--danger); border-color:var(--danger);">رد</button>
</form>
</span>
}
</li>
+22 -2
View File
@@ -8,6 +8,12 @@
UserRole.FacilityAdmin => "کارفرما",
_ => "کارجو (کادر درمان)",
};
(string cls, string txt) AppBadge(JobsMedical.Web.Models.ApplicationStatus s) => s switch
{
JobsMedical.Web.Models.ApplicationStatus.Accepted => ("badge-verified", "✓ پذیرفته شد"),
JobsMedical.Web.Models.ApplicationStatus.Rejected => ("badge-gender", "رد شد"),
_ => ("badge-type", "در انتظار بررسی"),
};
}
<div class="page-head">
@@ -73,8 +79,22 @@
else
{
<div class="grid grid-3">
@foreach (var s in Model.AppliedShifts) { <partial name="_ShiftCard" model="s" /> }
@foreach (var j in Model.AppliedJobs) { <partial name="_JobCard" model="j" /> }
@foreach (var s in Model.AppliedShifts)
{
var b = AppBadge(Model.ShiftAppStatus.GetValueOrDefault(s.Id, JobsMedical.Web.Models.ApplicationStatus.Interested));
<div>
<span class="badge @b.cls" style="margin-bottom:6px; display:inline-block;">@b.txt</span>
<partial name="_ShiftCard" model="s" />
</div>
}
@foreach (var j in Model.AppliedJobs)
{
var b = AppBadge(Model.JobAppStatus.GetValueOrDefault(j.Id, JobsMedical.Web.Models.ApplicationStatus.Interested));
<div>
<span class="badge @b.cls" style="margin-bottom:6px; display:inline-block;">@b.txt</span>
<partial name="_JobCard" model="j" />
</div>
}
</div>
}
</div>
@@ -29,6 +29,8 @@ public class IndexModel : PageModel
public List<Shift> SavedShifts { get; private set; } = new();
public List<Shift> AppliedShifts { get; private set; } = new();
public List<JobOpening> AppliedJobs { get; private set; } = new();
public Dictionary<int, ApplicationStatus> ShiftAppStatus { get; private set; } = new();
public Dictionary<int, ApplicationStatus> JobAppStatus { get; private set; } = new();
public async Task OnGetAsync()
{
@@ -52,6 +54,14 @@ public class IndexModel : PageModel
AppliedJobs = await _db.JobOpenings
.Include(j => j.Facility).ThenInclude(f => f.City).Include(j => j.Role)
.Where(j => appliedJobIds.Contains(j.Id)).ToListAsync();
// Latest application status per applied listing (employer accept/reject shows here).
ShiftAppStatus = events.Where(e => e.EventType == InterestEventType.Apply && e.ShiftId != null)
.GroupBy(e => e.ShiftId!.Value)
.ToDictionary(g => g.Key, g => g.OrderByDescending(e => e.CreatedAt).First().Status);
JobAppStatus = events.Where(e => e.EventType == InterestEventType.Apply && e.JobOpeningId != null)
.GroupBy(e => e.JobOpeningId!.Value)
.ToDictionary(g => g.Key, g => g.OrderByDescending(e => e.CreatedAt).First().Status);
}
private Task<List<Shift>> ShiftsByIds(List<int> ids) => _db.Shifts
@@ -86,6 +86,13 @@ public class NotificationService
$"/Employer/Listings?facilityId={j.FacilityId}");
}
/// <summary>Tell an applicant their application was accepted or rejected.</summary>
public Task NotifyApplicantStatusAsync(int userId, string listingTitle, bool accepted, string url)
{
var title = accepted ? "درخواست شما پذیرفته شد ✅" : "درخواست شما رد شد";
return AddAsync(new List<int> { userId }, title, listingTitle, url);
}
/// <summary>Users with a non-empty preference that matches the listing (via their visitor link).</summary>
private async Task<List<int>> MatchingUserIdsAsync(int roleId, int cityId, ShiftType? shiftType)
{