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>
|
<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 });
|
||||||
|
}
|
||||||
|
return list;
|
||||||
}
|
}
|
||||||
var talent = new TalentListing
|
|
||||||
|
TalentListing? firstTalent = null;
|
||||||
|
foreach (var role in validRoles)
|
||||||
{
|
{
|
||||||
RoleId = RoleId,
|
var t = new TalentListing
|
||||||
CityId = cityId.Value,
|
{
|
||||||
PersonName = string.IsNullOrWhiteSpace(PersonName) ? null : PersonName.Trim(),
|
RoleId = role.Id, CityId = cityId.Value,
|
||||||
YearsExperience = YearsExperience,
|
PersonName = string.IsNullOrWhiteSpace(PersonName) ? null : PersonName.Trim(),
|
||||||
IsLicensed = IsLicensed,
|
YearsExperience = YearsExperience, 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,
|
_db.TalentListings.Add(t);
|
||||||
Status = ShiftStatus.Open,
|
firstTalent ??= t;
|
||||||
Source = ShiftSource.Aggregated,
|
}
|
||||||
SourceUrl = Raw.SourceUrl,
|
|
||||||
Contacts = parsedContacts,
|
|
||||||
Tags = string.Join(" ", reparsed.Tags.Distinct()),
|
|
||||||
};
|
|
||||||
_db.TalentListings.Add(talent);
|
|
||||||
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>();
|
||||||
var shift = new Shift
|
foreach (var role in validRoles)
|
||||||
{
|
{
|
||||||
FacilityId = facilityId.Value,
|
var shift = new Shift
|
||||||
RoleId = RoleId,
|
{
|
||||||
Date = ShiftDate,
|
FacilityId = facilityId.Value, RoleId = role.Id,
|
||||||
StartTime = StartTime,
|
Date = ShiftDate, StartTime = StartTime, EndTime = EndTime, ShiftType = ShiftType,
|
||||||
EndTime = EndTime,
|
SpecialtyRequired = role.Name, Description = Description,
|
||||||
ShiftType = ShiftType,
|
PayType = payType, PayAmount = payAmt, SharePercent = sharePct,
|
||||||
SpecialtyRequired = role?.Name ?? "",
|
GenderRequirement = GenderRequirement, Status = ShiftStatus.Open,
|
||||||
Description = Description,
|
Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl,
|
||||||
PayType = Negotiable ? PayType.Negotiable
|
};
|
||||||
: (PayAmount is null && SharePercent is not null ? PayType.Percentage : PayType.PerShift),
|
_db.Shifts.Add(shift);
|
||||||
PayAmount = Negotiable ? null : PayAmount,
|
created.Add(shift);
|
||||||
SharePercent = Negotiable ? null : SharePercent,
|
}
|
||||||
GenderRequirement = GenderRequirement,
|
|
||||||
Status = ShiftStatus.Open,
|
|
||||||
Source = ShiftSource.Aggregated,
|
|
||||||
SourceUrl = Raw.SourceUrl,
|
|
||||||
};
|
|
||||||
_db.Shifts.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 job = new JobOpening
|
var created = new List<JobOpening>();
|
||||||
|
foreach (var role in validRoles)
|
||||||
{
|
{
|
||||||
FacilityId = facilityId.Value,
|
var job = new JobOpening
|
||||||
RoleId = RoleId,
|
{
|
||||||
Title = string.IsNullOrWhiteSpace(Title) ? "موقعیت استخدامی" : Title.Trim(),
|
FacilityId = facilityId.Value, RoleId = role.Id,
|
||||||
EmploymentType = EmploymentType,
|
// With several roles, give each a role-specific title; with one, honor the typed title.
|
||||||
SalaryMin = Negotiable ? null : SalaryMin,
|
Title = many || string.IsNullOrWhiteSpace(Title) ? $"استخدام {role.Name}" : Title.Trim(),
|
||||||
SalaryMax = Negotiable ? null : SalaryMax,
|
EmploymentType = EmploymentType,
|
||||||
GenderRequirement = GenderRequirement,
|
SalaryMin = Negotiable ? null : SalaryMin, SalaryMax = Negotiable ? null : SalaryMax,
|
||||||
Description = Description,
|
GenderRequirement = GenderRequirement, Description = Description,
|
||||||
Status = ShiftStatus.Open,
|
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl,
|
||||||
Source = ShiftSource.Aggregated,
|
};
|
||||||
SourceUrl = Raw.SourceUrl,
|
_db.JobOpenings.Add(job);
|
||||||
};
|
created.Add(job);
|
||||||
_db.JobOpenings.Add(job);
|
}
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
Raw.Status = RawListingStatus.Normalized;
|
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");
|
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; } }
|
@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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user