diff --git a/src/JobsMedical.Web/Pages/Admin/Review.cshtml b/src/JobsMedical.Web/Pages/Admin/Review.cshtml
index c0dcb33..2904a4e 100644
--- a/src/JobsMedical.Web/Pages/Admin/Review.cshtml
+++ b/src/JobsMedical.Web/Pages/Admin/Review.cshtml
@@ -62,13 +62,17 @@
اگر مرکز در فهرست نیست، نامش را اینجا بنویس تا بهصورت «تأییدنشده» ساخته شود.
-
-
diff --git a/src/JobsMedical.Web/Pages/Admin/Review.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Review.cshtml.cs
index a304504..49eadde 100644
--- a/src/JobsMedical.Web/Pages/Admin/Review.cshtml.cs
+++ b/src/JobsMedical.Web/Pages/Admin/Review.cshtml.cs
@@ -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; }
+ /// One or more roles — an ad like «پرستار سالمند و کودک» publishes one listing per role.
+ [BindProperty] public int[] RoleIds { get; set; } = Array.Empty();
[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();
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 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();
+ 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();
+ 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");
}
diff --git a/src/JobsMedical.Web/wwwroot/css/site.css b/src/JobsMedical.Web/wwwroot/css/site.css
index e621f53..9678b3d 100644
--- a/src/JobsMedical.Web/wwwroot/css/site.css
+++ b/src/JobsMedical.Web/wwwroot/css/site.css
@@ -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; }