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 @@

اگر مرکز در فهرست نیست، نامش را اینجا بنویس تا به‌صورت «تأییدنشده» ساخته شود.

- - + @role.Name + } - +
+

برای آگهی چندتخصصی (مثل «پرستار سالمند و کودک») همه‌ی نقش‌ها را تیک بزن — برای هر نقش یک آگهی جدا ساخته می‌شود.

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; }