Admin: role merge tool + usage list (taxonomy hygiene)
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:
@@ -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/Index") ? "active" : null)" asp-page="/Admin/Index">📥 صف آگهیها</a>
|
||||||
<a class="@(On("/Admin/Ingested") ? "active" : null)" asp-page="/Admin/Ingested">📜 نتایج جمعآوری</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/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/Users") ? "active" : null)" asp-page="/Admin/Users">👥 کاربران</a>
|
||||||
<a class="@(On("/Admin/Reports") ? "active" : null)" asp-page="/Admin/Reports">🛡️ گزارشها</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>
|
<a class="@(On("/Admin/Broadcast") ? "active" : null)" asp-page="/Admin/Broadcast">📣 اعلان همگانی</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user