Compare commits

...

2 Commits

Author SHA1 Message Date
soroush.asadi 6cf7c6b573 Typeahead: search descriptions + show highlighted body snippet (fixes empty mmt dropdown)
CI/CD / CI · dotnet build (push) Successful in 2m6s
CI/CD / Deploy · hamkadr (push) Successful in 2m34s
The suggest endpoint only matched role/city/tags/facility, so a term that
lives only in the ad body (e.g. mmt) returned nothing and the dropdown
never opened — even though /Search found it. Now each type also ILIKEs the
description, and the dropdown's sub-line is a snippet windowed around the
match (client highlights it). Title is bold; body wraps to 2 lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:06:15 +03:30
soroush.asadi 1e96526bd9 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>
2026-06-08 22:03:09 +03:30
4 changed files with 138 additions and 98 deletions
@@ -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 });
} }
var talent = new TalentListing return list;
}
TalentListing? firstTalent = null;
foreach (var role in validRoles)
{ {
RoleId = RoleId, var t = new TalentListing
CityId = cityId.Value, {
RoleId = role.Id, CityId = cityId.Value,
PersonName = string.IsNullOrWhiteSpace(PersonName) ? null : PersonName.Trim(), PersonName = string.IsNullOrWhiteSpace(PersonName) ? null : PersonName.Trim(),
YearsExperience = YearsExperience, YearsExperience = YearsExperience, IsLicensed = IsLicensed,
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,
Status = ShiftStatus.Open,
Source = ShiftSource.Aggregated,
SourceUrl = Raw.SourceUrl,
Contacts = parsedContacts,
Tags = string.Join(" ", reparsed.Tags.Distinct()),
}; };
_db.TalentListings.Add(talent); _db.TalentListings.Add(t);
firstTalent ??= t;
}
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>();
foreach (var role in validRoles)
{
var shift = new Shift var shift = new Shift
{ {
FacilityId = facilityId.Value, FacilityId = facilityId.Value, RoleId = role.Id,
RoleId = RoleId, Date = ShiftDate, StartTime = StartTime, EndTime = EndTime, ShiftType = ShiftType,
Date = ShiftDate, SpecialtyRequired = role.Name, Description = Description,
StartTime = StartTime, PayType = payType, PayAmount = payAmt, SharePercent = sharePct,
EndTime = EndTime, GenderRequirement = GenderRequirement, Status = ShiftStatus.Open,
ShiftType = ShiftType, Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl,
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); _db.Shifts.Add(shift);
created.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 created = new List<JobOpening>();
foreach (var role in validRoles)
{ {
var job = new JobOpening var job = new JobOpening
{ {
FacilityId = facilityId.Value, FacilityId = facilityId.Value, RoleId = role.Id,
RoleId = RoleId, // With several roles, give each a role-specific title; with one, honor the typed title.
Title = string.IsNullOrWhiteSpace(Title) ? "موقعیت استخدامی" : Title.Trim(), Title = many || string.IsNullOrWhiteSpace(Title) ? $"استخدام {role.Name}" : Title.Trim(),
EmploymentType = EmploymentType, EmploymentType = EmploymentType,
SalaryMin = Negotiable ? null : SalaryMin, SalaryMin = Negotiable ? null : SalaryMin, SalaryMax = Negotiable ? null : SalaryMax,
SalaryMax = Negotiable ? null : SalaryMax, GenderRequirement = GenderRequirement, Description = Description,
GenderRequirement = GenderRequirement, Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl,
Description = Description,
Status = ShiftStatus.Open,
Source = ShiftSource.Aggregated,
SourceUrl = Raw.SourceUrl,
}; };
_db.JobOpenings.Add(job); _db.JobOpenings.Add(job);
Raw.Status = RawListingStatus.Normalized; created.Add(job);
createdJob = job;
} }
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
if (createdShift is not null) await _notify.NotifyNewShiftAsync(createdShift.Id); Raw.Status = RawListingStatus.Normalized;
if (createdJob is not null) await _notify.NotifyNewJobAsync(createdJob.Id); await _db.SaveChangesAsync();
foreach (var j in created) await _notify.NotifyNewJobAsync(j.Id);
}
return RedirectToPage("/Admin/Index"); return RedirectToPage("/Admin/Index");
} }
+34 -9
View File
@@ -369,21 +369,46 @@ app.MapGet("/search/suggest", async (string? q, AppDbContext db) =>
var jobCut = JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc; var jobCut = JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc;
var talentCut = JobsMedical.Web.Services.Scraping.ListingPolicy.TalentCutoffUtc; var talentCut = JobsMedical.Web.Services.Scraping.ListingPolicy.TalentCutoffUtc;
var shifts = await db.Shifts // Plain (un-marked) snippet around the first occurrence of the term — the client highlights it.
static string? Snip(string? text, string term, string? fallback)
{
if (!string.IsNullOrWhiteSpace(text))
{
var flat = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ").Trim();
var i = flat.IndexOf(term, StringComparison.OrdinalIgnoreCase);
if (i >= 0)
{
var start = Math.Max(0, i - 40);
var end = Math.Min(flat.Length, i + term.Length + 40);
return (start > 0 ? "…" : "") + flat.Substring(start, end - start) + (end < flat.Length ? "…" : "");
}
}
return fallback;
}
var shiftRows = await db.Shifts
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today && .Where(s => s.Status == ShiftStatus.Open && s.Date >= today &&
(EF.Functions.ILike(s.Facility.Name, like) || EF.Functions.ILike(s.Role.Name, like) || EF.Functions.ILike(s.SpecialtyRequired, like))) (EF.Functions.ILike(s.Facility.Name, like) || EF.Functions.ILike(s.Role.Name, like)
|| EF.Functions.ILike(s.SpecialtyRequired, like) || EF.Functions.ILike(s.Description ?? "", like)))
.OrderByDescending(s => s.CreatedAt).Take(5) .OrderByDescending(s => s.CreatedAt).Take(5)
.Select(s => new SuggestItem("شیفت", s.Role.Name + " — " + s.Facility.Name, "/Shifts/Details/" + s.Id, s.Facility.City.Name + " · " + s.SpecialtyRequired)).ToListAsync(); .Select(s => new { s.Id, Role = s.Role.Name, Fac = s.Facility.Name, City = s.Facility.City.Name, s.Description }).ToListAsync();
var jobs = await db.JobOpenings var shifts = shiftRows.Select(s => new SuggestItem("شیفت", s.Role + " — " + s.Fac, "/Shifts/Details/" + s.Id, Snip(s.Description, term, s.City))).ToList();
var jobRows = await db.JobOpenings
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCut && .Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCut &&
(EF.Functions.ILike(j.Title, like) || EF.Functions.ILike(j.Facility.Name, like) || EF.Functions.ILike(j.Role.Name, like))) (EF.Functions.ILike(j.Title, like) || EF.Functions.ILike(j.Facility.Name, like)
|| EF.Functions.ILike(j.Role.Name, like) || EF.Functions.ILike(j.Description ?? "", like)))
.OrderByDescending(j => j.CreatedAt).Take(5) .OrderByDescending(j => j.CreatedAt).Take(5)
.Select(j => new SuggestItem("استخدام", j.Title, "/Jobs/Details/" + j.Id, j.Facility.Name + " · " + j.Facility.City.Name)).ToListAsync(); .Select(j => new { j.Id, j.Title, Fac = j.Facility.Name, City = j.Facility.City.Name, j.Description }).ToListAsync();
var talent = await db.TalentListings var jobs = jobRows.Select(j => new SuggestItem("استخدام", j.Title, "/Jobs/Details/" + j.Id, Snip(j.Description, term, j.Fac + " · " + j.City))).ToList();
var talentRows = await db.TalentListings
.Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= talentCut && .Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= talentCut &&
(EF.Functions.ILike(t.Tags ?? "", like) || EF.Functions.ILike(t.Role.Name, like) || EF.Functions.ILike(t.City.Name, like) || EF.Functions.ILike(t.PersonName ?? "", like))) (EF.Functions.ILike(t.Tags ?? "", like) || EF.Functions.ILike(t.Role.Name, like) || EF.Functions.ILike(t.City.Name, like)
|| EF.Functions.ILike(t.PersonName ?? "", like) || EF.Functions.ILike(t.Description ?? "", like)))
.OrderByDescending(t => t.CreatedAt).Take(5) .OrderByDescending(t => t.CreatedAt).Take(5)
.Select(t => new SuggestItem("آماده‌به‌کار", (t.PersonName ?? t.Role.Name) + " — " + t.City.Name, "/Talent/Details/" + t.Id, t.Tags)).ToListAsync(); .Select(t => new { t.Id, t.PersonName, Role = t.Role.Name, City = t.City.Name, t.Tags, t.Description }).ToListAsync();
var talent = talentRows.Select(t => new SuggestItem("آماده‌به‌کار", (t.PersonName ?? t.Role) + " — " + t.City, "/Talent/Details/" + t.Id, Snip(t.Description ?? t.Tags, term, t.Tags))).ToList();
// round-robin merge so all three types appear, capped at 5 // round-robin merge so all three types appear, capped at 5
var merged = new List<SuggestItem>(); var merged = new List<SuggestItem>();
+11 -3
View File
@@ -294,9 +294,10 @@ mark { background: #fff3bf; color: inherit; padding: 0 2px; border-radius: 3px;
.nav-search-results a:hover { background: var(--primary-soft); } .nav-search-results a:hover { background: var(--primary-soft); }
.nav-search-results .ns-type { flex: 0 0 auto; font-size: 11px; font-weight: 700; color: var(--primary-dark); .nav-search-results .ns-type { flex: 0 0 auto; font-size: 11px; font-weight: 700; color: var(--primary-dark);
background: var(--primary-soft); border-radius: 6px; padding: 2px 7px; } background: var(--primary-soft); border-radius: 6px; padding: 2px 7px; }
.nav-search-results .ns-text { flex: 1; display: flex; flex-direction: column; min-width: 0; } .nav-search-results .ns-text { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.nav-search-results .ns-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .nav-search-results .ns-label { font-weight: 800; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-search-results .ns-sub { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .nav-search-results .ns-sub { font-size: 11.5px; color: var(--muted); line-height: 1.5;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
/* ES-style matched snippet shown under a search-result card */ /* ES-style matched snippet shown under a search-result card */
.search-snippet { font-size: 12.5px; color: var(--muted); line-height: 1.6; margin: 4px 0 2px; .search-snippet { font-size: 12.5px; color: var(--muted); line-height: 1.6; margin: 4px 0 2px;
background: var(--bg); border-inline-start: 3px solid var(--primary-soft); padding: 5px 9px; border-radius: 6px; } background: var(--bg); border-inline-start: 3px solid var(--primary-soft); padding: 5px 9px; border-radius: 6px; }
@@ -430,6 +431,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; }