[Profile] Show applicant avatar + resume to employers; profile-completeness nudge
CI/CD / CI · dotnet build (push) Successful in 1m33s
CI/CD / Deploy · hamkadr (push) Successful in 2m7s

Employer Listings: each applicant row now shows their avatar (or initials) and a «مشاهده رزومه» link when they uploaded one (served via /resume/{id}, already access-controlled to the receiving employer). Applicant projection avoids loading avatar/resume blobs. Me panel: a nudge banner prompts users to complete their profile (name/photo/resume) when any is missing, linking to /Me/Profile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-07 07:14:03 +03:30
parent e633463906
commit c46e628f6a
5 changed files with 63 additions and 18 deletions
@@ -20,7 +20,8 @@ public class ListingsModel : PageModel
_notify = notify; _notify = notify;
} }
public record Applicant(string? Name, string Phone, DateTime When, long EventId, int UserId, ApplicationStatus Status); public record Applicant(string? Name, string Phone, DateTime When, long EventId, int UserId,
ApplicationStatus Status, bool HasAvatar, bool HasResume);
public record ShiftRow(Shift Shift, List<Applicant> Applicants, int Guests); public record ShiftRow(Shift Shift, List<Applicant> Applicants, int Guests);
public record JobRow(JobOpening Job, List<Applicant> Applicants, int Guests); public record JobRow(JobOpening Job, List<Applicant> Applicants, int Guests);
@@ -124,7 +125,9 @@ public class ListingsModel : PageModel
var visitorUser = await _db.Visitors.Where(v => visitorIds.Contains(v.Id)) var visitorUser = await _db.Visitors.Where(v => visitorIds.Contains(v.Id))
.ToDictionaryAsync(v => v.Id, v => v.UserId); .ToDictionaryAsync(v => v.Id, v => v.UserId);
var userIds = visitorUser.Values.Where(u => u != null).Select(u => u!.Value).Distinct().ToList(); var userIds = visitorUser.Values.Where(u => u != null).Select(u => u!.Value).Distinct().ToList();
var users = await _db.Users.Where(u => userIds.Contains(u.Id)).ToDictionaryAsync(u => u.Id); var users = await _db.Users.Where(u => userIds.Contains(u.Id))
.Select(u => new { u.Id, u.FullName, u.Phone, HasAvatar = u.Avatar != null, HasResume = u.Resume != null })
.ToDictionaryAsync(u => u.Id);
(List<Applicant> applicants, int guests) Resolve(IEnumerable<InterestEvent> evs) (List<Applicant> applicants, int guests) Resolve(IEnumerable<InterestEvent> evs)
{ {
@@ -136,7 +139,7 @@ public class ListingsModel : PageModel
var uid = visitorUser.GetValueOrDefault(e.VisitorId); var uid = visitorUser.GetValueOrDefault(e.VisitorId);
if (uid is int id && users.TryGetValue(id, out var u)) 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, e.Id, id, e.Status)); if (seen.Add(id)) applicants.Add(new Applicant(u.FullName, u.Phone, e.CreatedAt, e.Id, id, e.Status, u.HasAvatar, u.HasResume));
} }
else guests++; else guests++;
} }
@@ -1,26 +1,44 @@
@model JobsMedical.Web.Pages.Employer.ListingsModel.Applicant @model JobsMedical.Web.Pages.Employer.ListingsModel.Applicant
@{ @{
var s = Model.Status; var s = Model.Status;
var nm = (Model.Name ?? Model.Phone).Trim();
var initial = nm.Length > 0 ? nm.Substring(0, 1) : "؟";
} }
<li style="margin-bottom:8px;"> <li class="applicant-row">
<span>@(Model.Name ?? "کاربر") — <span dir="ltr">@JalaliDate.ToPersianDigits(Model.Phone)</span></span> <span class="avatar-sm">
@if (s == JobsMedical.Web.Models.ApplicationStatus.Accepted) @if (Model.HasAvatar)
{ {
<span class="badge badge-verified">✓ پذیرفته شد</span> <img src="/avatar/@Model.UserId" alt="" />
} }
else if (s == JobsMedical.Web.Models.ApplicationStatus.Rejected) else
{ {
<span class="badge badge-gender">رد شد</span> <span class="avatar-fallback">@initial</span>
} }
else </span>
{ <span class="applicant-info">
<span style="display:inline-flex; gap:6px; margin-inline-start:8px; vertical-align:middle;"> <span>@(Model.Name ?? "کاربر") — <span dir="ltr">@JalaliDate.ToPersianDigits(Model.Phone)</span></span>
@if (Model.HasResume)
{
<a href="/resume/@Model.UserId" target="_blank" class="resume-link">📎 مشاهده رزومه</a>
}
</span>
<span class="applicant-actions">
@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
{
<form method="post" asp-page-handler="Accept" asp-route-eventId="@Model.EventId" style="display:inline;"> <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> <button type="submit" class="btn btn-accent" style="padding:3px 12px; font-size:12px;">پذیرفتن</button>
</form> </form>
<form method="post" asp-page-handler="Reject" asp-route-eventId="@Model.EventId" style="display:inline;"> <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> <button type="submit" class="btn btn-outline" style="padding:3px 12px; font-size:12px; color:var(--danger); border-color:var(--danger);">رد</button>
</form> </form>
</span> }
} </span>
</li> </li>
@@ -27,6 +27,13 @@
</div> </div>
<div class="container section"> <div class="container section">
@if (Model.ProfileIncomplete)
{
<div class="profile-nudge">
<span class="pn-text">✨ پروفایلت را کامل کن (نام، تصویر، رزومه) تا شانس پذیرش‌ات بیشتر شود.</span>
<a class="btn btn-accent btn-sm" asp-page="/Me/Profile">تکمیل پروفایل</a>
</div>
}
<div class="rec-banner"> <div class="rec-banner">
<div> <div>
<h2 style="margin:0 0 4px;">✨ پیشنهادهای ویژه شما</h2> <h2 style="margin:0 0 4px;">✨ پیشنهادهای ویژه شما</h2>
@@ -31,11 +31,14 @@ public class IndexModel : PageModel
public List<JobOpening> AppliedJobs { 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> ShiftAppStatus { get; private set; } = new();
public Dictionary<int, ApplicationStatus> JobAppStatus { get; private set; } = new(); public Dictionary<int, ApplicationStatus> JobAppStatus { get; private set; } = new();
public bool ProfileIncomplete { get; private set; }
public async Task OnGetAsync() public async Task OnGetAsync()
{ {
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
CurrentUser = await _db.Users.FindAsync(userId); CurrentUser = await _db.Users.FindAsync(userId);
ProfileIncomplete = CurrentUser is not null &&
(string.IsNullOrWhiteSpace(CurrentUser.FullName) || CurrentUser.Avatar is null || CurrentUser.Resume is null);
Prefs = await _interest.GetPreferencesAsync(); Prefs = await _interest.GetPreferencesAsync();
Recommendations = await _recs.GetForVisitorAsync(6); Recommendations = await _recs.GetForVisitorAsync(6);
+14
View File
@@ -111,6 +111,20 @@ a { color: inherit; text-decoration: none; }
.pd-sep { height: 1px; background: var(--line); margin: 4px 0; } .pd-sep { height: 1px; background: var(--line); margin: 4px 0; }
.pd-logout { color: var(--danger); } .pd-logout { color: var(--danger); }
/* Applicant rows (employer listings) */
.applicant-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; list-style: none; }
.avatar-sm { flex: 0 0 auto; }
.avatar-sm img, .avatar-sm .avatar-fallback { width: 30px; height: 30px; border-radius: 50%; object-fit: cover; display: grid; place-items: center; }
.avatar-sm .avatar-fallback { background: var(--primary); color: #fff; font-weight: 800; font-size: 13px; }
.applicant-info { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }
.resume-link { font-size: 12px; color: var(--primary); font-weight: 600; }
.applicant-actions { display: inline-flex; gap: 6px; align-items: center; flex: 0 0 auto; }
/* Profile-completeness nudge */
.profile-nudge { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
background: #fff7ed; border: 1px solid var(--accent); border-radius: 12px; padding: 12px 16px; margin-bottom: 16px; }
.profile-nudge .pn-text { font-weight: 600; font-size: 14px; }
/* Large avatar on the profile editor page */ /* Large avatar on the profile editor page */
.avatar-lg { width: 84px; height: 84px; border-radius: 50%; overflow: hidden; flex: 0 0 auto; .avatar-lg { width: 84px; height: 84px; border-radius: 50%; overflow: hidden; flex: 0 0 auto;
background: var(--primary-soft); display: grid; place-items: center; } background: var(--primary-soft); display: grid; place-items: center; }