e2011d335e
- Jobs now keep the AI-extracted salary (d.PayAmount ?? parsed.PayAmount); they previously used only the parser figure, so every aggregated opening showed «توافقی». - Geocoder also scans the ad body, so Tehran ads that name a neighbourhood only in free text («… در سهروردی») get an approximate map point. - New BackfillCoordsAsync (+ admin button): fills missing coords on existing aggregated listings from their stored text, in place — no ID/URL churn, SEO-safe. - New PurgeInvalidAggregatedAsync + DedupeJobsAsync (+ admin button): in-place removal of out-of-scope (domestic/promo/spam) aggregated jobs/shifts and duplicate job reposts, keeping valid listings' IDs. - Jobs detail page always renders the location card (matches Shifts) instead of hiding it when coords are missing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
220 lines
12 KiB
Plaintext
220 lines
12 KiB
Plaintext
@page "{id:int}"
|
|
@model JobsMedical.Web.Pages.Jobs.DetailsModel
|
|
@{
|
|
var j = Model.Job!;
|
|
var f = j.Facility!;
|
|
var hasFac = JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f); // false for the «نامشخص» placeholder
|
|
var jobContacts = (j.Contacts ?? new List<JobsMedical.Web.Models.ContactMethod>()).ToList();
|
|
// Map: listing's own approx coords (aggregated) then facility's; aggregated = approximate area.
|
|
var mapLat = j.Lat ?? f.Lat;
|
|
var mapLng = j.Lng ?? f.Lng;
|
|
var mapApprox = j.Source == JobsMedical.Web.Models.ShiftSource.Aggregated;
|
|
ViewData["Title"] = j.Title;
|
|
ViewData["Description"] = hasFac
|
|
? $"{j.Title} در {f.Name}، {f.City?.Name}. موقعیت استخدامی برای {j.Role?.Name}."
|
|
: $"{j.Title} در {f.City?.Name}. موقعیت استخدامی برای {j.Role?.Name}.";
|
|
// Don't let Google index filled/expired openings (avoids dead "Job for jobs" results).
|
|
if (j.Status != JobsMedical.Web.Models.ShiftStatus.Open) ViewData["NoIndex"] = true;
|
|
string empLabel = j.EmploymentType switch
|
|
{
|
|
EmploymentType.FullTime => "تماموقت",
|
|
EmploymentType.PartTime => "پارهوقت",
|
|
EmploymentType.Contract => "قراردادی",
|
|
_ => "طرح",
|
|
};
|
|
string salary;
|
|
if (j.SalaryMin is null && j.SalaryMax is null) salary = "توافقی";
|
|
else if (j.SalaryMin == j.SalaryMax) salary = JalaliDate.Toman(j.SalaryMin) + " ماهانه";
|
|
else salary = $"از {JalaliDate.ToPersianDigits((j.SalaryMin ?? 0).ToString("#,0"))} تا {JalaliDate.Toman(j.SalaryMax)} ماهانه";
|
|
var crumbs = new List<JobsMedical.Web.Services.Crumb> { new("خانه", "/"), new("استخدام", "/Jobs") };
|
|
if (j.Role is not null) crumbs.Add(new(j.Role.Name, "/استخدام/" + JobsMedical.Web.Services.SeoSlug.Of(j.Role.Name)));
|
|
crumbs.Add(new(j.Title, null));
|
|
}
|
|
|
|
<div class="page-head">
|
|
<div class="container">
|
|
<partial name="_Breadcrumbs" model="crumbs" />
|
|
<div class="row" style="display:flex; gap:10px; align-items:center;">
|
|
<span class="badge badge-job">@empLabel</span>
|
|
@if (j.Role is not null) { <span class="badge badge-type">@j.Role.Name</span> }
|
|
@if (f.IsVerified) { <span class="badge badge-verified">✓ مرکز تأیید شده</span> }
|
|
</div>
|
|
<h1 style="margin-top:8px;">@j.Title</h1>
|
|
<p class="muted">@(hasFac ? "🏥 " + f.Name + " — " : "")📍 @f.City?.Name@(f.District is not null ? "، " + f.District.Name : "")</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="container section has-action-bar">
|
|
<div class="detail-grid">
|
|
<div>
|
|
@if (Model.ShowContact)
|
|
{
|
|
<div class="contact-reveal" style="margin-bottom:16px;">
|
|
<h4>✓ راههای ارتباطی</h4>
|
|
@if (jobContacts.Count > 0)
|
|
{
|
|
@* Numbers from THIS ad (aggregated) — the correct, per-listing contacts. *@
|
|
<partial name="_ContactList" model="jobContacts" />
|
|
}
|
|
else if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f) && (!string.IsNullOrEmpty(f.Phone) || !string.IsNullOrEmpty(f.BaleId)))
|
|
{
|
|
@if (!string.IsNullOrEmpty(f.Phone))
|
|
{
|
|
<div class="contact-row">
|
|
<span class="c-meta"><span class="c-type">📞 تلفن مرکز</span><span class="c-val" dir="ltr">@f.Phone</span></span>
|
|
<a class="btn btn-accent" href="tel:@f.Phone">تماس</a>
|
|
</div>
|
|
}
|
|
@if (!string.IsNullOrEmpty(f.BaleId))
|
|
{
|
|
<div class="contact-row">
|
|
<span class="c-meta"><span class="c-type">💬 بله</span><span class="c-val" dir="ltr">@f.BaleId</span></span>
|
|
<a class="btn btn-outline" href="https://ble.ir/@f.BaleId" target="_blank" rel="noopener">باز کردن</a>
|
|
</div>
|
|
}
|
|
}
|
|
else
|
|
{
|
|
<p class="muted" style="margin:0;">شمارهای ثبت نشده است.</p>
|
|
}
|
|
</div>
|
|
}
|
|
@if (Model.Saved)
|
|
{
|
|
<div class="alert alert-success">✓ این موقعیت ذخیره شد.</div>
|
|
}
|
|
|
|
<div class="card card-pad">
|
|
<h3 style="margin-top:0;">مشخصات موقعیت</h3>
|
|
<div class="info-row"><span class="k">نوع همکاری</span><span class="v">@empLabel</span></div>
|
|
<div class="info-row"><span class="k">نقش</span><span class="v">@j.Role?.Name</span></div>
|
|
@if (j.GenderRequirement != Gender.Any)
|
|
{
|
|
<div class="info-row"><span class="k">جنسیت</span><span class="v">@JalaliDate.GenderLabel(j.GenderRequirement)</span></div>
|
|
}
|
|
<div class="info-row"><span class="k">حقوق ماهانه</span><span class="v" style="color:var(--primary-dark)">@salary</span></div>
|
|
</div>
|
|
|
|
@if (!string.IsNullOrEmpty(j.Description))
|
|
{
|
|
<div class="card card-pad" style="margin-top:16px;">
|
|
<h3 style="margin-top:0;">شرح موقعیت</h3>
|
|
<p class="muted" style="margin:0;">@j.Description</p>
|
|
</div>
|
|
}
|
|
@if (!string.IsNullOrEmpty(j.Requirements))
|
|
{
|
|
<div class="card card-pad" style="margin-top:16px;">
|
|
<h3 style="margin-top:0;">شرایط احراز</h3>
|
|
<p class="muted" style="margin:0;">@j.Requirements</p>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<aside>
|
|
<div class="card card-pad">
|
|
<div class="pay" style="font-size:19px; margin-bottom:6px; color:var(--primary-dark); font-weight:800;">@salary</div>
|
|
<p class="muted" style="font-size:13px; margin-top:0;">@empLabel</p>
|
|
<div class="aside-apply">
|
|
<button type="button" class="btn btn-accent btn-block btn-lg contact-trigger"
|
|
data-contact-type="job" data-contact-id="@j.Id">📞 اعلام تمایل و مشاهده راه ارتباطی</button>
|
|
</div>
|
|
<div style="display:flex; gap:8px; margin-top:8px;">
|
|
<form method="post" style="flex:1;">
|
|
<button type="submit" asp-page-handler="Save" asp-route-id="@j.Id" class="btn btn-outline btn-block">♡ ذخیره</button>
|
|
</form>
|
|
<form method="post" style="flex:1;">
|
|
<button type="submit" asp-page-handler="Dismiss" asp-route-id="@j.Id" class="btn btn-outline btn-block">✕ علاقهمند نیستم</button>
|
|
</form>
|
|
</div>
|
|
@if (Model.Reported)
|
|
{
|
|
<p class="muted" style="font-size:12px; margin:8px 0 0;">✓ گزارش شما ثبت شد. متشکریم.</p>
|
|
}
|
|
else
|
|
{
|
|
<details style="margin-top:10px;">
|
|
<summary class="muted" style="font-size:12px; cursor:pointer;">گزارش تخلف یا اطلاعات نادرست</summary>
|
|
<form method="post" action="/report" style="margin-top:8px;">
|
|
<input type="hidden" name="targetType" value="Job" />
|
|
<input type="hidden" name="targetId" value="@j.Id" />
|
|
<input type="hidden" name="label" value="@j.Title" />
|
|
<input type="hidden" name="returnUrl" value="/Jobs/Details/@j.Id" />
|
|
<textarea name="reason" rows="2" placeholder="دلیل گزارش..." required></textarea>
|
|
<button type="submit" class="btn btn-outline btn-block" style="margin-top:6px;">ارسال گزارش</button>
|
|
</form>
|
|
</details>
|
|
@if (j.Facility is not null)
|
|
{
|
|
<details style="margin-top:6px;">
|
|
<summary class="muted" style="font-size:12px; cursor:pointer;">شکایت از این @(hasFac ? "مرکز (" + j.Facility.Name + ")" : "آگهی")</summary>
|
|
<form method="post" action="/report" style="margin-top:8px;">
|
|
<input type="hidden" name="targetType" value="Facility" />
|
|
<input type="hidden" name="targetId" value="@j.Facility.Id" />
|
|
<input type="hidden" name="label" value="@j.Facility.Name" />
|
|
<input type="hidden" name="returnUrl" value="/Jobs/Details/@j.Id" />
|
|
<textarea name="reason" rows="2" placeholder="شکایت یا گزارش درباره این مرکز..." required></textarea>
|
|
<button type="submit" class="btn btn-outline btn-block" style="margin-top:6px;">ثبت شکایت</button>
|
|
</form>
|
|
</details>
|
|
}
|
|
}
|
|
</div>
|
|
|
|
<div class="card card-pad" style="margin-top:16px;">
|
|
<h3 style="margin-top:0;">موقعیت مکانی</h3>
|
|
@if (mapLat is not null && mapLng is not null)
|
|
{
|
|
var latS = mapLat.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
|
var lngS = mapLng.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
|
@if (!string.IsNullOrEmpty(Model.MapKey))
|
|
{
|
|
<div id="facmap" data-lat="@latS" data-lng="@lngS" data-approx="@(mapApprox ? "true" : "false")" style="height:200px; border-radius:10px; overflow:hidden; border:1px solid var(--line);"></div>
|
|
}
|
|
else
|
|
{
|
|
<div style="background:var(--primary-soft); border-radius:10px; height:140px; display:grid; place-items:center; color:var(--primary-dark); text-align:center; padding:10px;">
|
|
🗺️<br /><small class="muted" dir="ltr">@latS، @lngS</small>
|
|
</div>
|
|
}
|
|
@if (mapApprox)
|
|
{
|
|
<p class="muted" style="font-size:12px; margin:8px 0 0;">📍 محدودهٔ تقریبی (برگرفته از آگهی منبع)؛ موقعیت دقیق نیست.</p>
|
|
}
|
|
<a class="btn btn-outline btn-block" style="margin-top:8px;" target="_blank" rel="noopener"
|
|
href="https://neshan.org/maps/@(latS),@(lngS),16z">مسیریابی در نشان</a>
|
|
}
|
|
else
|
|
{
|
|
<p class="muted" style="margin:0;">مختصات این آگهی ثبت نشده است.</p>
|
|
}
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
|
|
@* Sticky bottom action bar — mobile only. *@
|
|
<div class="mobile-action-bar">
|
|
<button type="button" class="btn btn-accent btn-lg cta-main contact-trigger"
|
|
data-contact-type="job" data-contact-id="@j.Id">📞 اعلام تمایل و مشاهده تماس</button>
|
|
<form method="post">
|
|
<button type="submit" asp-page-handler="Save" asp-route-id="@j.Id" class="btn btn-outline btn-lg" aria-label="ذخیره" title="ذخیره">♡</button>
|
|
</form>
|
|
</div>
|
|
|
|
@if (!string.IsNullOrEmpty(Model.MapKey) && mapLat is not null)
|
|
{
|
|
<partial name="_NeshanMap" model="Model.MapKey" />
|
|
}
|
|
|
|
@section Head {
|
|
@* Only emit JobPosting structured data for a real named employer — Google for Jobs rejects a
|
|
placeholder/empty hiringOrganization (most aggregated ads have no named center). *@
|
|
@{ var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
|
|
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.Breadcrumb(crumbs, bu) + "</script>")
|
|
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f))
|
|
{
|
|
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.JobPosting(j, bu) + "</script>")
|
|
}
|
|
}
|