[Facilities] Public facility pages + ratings & reviews
New /Facilities/Details public page: verified badge, info, Neshan map + directions, the facility's open shifts & jobs, and a complaint form; facility cards on /Facilities link to it. Ratings & reviews: Review model (1-5 stars + comment, one per user/facility, unique index, migration); logged-in users rate/review on the facility page; average + count shown in the header and the review list; admins moderate (hide/delete) at /Admin/Reviews. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
@page "{id:int}"
|
||||
@model JobsMedical.Web.Pages.Facilities.DetailsModel
|
||||
@{
|
||||
var f = Model.Facility!;
|
||||
ViewData["Title"] = f.Name;
|
||||
ViewData["Description"] = $"{f.Name} — {f.City?.Name}. شیفتها و موقعیتهای استخدامی کادر درمان در همکادر.";
|
||||
string TypeLabel(FacilityType t) => t switch
|
||||
{
|
||||
FacilityType.Hospital => "بیمارستان",
|
||||
FacilityType.Clinic => "کلینیک",
|
||||
_ => "درمانگاه",
|
||||
};
|
||||
}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="container">
|
||||
<h1 style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
|
||||
@f.Name
|
||||
@if (f.IsVerified) { <span class="badge badge-verified">✓ تأیید شده</span> }
|
||||
</h1>
|
||||
<p class="muted">
|
||||
@TypeLabel(f.Type) · 📍 @f.City?.Name@(f.District is not null ? "، " + f.District.Name : "")
|
||||
@if (Model.RatingCount > 0)
|
||||
{
|
||||
<text> · <span style="color:#f59e0b;">★</span> @JalaliDate.ToPersianDigits(Model.AvgRating.ToString("0.#")) (@JalaliDate.ToPersianDigits(Model.RatingCount.ToString()) نظر)</text>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container section">
|
||||
@if (Model.Reported) { <div class="alert alert-success">✓ گزارش شما ثبت شد. متشکریم.</div> }
|
||||
|
||||
<div class="layout-2">
|
||||
<div>
|
||||
@if (Model.Shifts.Count == 0 && Model.Jobs.Count == 0)
|
||||
{
|
||||
<div class="card empty-state">در حال حاضر فرصت بازی در این مرکز ثبت نشده است.</div>
|
||||
}
|
||||
@if (Model.Shifts.Count > 0)
|
||||
{
|
||||
<div class="section-head"><h2>شیفتهای باز (@JalaliDate.ToPersianDigits(Model.Shifts.Count.ToString()))</h2></div>
|
||||
<div class="grid grid-3">
|
||||
@foreach (var s in Model.Shifts) { <partial name="_ShiftCard" model="s" /> }
|
||||
</div>
|
||||
}
|
||||
@if (Model.Jobs.Count > 0)
|
||||
{
|
||||
<div class="section-head" style="margin-top:18px;"><h2>موقعیتهای استخدامی (@JalaliDate.ToPersianDigits(Model.Jobs.Count.ToString()))</h2></div>
|
||||
<div class="grid grid-3">
|
||||
@foreach (var j in Model.Jobs) { <partial name="_JobCard" model="j" /> }
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="section-head" style="margin-top:22px;"><h2>نظرات و امتیاز کاربران</h2></div>
|
||||
@if (Model.ReviewMsg is not null) { <div class="alert alert-success">@Model.ReviewMsg</div> }
|
||||
|
||||
<div class="card card-pad" style="margin-bottom:14px;">
|
||||
@if (Model.CanReview)
|
||||
{
|
||||
<form method="post" asp-page-handler="Review" asp-route-id="@f.Id">
|
||||
<label style="font-weight:700;">@(Model.AlreadyReviewed ? "ویرایش نظر شما" : "ثبت نظر و امتیاز")</label>
|
||||
<div class="star-input" style="margin:8px 0;">
|
||||
@for (var i = 5; i >= 1; i--)
|
||||
{
|
||||
<input type="radio" name="stars" id="st@(i)" value="@i" @(i == 5 ? "checked" : "") />
|
||||
<label for="st@(i)" title="@JalaliDate.ToPersianDigits(i.ToString())">★</label>
|
||||
}
|
||||
</div>
|
||||
<textarea name="comment" rows="2" placeholder="تجربهات از همکاری با این مرکز..."></textarea>
|
||||
<button type="submit" class="btn btn-accent" style="margin-top:8px;">ثبت نظر</button>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted" style="margin:0;">برای ثبت نظر <a asp-page="/Account/Login" asp-route-returnUrl="/Facilities/Details/@f.Id">وارد شو</a>.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (Model.Reviews.Count == 0)
|
||||
{
|
||||
<p class="muted">هنوز نظری ثبت نشده است.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var rv in Model.Reviews)
|
||||
{
|
||||
<div class="card card-pad" style="margin-bottom:8px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<strong>@rv.Who</strong>
|
||||
<span style="color:#f59e0b; letter-spacing:2px;">@(new string('★', rv.Stars))<span style="color:var(--line);">@(new string('★', 5 - rv.Stars))</span></span>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(rv.Comment)) { <p style="margin:6px 0 0;">@rv.Comment</p> }
|
||||
<p class="muted" style="font-size:12px; margin:6px 0 0;">@JalaliDate.ToLongDate(DateOnly.FromDateTime(rv.When))</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<aside>
|
||||
<div class="card card-pad">
|
||||
<h3 style="margin-top:0;">اطلاعات مرکز</h3>
|
||||
<div class="info-row"><span class="k">نوع</span><span class="v">@TypeLabel(f.Type)</span></div>
|
||||
<div class="info-row"><span class="k">شهر</span><span class="v">@f.City?.Name</span></div>
|
||||
@if (f.District is not null) { <div class="info-row"><span class="k">محله</span><span class="v">@f.District.Name</span></div> }
|
||||
@if (!string.IsNullOrEmpty(f.Address)) { <div class="info-row"><span class="k">آدرس</span><span class="v">@f.Address</span></div> }
|
||||
<div class="info-row"><span class="k">وضعیت</span><span class="v">@(f.IsVerified ? "✓ تأییدشده" : "تأیید نشده")</span></div>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad" style="margin-top:16px;">
|
||||
<h3 style="margin-top:0;">موقعیت مکانی</h3>
|
||||
@if (f.Lat is not null && f.Lng is not null)
|
||||
{
|
||||
var latS = f.Lat.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var lngS = f.Lng.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
@if (!string.IsNullOrEmpty(Model.MapKey))
|
||||
{
|
||||
<div id="facmap" data-lat="@latS" data-lng="@lngS" style="height:200px; border-radius:10px; overflow:hidden; border:1px solid var(--line);"></div>
|
||||
}
|
||||
<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>
|
||||
|
||||
<div class="card card-pad" style="margin-top:16px;">
|
||||
<details>
|
||||
<summary class="muted" style="font-size:13px; cursor:pointer;">شکایت از این مرکز</summary>
|
||||
<form method="post" action="/report" style="margin-top:8px;">
|
||||
<input type="hidden" name="targetType" value="Facility" />
|
||||
<input type="hidden" name="targetId" value="@f.Id" />
|
||||
<input type="hidden" name="label" value="@f.Name" />
|
||||
<input type="hidden" name="returnUrl" value="/Facilities/Details/@f.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>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.MapKey) && Model.Facility?.Lat is not null)
|
||||
{
|
||||
<partial name="_NeshanMap" model="Model.MapKey" />
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Security.Claims;
|
||||
using JobsMedical.Web.Data;
|
||||
using JobsMedical.Web.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobsMedical.Web.Pages.Facilities;
|
||||
|
||||
public class DetailsModel : PageModel
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly JobsMedical.Web.Services.Scraping.SettingsService _settings;
|
||||
|
||||
public DetailsModel(AppDbContext db, JobsMedical.Web.Services.Scraping.SettingsService settings)
|
||||
{
|
||||
_db = db;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public Facility? Facility { get; private set; }
|
||||
public List<Shift> Shifts { get; private set; } = new();
|
||||
public List<JobOpening> Jobs { get; private set; } = new();
|
||||
public string? MapKey { get; private set; }
|
||||
public bool Reported { get; private set; }
|
||||
|
||||
public record ReviewRow(string Who, int Stars, string? Comment, DateTime When);
|
||||
public List<ReviewRow> Reviews { get; private set; } = new();
|
||||
public double AvgRating { get; private set; }
|
||||
public int RatingCount { get; private set; }
|
||||
public bool CanReview { get; private set; } // logged in & not yet reviewed
|
||||
public bool AlreadyReviewed { get; private set; }
|
||||
[TempData] public string? ReviewMsg { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(int id)
|
||||
{
|
||||
Facility = await _db.Facilities.Include(f => f.City).Include(f => f.District)
|
||||
.FirstOrDefaultAsync(f => f.Id == id);
|
||||
if (Facility is null) return NotFound();
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
Shifts = await _db.Shifts.Include(s => s.Role)
|
||||
.Where(s => s.FacilityId == id && s.Status == ShiftStatus.Open && s.Date >= today)
|
||||
.OrderBy(s => s.Date).Take(12).ToListAsync();
|
||||
Jobs = await _db.JobOpenings.Include(j => j.Role)
|
||||
.Where(j => j.FacilityId == id && j.Status == ShiftStatus.Open)
|
||||
.OrderByDescending(j => j.CreatedAt).Take(12).ToListAsync();
|
||||
|
||||
MapKey = (await _settings.GetAsync()).NeshanMapKey;
|
||||
Reported = Request.Query["reported"] == "1";
|
||||
|
||||
await LoadReviewsAsync(id);
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostReviewAsync(int id, int stars, string? comment)
|
||||
{
|
||||
if (User.Identity?.IsAuthenticated != true)
|
||||
return RedirectToPage("/Account/Login", new { returnUrl = $"/Facilities/Details/{id}" });
|
||||
|
||||
var uid = int.Parse(User.FindFirstValue(System.Security.Claims.ClaimTypes.NameIdentifier)!);
|
||||
if (!await _db.Facilities.AnyAsync(f => f.Id == id)) return NotFound();
|
||||
stars = Math.Clamp(stars, 1, 5);
|
||||
|
||||
var existing = await _db.Reviews.FirstOrDefaultAsync(r => r.FacilityId == id && r.UserId == uid);
|
||||
if (existing is null)
|
||||
_db.Reviews.Add(new Review { FacilityId = id, UserId = uid, Stars = stars, Comment = comment?.Trim() });
|
||||
else { existing.Stars = stars; existing.Comment = comment?.Trim(); existing.CreatedAt = DateTime.UtcNow; }
|
||||
await _db.SaveChangesAsync();
|
||||
ReviewMsg = "نظر شما ثبت شد. متشکریم.";
|
||||
return RedirectToPage(new { id });
|
||||
}
|
||||
|
||||
private async Task LoadReviewsAsync(int id)
|
||||
{
|
||||
var rows = await _db.Reviews.Include(r => r.User)
|
||||
.Where(r => r.FacilityId == id && r.IsApproved)
|
||||
.OrderByDescending(r => r.CreatedAt).ToListAsync();
|
||||
RatingCount = rows.Count;
|
||||
AvgRating = rows.Count > 0 ? Math.Round(rows.Average(r => r.Stars), 1) : 0;
|
||||
Reviews = rows.Take(20).Select(r => new ReviewRow(
|
||||
r.User.FullName ?? "کاربر", r.Stars, r.Comment, r.CreatedAt)).ToList();
|
||||
|
||||
if (User.Identity?.IsAuthenticated == true &&
|
||||
int.TryParse(User.FindFirstValue(System.Security.Claims.ClaimTypes.NameIdentifier), out var uid))
|
||||
{
|
||||
AlreadyReviewed = rows.Any(r => r.UserId == uid)
|
||||
|| await _db.Reviews.AnyAsync(r => r.FacilityId == id && r.UserId == uid);
|
||||
CanReview = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="grid grid-3">
|
||||
@foreach (var row in Model.Rows)
|
||||
{
|
||||
<div class="card card-pad">
|
||||
<a class="card card-pad" asp-page="/Facilities/Details" asp-route-id="@row.Facility.Id" style="display:block;">
|
||||
<div class="row" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<span class="facility" style="font-weight:800; font-size:16px;">@row.Facility.Name</span>
|
||||
@if (row.Facility.IsVerified)
|
||||
@@ -34,10 +34,9 @@
|
||||
<span class="pay" style="color:var(--primary-dark); font-weight:800;">
|
||||
@JalaliDate.ToPersianDigits(row.OpenShifts.ToString()) شیفت باز
|
||||
</span>
|
||||
<a class="btn btn-outline" style="padding:6px 14px;"
|
||||
asp-page="/Calendar/Index" asp-route-FacilityId="@row.Facility.Id">تقویم</a>
|
||||
<span class="btn btn-outline" style="padding:6px 14px;">مشاهده مرکز</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user