Admin: role merge tool + usage list (taxonomy hygiene)
CI/CD / CI · dotnet build (push) Successful in 2m38s
CI/CD / Deploy · hamkadr (push) Successful in 2m7s

New /Admin/Roles screen lists every role with its shift/job/talent usage and lets
an admin merge a duplicate role into another — reassigns all listings (the Restrict
FKs) plus preferences/alerts/profiles to the target, then deletes the source — or
toggle a role's visibility. Linked from the admin panel nav (🏷️ نقش‌ها). Lets you
clean up dynamic-ingestion sprawl («کمک‌یار»→«کمک بهیار») without DB surgery.

Improvement 7 of the backlog (data).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-20 19:21:23 +03:30
parent 5e1b2ee979
commit cdca4ad264
3 changed files with 160 additions and 0 deletions
@@ -0,0 +1,84 @@
@page
@model JobsMedical.Web.Pages.Admin.RolesModel
@{
ViewData["Title"] = "نقش‌ها و دسته‌بندی";
string P(int n) => JalaliDate.ToPersianDigits(n.ToString());
}
<div class="page-head">
<div class="container">
<h1>نقش‌ها و دسته‌بندی</h1>
<p class="muted"><a asp-page="/Admin/Index">← صف بررسی</a> — ادغام نقش‌های تکراری و مدیریت تاکسونومی.</p>
</div>
</div>
<div class="container section">
@if (Model.Message is not null)
{
<div class="alert alert-success">✓ @Model.Message</div>
}
<div class="card card-pad" style="margin-bottom:16px;">
<h3 style="margin-top:0;">ادغام نقش</h3>
<p class="muted" style="font-size:12.5px; margin-top:0;">همهٔ آگهی‌ها و علاقه‌مندی‌های «نقش مبدأ» به «نقش مقصد» منتقل و نقش مبدأ حذف می‌شود. این کار بازگشت‌ناپذیر است.</p>
<form method="post" asp-page-handler="Merge"
onsubmit="return confirm('ادغام انجام شود؟ این کار بازگشت‌ناپذیر است.');"
style="display:flex; gap:8px; flex-wrap:wrap; align-items:end;">
<div class="filter-group" style="margin:0; flex:1; min-width:200px;">
<label>نقش مبدأ (حذف می‌شود)</label>
<select name="sourceId" required>
<option value="">— انتخاب —</option>
@foreach (var x in Model.Stats)
{
<option value="@x.Role.Id">@x.Role.Name (@P(x.Total))</option>
}
</select>
</div>
<div class="filter-group" style="margin:0; flex:1; min-width:200px;">
<label>نقش مقصد (می‌ماند)</label>
<select name="targetId" required>
<option value="">— انتخاب —</option>
@foreach (var x in Model.Stats)
{
<option value="@x.Role.Id">@x.Role.Name (@P(x.Total))</option>
}
</select>
</div>
<button type="submit" class="btn btn-accent">ادغام</button>
</form>
</div>
<table style="width:100%; border-collapse:collapse; font-size:13.5px;">
<thead>
<tr style="color:var(--muted); text-align:start;">
<th style="text-align:start; padding:6px 0;">نقش</th>
<th style="text-align:start;">گروه</th>
<th style="text-align:start;">شیفت</th>
<th style="text-align:start;">استخدام</th>
<th style="text-align:start;">آماده‌به‌کار</th>
<th style="text-align:start;">جمع</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var x in Model.Stats)
{
<tr style="border-top:1px solid var(--line); @(x.Role.IsActive ? "" : "opacity:.5;")">
<td style="padding:8px 0;"><strong>@x.Role.Name</strong> @(x.Role.IsActive ? "" : "(غیرفعال)")</td>
<td class="muted">@x.Role.Category</td>
<td>@P(x.Shifts)</td>
<td>@P(x.Jobs)</td>
<td>@P(x.Talent)</td>
<td><strong>@P(x.Total)</strong></td>
<td style="text-align:end;">
<form method="post" asp-page-handler="Toggle" asp-route-id="@x.Role.Id" style="display:inline;">
<button type="submit" class="btn btn-outline" style="padding:4px 12px; font-size:12px;">
@(x.Role.IsActive ? "غیرفعال‌سازی" : "فعال‌سازی")
</button>
</form>
</td>
</tr>
}
</tbody>
</table>
</div>
@@ -0,0 +1,75 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Admin;
/// <summary>Role taxonomy hygiene — dynamic ingestion can mint near-duplicate roles over time
/// («کمک‌یار» vs «کمک بهیار»). This screen lists every role with its usage and lets an admin merge
/// one role into another (reassigning all its listings/preferences) or toggle a role's visibility.</summary>
[Authorize(Roles = "Admin")]
public class RolesModel : PageModel
{
private readonly AppDbContext _db;
public RolesModel(AppDbContext db) => _db = db;
public record RoleStat(Role Role, int Shifts, int Jobs, int Talent)
{
public int Total => Shifts + Jobs + Talent;
}
public List<RoleStat> Stats { get; private set; } = new();
[TempData] public string? Message { get; set; }
public async Task OnGetAsync() => await LoadAsync();
private async Task LoadAsync()
{
var roles = await _db.Roles.OrderBy(r => r.Category).ThenBy(r => r.Name).ToListAsync();
var sc = await _db.Shifts.GroupBy(s => s.RoleId).Select(g => new { g.Key, C = g.Count() }).ToDictionaryAsync(x => x.Key, x => x.C);
var jc = await _db.JobOpenings.GroupBy(j => j.RoleId).Select(g => new { g.Key, C = g.Count() }).ToDictionaryAsync(x => x.Key, x => x.C);
var tc = await _db.TalentListings.GroupBy(t => t.RoleId).Select(g => new { g.Key, C = g.Count() }).ToDictionaryAsync(x => x.Key, x => x.C);
Stats = roles.Select(r => new RoleStat(r, sc.GetValueOrDefault(r.Id), jc.GetValueOrDefault(r.Id), tc.GetValueOrDefault(r.Id)))
.OrderByDescending(x => x.Total).ToList();
}
/// <summary>Move every reference from <paramref name="sourceId"/> to <paramref name="targetId"/>
/// (listings — the Restrict FKs that would otherwise block — plus preferences/alerts/profiles),
/// then delete the now-empty source role.</summary>
public async Task<IActionResult> OnPostMergeAsync(int sourceId, int targetId)
{
if (sourceId == 0 || targetId == 0 || sourceId == targetId)
{ Message = "نقش مبدأ و مقصد را درست انتخاب کن."; return RedirectToPage(); }
var source = await _db.Roles.FindAsync(sourceId);
var target = await _db.Roles.FindAsync(targetId);
if (source is null || target is null) { Message = "نقش پیدا نشد."; return RedirectToPage(); }
var s = await _db.Shifts.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, targetId));
var j = await _db.JobOpenings.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, targetId));
var t = await _db.TalentListings.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, targetId));
// Nullable references too, so a saved preference/alert follows the merge instead of dangling.
await _db.UserPreferences.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, (int?)targetId));
await _db.JobAlerts.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, (int?)targetId));
await _db.DoctorProfiles.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, (int?)targetId));
_db.Roles.Remove(source);
await _db.SaveChangesAsync();
string P(int n) => JalaliDate.ToPersianDigits(n.ToString());
Message = $"«{source.Name}» در «{target.Name}» ادغام شد — منتقل‌شده: {P(s)} شیفت، {P(j)} استخدام، {P(t)} آماده‌به‌کار.";
return RedirectToPage();
}
/// <summary>Hide a role from filters/forms without deleting it (keeps its listings intact).</summary>
public async Task<IActionResult> OnPostToggleAsync(int id)
{
var role = await _db.Roles.FindAsync(id);
if (role is not null) { role.IsActive = !role.IsActive; await _db.SaveChangesAsync(); }
return RedirectToPage();
}
}
@@ -16,6 +16,7 @@
<a class="@(On("/Admin/Index") ? "active" : null)" asp-page="/Admin/Index">📥 صف آگهی‌ها</a>
<a class="@(On("/Admin/Ingested") ? "active" : null)" asp-page="/Admin/Ingested">📜 نتایج جمع‌آوری</a>
<a class="@(On("/Admin/Facilities") ? "active" : null)" asp-page="/Admin/Facilities">🏥 مراکز</a>
<a class="@(On("/Admin/Roles") ? "active" : null)" asp-page="/Admin/Roles">🏷️ نقش‌ها</a>
<a class="@(On("/Admin/Users") ? "active" : null)" asp-page="/Admin/Users">👥 کاربران</a>
<a class="@(On("/Admin/Reports") ? "active" : null)" asp-page="/Admin/Reports">🛡️ گزارش‌ها</a>
<a class="@(On("/Admin/Broadcast") ? "active" : null)" asp-page="/Admin/Broadcast">📣 اعلان همگانی</a>