Review/publish: multi-select roles → one listing per role

An ad can cover several roles (e.g. «پرستار سالمند و کودک و همراه بیمار»).
The role dropdown is now a checkbox multi-select; on publish we fan out and
create one Shift/Job/Talent per selected role (mirrors the auto-ingest
fan-out). Jobs get a per-role title when multiple are chosen; talent
listings each get their own contact rows; all created items notify matches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-08 22:03:09 +03:30
parent 5e5d7f80ef
commit 1e96526bd9
3 changed files with 100 additions and 86 deletions
@@ -62,13 +62,17 @@
<p class="muted" style="font-size:11px; margin:4px 0 0;">اگر مرکز در فهرست نیست، نامش را اینجا بنویس تا به‌صورت «تأییدنشده» ساخته شود.</p> <p class="muted" style="font-size:11px; margin:4px 0 0;">اگر مرکز در فهرست نیست، نامش را اینجا بنویس تا به‌صورت «تأییدنشده» ساخته شود.</p>
</div> </div>
<div class="filter-group"> <div class="filter-group">
<label>نقش</label> <label>نقش‌ها (می‌توانی چند مورد انتخاب کنی)</label>
<select name="RoleId"> <div class="role-checks">
@foreach (var role in Model.Roles) @foreach (var role in Model.Roles)
{ {
<option value="@role.Id" selected="@(Model.RoleId == role.Id)">@role.Name</option> <label class="role-check">
<input type="checkbox" name="RoleIds" value="@role.Id" checked="@(Model.RoleIds.Contains(role.Id))" />
<span>@role.Name</span>
</label>
} }
</select> </div>
<p class="muted" style="font-size:11px; margin:4px 0 0;">برای آگهی چندتخصصی (مثل «پرستار سالمند و کودک») همه‌ی نقش‌ها را تیک بزن — برای هر نقش یک آگهی جدا ساخته می‌شود.</p>
</div> </div>
<div class="filter-group"> <div class="filter-group">
@@ -35,7 +35,8 @@ public class ReviewModel : PageModel
[BindProperty] public ListingKind Kind { get; set; } [BindProperty] public ListingKind Kind { get; set; }
[BindProperty] public int FacilityId { get; set; } [BindProperty] public int FacilityId { get; set; }
[BindProperty] public string? NewFacilityName { get; set; } // create a facility on the fly if none picked [BindProperty] public string? NewFacilityName { get; set; } // create a facility on the fly if none picked
[BindProperty] public int RoleId { get; set; } /// <summary>One or more roles — an ad like «پرستار سالمند و کودک» publishes one listing per role.</summary>
[BindProperty] public int[] RoleIds { get; set; } = Array.Empty<int>();
[BindProperty] public string? Description { get; set; } [BindProperty] public string? Description { get; set; }
// Shift fields // Shift fields
[BindProperty] public DateOnly ShiftDate { get; set; } [BindProperty] public DateOnly ShiftDate { get; set; }
@@ -70,7 +71,8 @@ public class ReviewModel : PageModel
// Prefill the form from the parser's best guess. // Prefill the form from the parser's best guess.
Kind = Parsed.Kind; Kind = Parsed.Kind;
RoleId = Roles.FirstOrDefault(r => r.Name == Parsed.RoleName)?.Id ?? Roles.FirstOrDefault()?.Id ?? 0; var matchedRole = Roles.FirstOrDefault(r => r.Name == Parsed.RoleName)?.Id ?? Roles.FirstOrDefault()?.Id ?? 0;
RoleIds = matchedRole > 0 ? new[] { matchedRole } : Array.Empty<int>();
ShiftType = Parsed.ShiftType ?? ShiftType.Day; ShiftType = Parsed.ShiftType ?? ShiftType.Day;
EmploymentType = Parsed.EmploymentType ?? EmploymentType.FullTime; EmploymentType = Parsed.EmploymentType ?? EmploymentType.FullTime;
(StartTime, EndTime) = DefaultTimes(ShiftType); (StartTime, EndTime) = DefaultTimes(ShiftType);
@@ -117,13 +119,20 @@ public class ReviewModel : PageModel
Raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id); Raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id);
if (Raw is null) return NotFound(); if (Raw is null) return NotFound();
if (!await _db.Roles.AnyAsync(r => r.Id == RoleId)) // One or more roles — publish a separate listing per selected role.
var validRoles = await _db.Roles.Where(r => RoleIds.Contains(r.Id)).ToListAsync();
if (validRoles.Count == 0)
{ {
Error = "یک نقش معتبر انتخاب کن."; Error = "حداقل یک نقش معتبر انتخاب کن.";
return RedirectToPage(new { id }); return RedirectToPage(new { id });
} }
// «آماده به کار» — a worker offering themselves. No facility; publish a TalentListing. var payType = Negotiable ? PayType.Negotiable
: (PayAmount is null && SharePercent is not null ? PayType.Percentage : PayType.PerShift);
var payAmt = Negotiable ? (long?)null : PayAmount;
var sharePct = Negotiable ? (int?)null : SharePercent;
// ---- آماده به کار: no facility; one TalentListing per role ----
if (Kind == ListingKind.Talent) if (Kind == ListingKind.Talent)
{ {
var cityId = TalentCityId > 0 && await _db.Cities.AnyAsync(c => c.Id == TalentCityId) var cityId = TalentCityId > 0 && await _db.Cities.AnyAsync(c => c.Id == TalentCityId)
@@ -134,112 +143,106 @@ public class ReviewModel : PageModel
Error = "شهری برای انتشار آگهی «آماده به کار» موجود نیست."; Error = "شهری برای انتشار آگهی «آماده به کار» موجود نیست.";
return RedirectToPage(new { id }); return RedirectToPage(new { id });
} }
// Re-parse the raw text to recover all contact channels (phones/email/socials) + tags.
var roleNames = await _db.Roles.Select(r => r.Name).ToListAsync(); var roleNames = await _db.Roles.Select(r => r.Name).ToListAsync();
var reparsed = _parser.Parse(Raw.RawText, roleNames, await CityNamesAsync(), await DistrictNamesAsync()); var reparsed = _parser.Parse(Raw.RawText, roleNames, await CityNamesAsync(), await DistrictNamesAsync());
var parsedContacts = reparsed.Contacts var contactSpecs = reparsed.Contacts.Select((c, i) => (c.Type, c.Value, Order: i)).ToList();
.Select((c, i) => new ContactMethod { Type = c.Type, Value = c.Value, SortOrder = i }) var adminPhone = string.IsNullOrWhiteSpace(Phone) ? null : Phone.Trim();
.ToList(); var tags = string.Join(" ", reparsed.Tags.Distinct());
// Include the admin-typed phone if it isn't already captured.
if (!string.IsNullOrWhiteSpace(Phone)) // Fresh ContactMethod instances per listing (EF can't share children across parents).
List<ContactMethod> FreshContacts()
{ {
var digits = new string(Phone.Where(char.IsDigit).ToArray()); var list = contactSpecs.Select(s => new ContactMethod { Type = s.Type, Value = s.Value, SortOrder = s.Order }).ToList();
if (!parsedContacts.Any(c => new string(c.Value.Where(char.IsDigit).ToArray()) == digits)) if (adminPhone is not null)
parsedContacts.Insert(0, new ContactMethod { Type = ContactType.Mobile, Value = Phone.Trim(), SortOrder = -1 }); {
var d = new string(adminPhone.Where(char.IsDigit).ToArray());
if (!list.Any(c => new string(c.Value.Where(char.IsDigit).ToArray()) == d))
list.Insert(0, new ContactMethod { Type = ContactType.Mobile, Value = adminPhone, SortOrder = -1 });
} }
var talent = new TalentListing return list;
}
TalentListing? firstTalent = null;
foreach (var role in validRoles)
{ {
RoleId = RoleId, var t = new TalentListing
CityId = cityId.Value, {
RoleId = role.Id, CityId = cityId.Value,
PersonName = string.IsNullOrWhiteSpace(PersonName) ? null : PersonName.Trim(), PersonName = string.IsNullOrWhiteSpace(PersonName) ? null : PersonName.Trim(),
YearsExperience = YearsExperience, YearsExperience = YearsExperience, IsLicensed = IsLicensed,
IsLicensed = IsLicensed,
AreaNote = string.IsNullOrWhiteSpace(AreaNote) ? null : AreaNote.Trim(), AreaNote = string.IsNullOrWhiteSpace(AreaNote) ? null : AreaNote.Trim(),
Availability = EmploymentType, Availability = EmploymentType, Gender = GenderRequirement,
Gender = GenderRequirement, PayType = payType, PayAmount = payAmt, SharePercent = sharePct,
PayType = Negotiable ? PayType.Negotiable Phone = adminPhone, Description = Description,
: (PayAmount is null && SharePercent is not null ? PayType.Percentage : PayType.PerShift), Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl,
PayAmount = Negotiable ? null : PayAmount, Contacts = FreshContacts(),
SharePercent = Negotiable ? null : SharePercent, Tags = string.Join(" ", new[] { tags, role.Name }.Where(x => !string.IsNullOrWhiteSpace(x))),
Phone = string.IsNullOrWhiteSpace(Phone) ? null : Phone.Trim(),
Description = Description,
Status = ShiftStatus.Open,
Source = ShiftSource.Aggregated,
SourceUrl = Raw.SourceUrl,
Contacts = parsedContacts,
Tags = string.Join(" ", reparsed.Tags.Distinct()),
}; };
_db.TalentListings.Add(talent); _db.TalentListings.Add(t);
firstTalent ??= t;
}
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
Raw.Status = RawListingStatus.Normalized; Raw.Status = RawListingStatus.Normalized;
Raw.LinkedTalentId = talent.Id; Raw.LinkedTalentId = firstTalent!.Id;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
return RedirectToPage("/Admin/Index"); return RedirectToPage("/Admin/Index");
} }
// Shift/Job need a facility. Resolve the picked/typed one, falling back to a single // ---- Shift / Job: need a facility (falls back to «نامشخص / ثبت نشده») ----
// shared «نامشخص / ثبت نشده» record when the ad doesn't name a facility — so publishing
// never fails on a missing facility.
var facilityId = await ResolveFacilityIdAsync(); var facilityId = await ResolveFacilityIdAsync();
if (facilityId is null) if (facilityId is null)
{ {
Error = "شهری برای ساخت مرکز موجود نیست؛ ابتدا یک شهر اضافه کن."; Error = "شهری برای ساخت مرکز موجود نیست؛ ابتدا یک شهر اضافه کن.";
return RedirectToPage(new { id }); return RedirectToPage(new { id });
} }
var many = validRoles.Count > 1;
Shift? createdShift = null;
JobOpening? createdJob = null;
if (Kind == ListingKind.Shift) if (Kind == ListingKind.Shift)
{ {
var role = await _db.Roles.FindAsync(RoleId); var created = new List<Shift>();
foreach (var role in validRoles)
{
var shift = new Shift var shift = new Shift
{ {
FacilityId = facilityId.Value, FacilityId = facilityId.Value, RoleId = role.Id,
RoleId = RoleId, Date = ShiftDate, StartTime = StartTime, EndTime = EndTime, ShiftType = ShiftType,
Date = ShiftDate, SpecialtyRequired = role.Name, Description = Description,
StartTime = StartTime, PayType = payType, PayAmount = payAmt, SharePercent = sharePct,
EndTime = EndTime, GenderRequirement = GenderRequirement, Status = ShiftStatus.Open,
ShiftType = ShiftType, Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl,
SpecialtyRequired = role?.Name ?? "",
Description = Description,
PayType = Negotiable ? PayType.Negotiable
: (PayAmount is null && SharePercent is not null ? PayType.Percentage : PayType.PerShift),
PayAmount = Negotiable ? null : PayAmount,
SharePercent = Negotiable ? null : SharePercent,
GenderRequirement = GenderRequirement,
Status = ShiftStatus.Open,
Source = ShiftSource.Aggregated,
SourceUrl = Raw.SourceUrl,
}; };
_db.Shifts.Add(shift); _db.Shifts.Add(shift);
created.Add(shift);
}
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
Raw.Status = RawListingStatus.Normalized; Raw.Status = RawListingStatus.Normalized;
Raw.LinkedShiftId = shift.Id; Raw.LinkedShiftId = created[0].Id;
createdShift = shift; await _db.SaveChangesAsync();
foreach (var s in created) await _notify.NotifyNewShiftAsync(s.Id);
} }
else else
{
var created = new List<JobOpening>();
foreach (var role in validRoles)
{ {
var job = new JobOpening var job = new JobOpening
{ {
FacilityId = facilityId.Value, FacilityId = facilityId.Value, RoleId = role.Id,
RoleId = RoleId, // With several roles, give each a role-specific title; with one, honor the typed title.
Title = string.IsNullOrWhiteSpace(Title) ? "موقعیت استخدامی" : Title.Trim(), Title = many || string.IsNullOrWhiteSpace(Title) ? $"استخدام {role.Name}" : Title.Trim(),
EmploymentType = EmploymentType, EmploymentType = EmploymentType,
SalaryMin = Negotiable ? null : SalaryMin, SalaryMin = Negotiable ? null : SalaryMin, SalaryMax = Negotiable ? null : SalaryMax,
SalaryMax = Negotiable ? null : SalaryMax, GenderRequirement = GenderRequirement, Description = Description,
GenderRequirement = GenderRequirement, Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl,
Description = Description,
Status = ShiftStatus.Open,
Source = ShiftSource.Aggregated,
SourceUrl = Raw.SourceUrl,
}; };
_db.JobOpenings.Add(job); _db.JobOpenings.Add(job);
Raw.Status = RawListingStatus.Normalized; created.Add(job);
createdJob = job;
} }
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
if (createdShift is not null) await _notify.NotifyNewShiftAsync(createdShift.Id); Raw.Status = RawListingStatus.Normalized;
if (createdJob is not null) await _notify.NotifyNewJobAsync(createdJob.Id); await _db.SaveChangesAsync();
foreach (var j in created) await _notify.NotifyNewJobAsync(j.Id);
}
return RedirectToPage("/Admin/Index"); return RedirectToPage("/Admin/Index");
} }
+7
View File
@@ -430,6 +430,13 @@ mark { background: #fff3bf; color: inherit; padding: 0 2px; border-radius: 3px;
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.settings-panel h3:first-child { margin-top: 0; } .settings-panel h3:first-child { margin-top: 0; }
/* Multi-select role checkboxes on the review/publish form */
.role-checks { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 6px; }
.role-check { display: flex; align-items: center; gap: 7px; padding: 7px 10px; border: 1px solid var(--line);
border-radius: 10px; cursor: pointer; font-size: 13.5px; font-weight: 600; background: var(--bg); }
.role-check input { width: 16px; height: 16px; flex: 0 0 auto; }
.role-check:has(input:checked) { border-color: var(--primary); background: var(--primary-soft); color: var(--primary-dark); }
/* Each ingestion source gets its own card so the settings don't run together. */ /* Each ingestion source gets its own card so the settings don't run together. */
.source-box { border: 1px solid var(--line); border-radius: 14px; padding: 14px; margin: 12px 0; background: var(--surface); } .source-box { border: 1px solid var(--line); border-radius: 14px; padding: 14px; margin: 12px 0; background: var(--surface); }
.source-box .toggle-row { background: var(--bg); margin-bottom: 10px; } .source-box .toggle-row { background: var(--bg); margin-bottom: 10px; }