2026-06-03 01:43:55 +03:30
|
|
|
using JobsMedical.Web.Data;
|
|
|
|
|
using JobsMedical.Web.Models;
|
|
|
|
|
using JobsMedical.Web.Services;
|
2026-06-08 07:14:48 +03:30
|
|
|
using JobsMedical.Web.Services.Scraping;
|
2026-06-03 01:43:55 +03:30
|
|
|
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;
|
2026-06-04 11:56:07 +03:30
|
|
|
private readonly NotificationService _notify;
|
2026-06-03 01:43:55 +03:30
|
|
|
|
2026-06-04 11:56:07 +03:30
|
|
|
public ReviewModel(AppDbContext db, IListingParser parser, NotificationService notify)
|
2026-06-03 01:43:55 +03:30
|
|
|
{
|
|
|
|
|
_db = db;
|
|
|
|
|
_parser = parser;
|
2026-06-04 11:56:07 +03:30
|
|
|
_notify = notify;
|
2026-06-03 01:43:55 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public RawListing? Raw { get; private set; }
|
|
|
|
|
public ParsedListing? Parsed { get; private set; }
|
|
|
|
|
public List<Facility> Facilities { get; private set; } = new();
|
|
|
|
|
public List<Role> Roles { get; private set; } = new();
|
2026-06-08 08:01:12 +03:30
|
|
|
public List<City> Cities { get; private set; } = new();
|
2026-06-03 01:43:55 +03:30
|
|
|
|
2026-06-08 07:09:18 +03:30
|
|
|
[TempData] public string? Error { get; set; }
|
|
|
|
|
|
2026-06-03 01:43:55 +03:30
|
|
|
// The editable form (prefilled from the parser, admin can override everything).
|
|
|
|
|
[BindProperty] public ListingKind Kind { get; set; }
|
|
|
|
|
[BindProperty] public int FacilityId { get; set; }
|
2026-06-08 07:09:18 +03:30
|
|
|
[BindProperty] public string? NewFacilityName { get; set; } // create a facility on the fly if none picked
|
2026-06-03 01:43:55 +03:30
|
|
|
[BindProperty] public int RoleId { get; set; }
|
|
|
|
|
[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; }
|
2026-06-03 06:26:54 +03:30
|
|
|
[BindProperty] public int? SharePercent { get; set; }
|
2026-06-03 01:43:55 +03:30
|
|
|
[BindProperty] public bool Negotiable { get; set; }
|
2026-06-04 00:19:32 +03:30
|
|
|
[BindProperty] public Gender GenderRequirement { get; set; }
|
2026-06-03 01:43:55 +03:30
|
|
|
// 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; }
|
2026-06-08 08:01:12 +03:30
|
|
|
// 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; }
|
2026-06-03 01:43:55 +03:30
|
|
|
|
|
|
|
|
public async Task<IActionResult> 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;
|
|
|
|
|
RoleId = Roles.FirstOrDefault(r => r.Name == Parsed.RoleName)?.Id ?? Roles.FirstOrDefault()?.Id ?? 0;
|
|
|
|
|
ShiftType = Parsed.ShiftType ?? ShiftType.Day;
|
|
|
|
|
EmploymentType = Parsed.EmploymentType ?? EmploymentType.FullTime;
|
|
|
|
|
(StartTime, EndTime) = DefaultTimes(ShiftType);
|
|
|
|
|
ShiftDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1);
|
|
|
|
|
Negotiable = Parsed.PayNegotiable;
|
2026-06-03 06:26:54 +03:30
|
|
|
SharePercent = Parsed.SharePercent;
|
2026-06-04 00:19:32 +03:30
|
|
|
GenderRequirement = Parsed.Gender;
|
2026-06-03 01:43:55 +03:30
|
|
|
if (Parsed.PayAmount is not null) { PayAmount = Parsed.PayAmount; SalaryMin = Parsed.PayAmount; }
|
|
|
|
|
Description = Raw.RawText;
|
|
|
|
|
Title = Parsed.RoleName is not null ? $"استخدام {Parsed.RoleName}" : "موقعیت استخدامی";
|
2026-06-08 07:14:48 +03:30
|
|
|
|
2026-06-08 08:01:12 +03:30
|
|
|
// 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;
|
|
|
|
|
|
2026-06-08 07:14:48 +03:30
|
|
|
// 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}» — هنگام انتشار ساخته میشود.");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-03 01:43:55 +03:30
|
|
|
return Page();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<IActionResult> OnPostPublishAsync(int id)
|
|
|
|
|
{
|
|
|
|
|
Raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id);
|
|
|
|
|
if (Raw is null) return NotFound();
|
|
|
|
|
|
2026-06-08 08:01:12 +03:30
|
|
|
if (!await _db.Roles.AnyAsync(r => r.Id == RoleId))
|
2026-06-08 07:09:18 +03:30
|
|
|
{
|
2026-06-08 08:01:12 +03:30
|
|
|
Error = "یک نقش معتبر انتخاب کن.";
|
2026-06-08 07:09:18 +03:30
|
|
|
return RedirectToPage(new { id });
|
|
|
|
|
}
|
2026-06-08 08:01:12 +03:30
|
|
|
|
|
|
|
|
// «آماده به کار» — a worker offering themselves. No facility; publish a TalentListing.
|
|
|
|
|
if (Kind == ListingKind.Talent)
|
2026-06-08 07:09:18 +03:30
|
|
|
{
|
2026-06-08 08:01:12 +03:30
|
|
|
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 });
|
|
|
|
|
}
|
2026-06-08 11:10:19 +03:30
|
|
|
// Re-parse the raw text to recover all contact channels (phones/email/socials).
|
|
|
|
|
var roleNames = await _db.Roles.Select(r => r.Name).ToListAsync();
|
|
|
|
|
var parsedContacts = _parser
|
|
|
|
|
.Parse(Raw.RawText, roleNames, await CityNamesAsync(), await DistrictNamesAsync())
|
|
|
|
|
.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 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 });
|
|
|
|
|
}
|
2026-06-08 08:01:12 +03:30
|
|
|
var talent = new TalentListing
|
|
|
|
|
{
|
|
|
|
|
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,
|
2026-06-08 11:10:19 +03:30
|
|
|
Contacts = parsedContacts,
|
2026-06-08 08:01:12 +03:30
|
|
|
};
|
|
|
|
|
_db.TalentListings.Add(talent);
|
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
|
Raw.Status = RawListingStatus.Normalized;
|
|
|
|
|
Raw.LinkedTalentId = talent.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.
|
|
|
|
|
var facilityId = await ResolveFacilityIdAsync();
|
|
|
|
|
if (facilityId is null)
|
|
|
|
|
{
|
|
|
|
|
Error = "شهری برای ساخت مرکز موجود نیست؛ ابتدا یک شهر اضافه کن.";
|
2026-06-08 07:09:18 +03:30
|
|
|
return RedirectToPage(new { id });
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 11:56:07 +03:30
|
|
|
Shift? createdShift = null;
|
|
|
|
|
JobOpening? createdJob = null;
|
2026-06-03 01:43:55 +03:30
|
|
|
if (Kind == ListingKind.Shift)
|
|
|
|
|
{
|
|
|
|
|
var role = await _db.Roles.FindAsync(RoleId);
|
|
|
|
|
var shift = new Shift
|
|
|
|
|
{
|
2026-06-08 07:09:18 +03:30
|
|
|
FacilityId = facilityId.Value,
|
2026-06-03 01:43:55 +03:30
|
|
|
RoleId = RoleId,
|
|
|
|
|
Date = ShiftDate,
|
|
|
|
|
StartTime = StartTime,
|
|
|
|
|
EndTime = EndTime,
|
|
|
|
|
ShiftType = ShiftType,
|
|
|
|
|
SpecialtyRequired = role?.Name ?? "",
|
|
|
|
|
Description = Description,
|
2026-06-03 06:26:54 +03:30
|
|
|
PayType = Negotiable ? PayType.Negotiable
|
|
|
|
|
: (PayAmount is null && SharePercent is not null ? PayType.Percentage : PayType.PerShift),
|
2026-06-03 01:43:55 +03:30
|
|
|
PayAmount = Negotiable ? null : PayAmount,
|
2026-06-03 06:26:54 +03:30
|
|
|
SharePercent = Negotiable ? null : SharePercent,
|
2026-06-04 00:19:32 +03:30
|
|
|
GenderRequirement = GenderRequirement,
|
2026-06-03 01:43:55 +03:30
|
|
|
Status = ShiftStatus.Open,
|
|
|
|
|
Source = ShiftSource.Aggregated,
|
|
|
|
|
SourceUrl = Raw.SourceUrl,
|
|
|
|
|
};
|
|
|
|
|
_db.Shifts.Add(shift);
|
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
|
Raw.Status = RawListingStatus.Normalized;
|
|
|
|
|
Raw.LinkedShiftId = shift.Id;
|
2026-06-04 11:56:07 +03:30
|
|
|
createdShift = shift;
|
2026-06-03 01:43:55 +03:30
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
var job = new JobOpening
|
|
|
|
|
{
|
2026-06-08 07:09:18 +03:30
|
|
|
FacilityId = facilityId.Value,
|
2026-06-03 01:43:55 +03:30
|
|
|
RoleId = RoleId,
|
|
|
|
|
Title = string.IsNullOrWhiteSpace(Title) ? "موقعیت استخدامی" : Title.Trim(),
|
|
|
|
|
EmploymentType = EmploymentType,
|
|
|
|
|
SalaryMin = Negotiable ? null : SalaryMin,
|
|
|
|
|
SalaryMax = Negotiable ? null : SalaryMax,
|
2026-06-04 00:19:32 +03:30
|
|
|
GenderRequirement = GenderRequirement,
|
2026-06-03 01:43:55 +03:30
|
|
|
Description = Description,
|
|
|
|
|
Status = ShiftStatus.Open,
|
|
|
|
|
Source = ShiftSource.Aggregated,
|
|
|
|
|
SourceUrl = Raw.SourceUrl,
|
|
|
|
|
};
|
|
|
|
|
_db.JobOpenings.Add(job);
|
|
|
|
|
Raw.Status = RawListingStatus.Normalized;
|
2026-06-04 11:56:07 +03:30
|
|
|
createdJob = job;
|
2026-06-03 01:43:55 +03:30
|
|
|
}
|
|
|
|
|
await _db.SaveChangesAsync();
|
2026-06-04 11:56:07 +03:30
|
|
|
if (createdShift is not null) await _notify.NotifyNewShiftAsync(createdShift.Id);
|
|
|
|
|
if (createdJob is not null) await _notify.NotifyNewJobAsync(createdJob.Id);
|
2026-06-03 01:43:55 +03:30
|
|
|
return RedirectToPage("/Admin/Index");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<IActionResult> 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)),
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-08 08:01:12 +03:30
|
|
|
/// <summary>Placeholder facility name used when an ad doesn't name a real one.</summary>
|
|
|
|
|
private const string UnknownFacilityName = "نامشخص / ثبت نشده";
|
|
|
|
|
|
2026-06-08 07:09:18 +03:30
|
|
|
/// <summary>
|
2026-06-08 08:01:12 +03:30
|
|
|
/// 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.
|
2026-06-08 07:09:18 +03:30
|
|
|
/// </summary>
|
|
|
|
|
private async Task<int?> 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
|
|
|
|
|
|
2026-06-08 08:01:12 +03:30
|
|
|
// No facility named in the ad → use/create the shared placeholder.
|
|
|
|
|
var name = string.IsNullOrWhiteSpace(NewFacilityName) ? UnknownFacilityName : NewFacilityName.Trim();
|
|
|
|
|
|
2026-06-08 07:14:48 +03:30
|
|
|
// 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) return match.Id;
|
|
|
|
|
|
2026-06-08 07:09:18 +03:30
|
|
|
var facility = new Facility
|
|
|
|
|
{
|
|
|
|
|
Name = name,
|
|
|
|
|
CityId = cityId.Value,
|
|
|
|
|
Type = FacilityType.Hospital,
|
|
|
|
|
Verification = VerificationStatus.Unverified,
|
|
|
|
|
IsVerified = false,
|
|
|
|
|
};
|
|
|
|
|
_db.Facilities.Add(facility);
|
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
|
return facility.Id;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 01:43:55 +03:30
|
|
|
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();
|
2026-06-08 08:01:12 +03:30
|
|
|
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
|
2026-06-03 01:43:55 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Task<List<string>> CityNamesAsync() => _db.Cities.Select(c => c.Name).ToListAsync();
|
|
|
|
|
private Task<List<string>> DistrictNamesAsync() => _db.Districts.Select(d => d.Name).ToListAsync();
|
|
|
|
|
}
|