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:
@@ -62,13 +62,17 @@
|
||||
<p class="muted" style="font-size:11px; margin:4px 0 0;">اگر مرکز در فهرست نیست، نامش را اینجا بنویس تا بهصورت «تأییدنشده» ساخته شود.</p>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>نقش</label>
|
||||
<select name="RoleId">
|
||||
<label>نقشها (میتوانی چند مورد انتخاب کنی)</label>
|
||||
<div class="role-checks">
|
||||
@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 class="filter-group">
|
||||
|
||||
@@ -35,7 +35,8 @@ public class ReviewModel : PageModel
|
||||
[BindProperty] public ListingKind Kind { 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 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; }
|
||||
// Shift fields
|
||||
[BindProperty] public DateOnly ShiftDate { get; set; }
|
||||
@@ -70,7 +71,8 @@ public class ReviewModel : PageModel
|
||||
|
||||
// Prefill the form from the parser's best guess.
|
||||
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;
|
||||
EmploymentType = Parsed.EmploymentType ?? EmploymentType.FullTime;
|
||||
(StartTime, EndTime) = DefaultTimes(ShiftType);
|
||||
@@ -117,13 +119,20 @@ public class ReviewModel : PageModel
|
||||
Raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id);
|
||||
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 });
|
||||
}
|
||||
|
||||
// «آماده به کار» — 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)
|
||||
{
|
||||
var cityId = TalentCityId > 0 && await _db.Cities.AnyAsync(c => c.Id == TalentCityId)
|
||||
@@ -134,112 +143,106 @@ public class ReviewModel : PageModel
|
||||
Error = "شهری برای انتشار آگهی «آماده به کار» موجود نیست.";
|
||||
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 reparsed = _parser.Parse(Raw.RawText, roleNames, await CityNamesAsync(), await DistrictNamesAsync());
|
||||
var parsedContacts = reparsed.Contacts
|
||||
.Select((c, i) => new ContactMethod { Type = c.Type, Value = c.Value, SortOrder = i })
|
||||
.ToList();
|
||||
// Include the admin-typed phone if it isn't already captured.
|
||||
if (!string.IsNullOrWhiteSpace(Phone))
|
||||
var contactSpecs = reparsed.Contacts.Select((c, i) => (c.Type, c.Value, Order: i)).ToList();
|
||||
var adminPhone = string.IsNullOrWhiteSpace(Phone) ? null : Phone.Trim();
|
||||
var tags = string.Join(" ", reparsed.Tags.Distinct());
|
||||
|
||||
// Fresh ContactMethod instances per listing (EF can't share children across parents).
|
||||
List<ContactMethod> FreshContacts()
|
||||
{
|
||||
var digits = new string(Phone.Where(char.IsDigit).ToArray());
|
||||
if (!parsedContacts.Any(c => new string(c.Value.Where(char.IsDigit).ToArray()) == digits))
|
||||
parsedContacts.Insert(0, new ContactMethod { Type = ContactType.Mobile, Value = Phone.Trim(), SortOrder = -1 });
|
||||
var list = contactSpecs.Select(s => new ContactMethod { Type = s.Type, Value = s.Value, SortOrder = s.Order }).ToList();
|
||||
if (adminPhone is not null)
|
||||
{
|
||||
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 });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
var talent = new TalentListing
|
||||
|
||||
TalentListing? firstTalent = null;
|
||||
foreach (var role in validRoles)
|
||||
{
|
||||
RoleId = RoleId,
|
||||
CityId = cityId.Value,
|
||||
PersonName = string.IsNullOrWhiteSpace(PersonName) ? null : PersonName.Trim(),
|
||||
YearsExperience = YearsExperience,
|
||||
IsLicensed = IsLicensed,
|
||||
AreaNote = string.IsNullOrWhiteSpace(AreaNote) ? null : AreaNote.Trim(),
|
||||
Availability = EmploymentType,
|
||||
Gender = GenderRequirement,
|
||||
PayType = Negotiable ? PayType.Negotiable
|
||||
: (PayAmount is null && SharePercent is not null ? PayType.Percentage : PayType.PerShift),
|
||||
PayAmount = Negotiable ? null : PayAmount,
|
||||
SharePercent = Negotiable ? null : SharePercent,
|
||||
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);
|
||||
var t = new TalentListing
|
||||
{
|
||||
RoleId = role.Id, CityId = cityId.Value,
|
||||
PersonName = string.IsNullOrWhiteSpace(PersonName) ? null : PersonName.Trim(),
|
||||
YearsExperience = YearsExperience, IsLicensed = IsLicensed,
|
||||
AreaNote = string.IsNullOrWhiteSpace(AreaNote) ? null : AreaNote.Trim(),
|
||||
Availability = EmploymentType, Gender = GenderRequirement,
|
||||
PayType = payType, PayAmount = payAmt, SharePercent = sharePct,
|
||||
Phone = adminPhone, Description = Description,
|
||||
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl,
|
||||
Contacts = FreshContacts(),
|
||||
Tags = string.Join(" ", new[] { tags, role.Name }.Where(x => !string.IsNullOrWhiteSpace(x))),
|
||||
};
|
||||
_db.TalentListings.Add(t);
|
||||
firstTalent ??= t;
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
Raw.Status = RawListingStatus.Normalized;
|
||||
Raw.LinkedTalentId = talent.Id;
|
||||
Raw.LinkedTalentId = firstTalent!.Id;
|
||||
await _db.SaveChangesAsync();
|
||||
return RedirectToPage("/Admin/Index");
|
||||
}
|
||||
|
||||
// Shift/Job need a facility. Resolve the picked/typed one, falling back to a single
|
||||
// shared «نامشخص / ثبت نشده» record when the ad doesn't name a facility — so publishing
|
||||
// never fails on a missing facility.
|
||||
// ---- Shift / Job: need a facility (falls back to «نامشخص / ثبت نشده») ----
|
||||
var facilityId = await ResolveFacilityIdAsync();
|
||||
if (facilityId is null)
|
||||
{
|
||||
Error = "شهری برای ساخت مرکز موجود نیست؛ ابتدا یک شهر اضافه کن.";
|
||||
return RedirectToPage(new { id });
|
||||
}
|
||||
var many = validRoles.Count > 1;
|
||||
|
||||
Shift? createdShift = null;
|
||||
JobOpening? createdJob = null;
|
||||
if (Kind == ListingKind.Shift)
|
||||
{
|
||||
var role = await _db.Roles.FindAsync(RoleId);
|
||||
var shift = new Shift
|
||||
var created = new List<Shift>();
|
||||
foreach (var role in validRoles)
|
||||
{
|
||||
FacilityId = facilityId.Value,
|
||||
RoleId = RoleId,
|
||||
Date = ShiftDate,
|
||||
StartTime = StartTime,
|
||||
EndTime = EndTime,
|
||||
ShiftType = ShiftType,
|
||||
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);
|
||||
var shift = new Shift
|
||||
{
|
||||
FacilityId = facilityId.Value, RoleId = role.Id,
|
||||
Date = ShiftDate, StartTime = StartTime, EndTime = EndTime, ShiftType = ShiftType,
|
||||
SpecialtyRequired = role.Name, Description = Description,
|
||||
PayType = payType, PayAmount = payAmt, SharePercent = sharePct,
|
||||
GenderRequirement = GenderRequirement, Status = ShiftStatus.Open,
|
||||
Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl,
|
||||
};
|
||||
_db.Shifts.Add(shift);
|
||||
created.Add(shift);
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
Raw.Status = RawListingStatus.Normalized;
|
||||
Raw.LinkedShiftId = shift.Id;
|
||||
createdShift = shift;
|
||||
Raw.LinkedShiftId = created[0].Id;
|
||||
await _db.SaveChangesAsync();
|
||||
foreach (var s in created) await _notify.NotifyNewShiftAsync(s.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
var job = new JobOpening
|
||||
var created = new List<JobOpening>();
|
||||
foreach (var role in validRoles)
|
||||
{
|
||||
FacilityId = facilityId.Value,
|
||||
RoleId = RoleId,
|
||||
Title = string.IsNullOrWhiteSpace(Title) ? "موقعیت استخدامی" : Title.Trim(),
|
||||
EmploymentType = EmploymentType,
|
||||
SalaryMin = Negotiable ? null : SalaryMin,
|
||||
SalaryMax = Negotiable ? null : SalaryMax,
|
||||
GenderRequirement = GenderRequirement,
|
||||
Description = Description,
|
||||
Status = ShiftStatus.Open,
|
||||
Source = ShiftSource.Aggregated,
|
||||
SourceUrl = Raw.SourceUrl,
|
||||
};
|
||||
_db.JobOpenings.Add(job);
|
||||
var job = new JobOpening
|
||||
{
|
||||
FacilityId = facilityId.Value, RoleId = role.Id,
|
||||
// With several roles, give each a role-specific title; with one, honor the typed title.
|
||||
Title = many || string.IsNullOrWhiteSpace(Title) ? $"استخدام {role.Name}" : Title.Trim(),
|
||||
EmploymentType = EmploymentType,
|
||||
SalaryMin = Negotiable ? null : SalaryMin, SalaryMax = Negotiable ? null : SalaryMax,
|
||||
GenderRequirement = GenderRequirement, Description = Description,
|
||||
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl,
|
||||
};
|
||||
_db.JobOpenings.Add(job);
|
||||
created.Add(job);
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
Raw.Status = RawListingStatus.Normalized;
|
||||
createdJob = job;
|
||||
await _db.SaveChangesAsync();
|
||||
foreach (var j in created) await _notify.NotifyNewJobAsync(j.Id);
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
if (createdShift is not null) await _notify.NotifyNewShiftAsync(createdShift.Id);
|
||||
if (createdJob is not null) await _notify.NotifyNewJobAsync(createdJob.Id);
|
||||
return RedirectToPage("/Admin/Index");
|
||||
}
|
||||
|
||||
|
||||
@@ -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; } }
|
||||
.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. */
|
||||
.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; }
|
||||
|
||||
Reference in New Issue
Block a user