using JobsMedical.Web.Data; using JobsMedical.Web.Models; using JobsMedical.Web.Services; using JobsMedical.Web.Services.Scraping; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; namespace JobsMedical.Web.Pages.Admin; [Authorize(Roles = "Admin")] public class ReviewModel : PageModel { private readonly AppDbContext _db; private readonly IListingParser _parser; private readonly NotificationService _notify; public ReviewModel(AppDbContext db, IListingParser parser, NotificationService notify) { _db = db; _parser = parser; _notify = notify; } public RawListing? Raw { get; private set; } public ParsedListing? Parsed { get; private set; } public List Facilities { get; private set; } = new(); public List Roles { get; private set; } = new(); public List Cities { get; private set; } = new(); [TempData] public string? Error { get; set; } // The editable form (prefilled from the parser, admin can override everything). [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 /// 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; } [BindProperty] public ShiftType ShiftType { get; set; } [BindProperty] public TimeOnly StartTime { get; set; } [BindProperty] public TimeOnly EndTime { get; set; } [BindProperty] public long? PayAmount { get; set; } [BindProperty] public int? SharePercent { get; set; } [BindProperty] public bool Negotiable { get; set; } [BindProperty] public Gender GenderRequirement { get; set; } // Job fields [BindProperty] public string? Title { get; set; } [BindProperty] public EmploymentType EmploymentType { get; set; } [BindProperty] public long? SalaryMin { get; set; } [BindProperty] public long? SalaryMax { get; set; } // Talent («آماده به کار») fields — no facility; contact phone is key. [BindProperty] public int TalentCityId { get; set; } [BindProperty] public string? PersonName { get; set; } [BindProperty] public int? YearsExperience { get; set; } [BindProperty] public bool IsLicensed { get; set; } [BindProperty] public string? AreaNote { get; set; } [BindProperty] public string? Phone { get; set; } public async Task OnGetAsync(int id) { await LoadListsAsync(); Raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id); if (Raw is null) return NotFound(); Parsed = _parser.Parse(Raw.RawText, Roles.Select(r => r.Name), await CityNamesAsync(), await DistrictNamesAsync()); // Prefill the form from the parser's best guess. Kind = Parsed.Kind; 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); ShiftDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1); Negotiable = Parsed.PayNegotiable; SharePercent = Parsed.SharePercent; GenderRequirement = Parsed.Gender; if (Parsed.PayAmount is not null) { PayAmount = Parsed.PayAmount; SalaryMin = Parsed.PayAmount; } Description = Raw.RawText; Title = Parsed.RoleName is not null ? $"استخدام {Parsed.RoleName}" : "موقعیت استخدامی"; // Talent prefill. Phone = Parsed.Phone; PersonName = Parsed.PersonName; YearsExperience = Parsed.YearsExperience; IsLicensed = Parsed.IsLicensed; AreaNote = Parsed.AreaNote; TalentCityId = Cities.FirstOrDefault(c => c.Name == Parsed.CityName)?.Id ?? Cities.FirstOrDefault()?.Id ?? 0; // Facility: try to match the listing's facility to one we already have; otherwise // prefill the "new facility" box so publishing creates it. if (!string.IsNullOrWhiteSpace(Parsed.FacilityName)) { var cityId = await _db.Cities.Where(c => c.Name == Parsed.CityName) .Select(c => (int?)c.Id).FirstOrDefaultAsync(); var match = FacilityMatcher.FindBest(Facilities, Parsed.FacilityName, cityId); if (match is not null) { FacilityId = match.Id; Parsed.Notes.Add($"مرکز منطبق در سیستم: «{match.Name}» — همین انتخاب شد."); } else { NewFacilityName = Parsed.FacilityName; Parsed.Notes.Add($"مرکز جدید پیشنهادی: «{Parsed.FacilityName}» — هنگام انتشار ساخته می‌شود."); } } return Page(); } public async Task OnPostPublishAsync(int id) { Raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id); if (Raw is null) return NotFound(); // 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 = "حداقل یک نقش معتبر انتخاب کن."; return RedirectToPage(new { id }); } 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) ? TalentCityId : await _db.Cities.OrderByDescending(c => c.IsActive).Select(c => (int?)c.Id).FirstOrDefaultAsync(); if (cityId is null) { Error = "شهری برای انتشار آگهی «آماده به کار» موجود نیست."; return RedirectToPage(new { id }); } var roleNames = await _db.Roles.Select(r => r.Name).ToListAsync(); var reparsed = _parser.Parse(Raw.RawText, roleNames, await CityNamesAsync(), await DistrictNamesAsync()); 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 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; } TalentListing? firstTalent = null; foreach (var role in validRoles) { 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 = firstTalent!.Id; await _db.SaveChangesAsync(); return RedirectToPage("/Admin/Index"); } // ---- 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; if (Kind == ListingKind.Shift) { var created = new List(); foreach (var role in validRoles) { 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 = created[0].Id; await _db.SaveChangesAsync(); foreach (var s in created) await _notify.NotifyNewShiftAsync(s.Id); } else { var created = new List(); foreach (var role in validRoles) { 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; await _db.SaveChangesAsync(); foreach (var j in created) await _notify.NotifyNewJobAsync(j.Id); } return RedirectToPage("/Admin/Index"); } public async Task OnPostDiscardAsync(int id) { var raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id); if (raw is null) return NotFound(); raw.Status = RawListingStatus.Discarded; await _db.SaveChangesAsync(); return RedirectToPage("/Admin/Index"); } private static (TimeOnly, TimeOnly) DefaultTimes(ShiftType t) => t switch { ShiftType.Day => (new TimeOnly(8, 0), new TimeOnly(14, 0)), ShiftType.Evening => (new TimeOnly(14, 0), new TimeOnly(20, 0)), ShiftType.Night => (new TimeOnly(20, 0), new TimeOnly(8, 0)), _ => (new TimeOnly(8, 0), new TimeOnly(8, 0)), }; /// Placeholder facility name used when an ad doesn't name a real one. private const string UnknownFacilityName = "نامشخص / ثبت نشده"; /// /// Returns a valid FacilityId. Prefers the picked facility, then the typed/parsed name /// (reusing a fuzzy match before creating), and finally falls back to a single shared /// «نامشخص / ثبت نشده» record so publishing never fails for a missing facility. /// Returns null only when there are no cities at all. /// private async Task ResolveFacilityIdAsync() { if (FacilityId > 0 && await _db.Facilities.AnyAsync(f => f.Id == FacilityId)) return FacilityId; var cityId = await _db.Cities.OrderByDescending(c => c.IsActive) .Select(c => (int?)c.Id).FirstOrDefaultAsync(); if (cityId is null) return null; // no cities seeded — cannot create a facility // No facility named in the ad → use/create the shared placeholder. var isPlaceholder = string.IsNullOrWhiteSpace(NewFacilityName); var name = isPlaceholder ? UnknownFacilityName : NewFacilityName.Trim(); // Approximate coords carried from the crawl (e.g. Divar). NEVER apply them to the shared // «نامشخص» placeholder — it's reused across many ads, so a single ad's point would mislead. bool HasGeo() => !isPlaceholder && Raw?.Lat is not null; // Reuse an existing facility that's exactly or closely the same (Persian-aware fuzzy // match), so we don't create duplicates like «بیمارستان میلاد» vs «میلاد». var all = await _db.Facilities.ToListAsync(); var match = FacilityMatcher.FindBest(all, name, cityId); if (match is not null) { if (HasGeo() && match.Lat is null && match.Lng is null) // backfill only, never overwrite { match.Lat = Raw!.Lat; match.Lng = Raw.Lng; await _db.SaveChangesAsync(); } return match.Id; } var facility = new Facility { Name = name, CityId = cityId.Value, Type = FacilityType.Hospital, Verification = VerificationStatus.Unverified, IsVerified = false, Lat = HasGeo() ? Raw!.Lat : null, Lng = HasGeo() ? Raw!.Lng : null, }; _db.Facilities.Add(facility); await _db.SaveChangesAsync(); return facility.Id; } private async Task LoadListsAsync() { Facilities = await _db.Facilities.Include(f => f.City).OrderBy(f => f.Name).ToListAsync(); Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync(); Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync(); } private Task> CityNamesAsync() => _db.Cities.Select(c => c.Name).ToListAsync(); private Task> DistrictNamesAsync() => _db.Districts.Select(d => d.Name).ToListAsync(); }