[Applications] Applicant pipeline: employer accept/reject + status to applicant
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:
+1230
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>
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user