[Applicant+Admin] Withdraw application, delete account, admin analytics dashboard
Applicant: 'انصراف از درخواست' on /Me removes the Apply event for that shift/job. Account: 'حذف حساب من' on /Me/Profile permanently deletes the user + cascades (profile, alerts, reviews, applications), detaches anonymous visitor history, and signs out (per privacy policy). Admin: /Admin/Analytics dashboard — totals (users, facilities/verified, open shifts/jobs, applications, reviews), 7-day deltas, and a 14-day applications bar chart; linked from Overview alongside the new نظرات moderation page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
@page
|
||||||
|
@model JobsMedical.Web.Pages.Admin.AnalyticsModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "آمار و تحلیل";
|
||||||
|
string Fa(int n) => JalaliDate.ToPersianDigits(n.ToString());
|
||||||
|
}
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="container">
|
||||||
|
<h1>📊 آمار و تحلیل</h1>
|
||||||
|
<p class="muted"><a asp-page="/Admin/Overview">← پنل مدیریت</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container section">
|
||||||
|
<div class="grid grid-4">
|
||||||
|
<div class="card card-pad"><div class="muted">کاربران</div><div style="font-size:26px; font-weight:800;">@Fa(Model.Users)</div><div class="muted" style="font-size:12px;">+@Fa(Model.NewUsers7) در ۷ روز</div></div>
|
||||||
|
<div class="card card-pad"><div class="muted">مراکز</div><div style="font-size:26px; font-weight:800;">@Fa(Model.Facilities)</div><div class="muted" style="font-size:12px;">@Fa(Model.VerifiedFacilities) تأییدشده</div></div>
|
||||||
|
<div class="card card-pad"><div class="muted">شیفتهای باز</div><div style="font-size:26px; font-weight:800; color:var(--primary-dark);">@Fa(Model.OpenShifts)</div></div>
|
||||||
|
<div class="card card-pad"><div class="muted">استخدامهای باز</div><div style="font-size:26px; font-weight:800; color:var(--primary-dark);">@Fa(Model.OpenJobs)</div></div>
|
||||||
|
<div class="card card-pad"><div class="muted">اعلام تمایلها</div><div style="font-size:26px; font-weight:800; color:var(--accent);">@Fa(Model.Applications)</div><div class="muted" style="font-size:12px;">+@Fa(Model.NewApps7) در ۷ روز</div></div>
|
||||||
|
<div class="card card-pad"><div class="muted">نظرات</div><div style="font-size:26px; font-weight:800;">@Fa(Model.Reviews)</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad" style="margin-top:18px;">
|
||||||
|
<h3 style="margin-top:0;">اعلام تمایل — ۱۴ روز اخیر</h3>
|
||||||
|
<div style="display:flex; align-items:flex-end; gap:6px; height:140px; padding-top:10px;">
|
||||||
|
@foreach (var b in Model.ApplyByDay)
|
||||||
|
{
|
||||||
|
var h = (int)(b.Count / (double)Model.MaxBar * 120) + 2;
|
||||||
|
<div style="flex:1; display:flex; flex-direction:column; align-items:center; gap:4px;">
|
||||||
|
<div style="width:100%; height:@(h)px; background:var(--primary); border-radius:6px 6px 0 0;" title="@Fa(b.Count)"></div>
|
||||||
|
<span class="muted" style="font-size:10px;">@Fa(b.Day.Day)</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad" style="margin-top:18px; display:flex; gap:10px; flex-wrap:wrap;">
|
||||||
|
<a class="btn btn-outline" asp-page="/Admin/Index">صف آگهیها</a>
|
||||||
|
<a class="btn btn-outline" asp-page="/Admin/Facilities">مراکز</a>
|
||||||
|
<a class="btn btn-outline" asp-page="/Admin/Reviews">نظرات</a>
|
||||||
|
<a class="btn btn-outline" asp-page="/Admin/Reports">گزارشها</a>
|
||||||
|
<a class="btn btn-outline" asp-page="/Admin/Users">کاربران</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using JobsMedical.Web.Data;
|
||||||
|
using JobsMedical.Web.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Pages.Admin;
|
||||||
|
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public class AnalyticsModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public AnalyticsModel(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public int Users { get; private set; }
|
||||||
|
public int Facilities { get; private set; }
|
||||||
|
public int VerifiedFacilities { get; private set; }
|
||||||
|
public int OpenShifts { get; private set; }
|
||||||
|
public int OpenJobs { get; private set; }
|
||||||
|
public int Applications { get; private set; }
|
||||||
|
public int Reviews { get; private set; }
|
||||||
|
public int NewUsers7 { get; private set; }
|
||||||
|
public int NewApps7 { get; private set; }
|
||||||
|
|
||||||
|
public record DayBar(DateOnly Day, int Count);
|
||||||
|
public List<DayBar> ApplyByDay { get; private set; } = new();
|
||||||
|
public int MaxBar { get; private set; } = 1;
|
||||||
|
|
||||||
|
public async Task OnGetAsync()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
Users = await _db.Users.CountAsync();
|
||||||
|
Facilities = await _db.Facilities.CountAsync();
|
||||||
|
VerifiedFacilities = await _db.Facilities.CountAsync(f => f.IsVerified);
|
||||||
|
OpenShifts = await _db.Shifts.CountAsync(s => s.Status == ShiftStatus.Open && s.Date >= today);
|
||||||
|
OpenJobs = await _db.JobOpenings.CountAsync(j => j.Status == ShiftStatus.Open);
|
||||||
|
Applications = await _db.InterestEvents.CountAsync(e => e.EventType == InterestEventType.Apply);
|
||||||
|
Reviews = await _db.Reviews.CountAsync();
|
||||||
|
|
||||||
|
var since7 = DateTime.UtcNow.AddDays(-7);
|
||||||
|
NewUsers7 = await _db.Users.CountAsync(u => u.CreatedAt >= since7);
|
||||||
|
NewApps7 = await _db.InterestEvents.CountAsync(e => e.EventType == InterestEventType.Apply && e.CreatedAt >= since7);
|
||||||
|
|
||||||
|
var since14 = DateTime.UtcNow.Date.AddDays(-13);
|
||||||
|
var stamps = await _db.InterestEvents
|
||||||
|
.Where(e => e.EventType == InterestEventType.Apply && e.CreatedAt >= since14)
|
||||||
|
.Select(e => e.CreatedAt).ToListAsync();
|
||||||
|
var byDay = stamps.GroupBy(d => DateOnly.FromDateTime(d.Date)).ToDictionary(g => g.Key, g => g.Count());
|
||||||
|
for (var i = 0; i < 14; i++)
|
||||||
|
{
|
||||||
|
var day = DateOnly.FromDateTime(since14).AddDays(i);
|
||||||
|
ApplyByDay.Add(new DayBar(day, byDay.GetValueOrDefault(day)));
|
||||||
|
}
|
||||||
|
MaxBar = Math.Max(1, ApplyByDay.Count > 0 ? ApplyByDay.Max(b => b.Count) : 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
<a asp-page="/Admin/Facilities">مراکز</a> ·
|
<a asp-page="/Admin/Facilities">مراکز</a> ·
|
||||||
<a asp-page="/Admin/Reports">گزارشها</a> ·
|
<a asp-page="/Admin/Reports">گزارشها</a> ·
|
||||||
<a asp-page="/Admin/Broadcast">ارسال اعلان</a> ·
|
<a asp-page="/Admin/Broadcast">ارسال اعلان</a> ·
|
||||||
|
<a asp-page="/Admin/Reviews">نظرات</a> ·
|
||||||
|
<a asp-page="/Admin/Analytics">آمار</a> ·
|
||||||
<a asp-page="/Admin/Settings">تنظیمات</a>
|
<a asp-page="/Admin/Settings">تنظیمات</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -92,6 +92,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<span class="badge @b.cls" style="margin-bottom:6px; display:inline-block;">@b.txt</span>
|
<span class="badge @b.cls" style="margin-bottom:6px; display:inline-block;">@b.txt</span>
|
||||||
<partial name="_ShiftCard" model="s" />
|
<partial name="_ShiftCard" model="s" />
|
||||||
|
<form method="post" asp-page-handler="WithdrawShift" asp-route-id="@s.Id" onsubmit="return confirm('از این فرصت انصراف میدهی؟');">
|
||||||
|
<button class="btn btn-outline" style="width:100%; padding:5px; font-size:12px; margin-top:6px; color:var(--danger); border-color:var(--danger);">انصراف از درخواست</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@foreach (var j in Model.AppliedJobs)
|
@foreach (var j in Model.AppliedJobs)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using JobsMedical.Web.Data;
|
|||||||
using JobsMedical.Web.Models;
|
using JobsMedical.Web.Models;
|
||||||
using JobsMedical.Web.Services;
|
using JobsMedical.Web.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -67,6 +68,21 @@ public class IndexModel : PageModel
|
|||||||
.ToDictionary(g => g.Key, g => g.OrderByDescending(e => e.CreatedAt).First().Status);
|
.ToDictionary(g => g.Key, g => g.OrderByDescending(e => e.CreatedAt).First().Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<IActionResult> OnPostWithdrawShiftAsync(int id) => WithdrawAsync(id, isJob: false);
|
||||||
|
public Task<IActionResult> OnPostWithdrawJobAsync(int id) => WithdrawAsync(id, isJob: true);
|
||||||
|
|
||||||
|
private async Task<IActionResult> WithdrawAsync(int id, bool isJob)
|
||||||
|
{
|
||||||
|
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
var visitorIds = await _db.Visitors.Where(v => v.UserId == userId).Select(v => v.Id).ToListAsync();
|
||||||
|
var evs = _db.InterestEvents.Where(e => visitorIds.Contains(e.VisitorId)
|
||||||
|
&& e.EventType == InterestEventType.Apply
|
||||||
|
&& (isJob ? e.JobOpeningId == id : e.ShiftId == id));
|
||||||
|
_db.InterestEvents.RemoveRange(evs);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
private Task<List<Shift>> ShiftsByIds(List<int> ids) => _db.Shifts
|
private Task<List<Shift>> ShiftsByIds(List<int> ids) => _db.Shifts
|
||||||
.Include(s => s.Facility).ThenInclude(f => f.City)
|
.Include(s => s.Facility).ThenInclude(f => f.City)
|
||||||
.Include(s => s.Facility).ThenInclude(f => f.District)
|
.Include(s => s.Facility).ThenInclude(f => f.District)
|
||||||
|
|||||||
@@ -95,4 +95,12 @@
|
|||||||
|
|
||||||
<button type="submit" class="btn btn-accent btn-block btn-lg">ذخیره پروفایل</button>
|
<button type="submit" class="btn btn-accent btn-block btn-lg">ذخیره پروفایل</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div class="card card-pad" style="margin-top:14px; border-color:var(--danger);">
|
||||||
|
<h3 style="margin-top:0; color:var(--danger);">حذف حساب کاربری</h3>
|
||||||
|
<p class="muted" style="font-size:13px;">با حذف حساب، اطلاعات پروفایل، رزومه، هشدارها و درخواستهای شما حذف میشود. این کار بازگشتناپذیر است.</p>
|
||||||
|
<form method="post" asp-page-handler="DeleteAccount" onsubmit="return confirm('آیا از حذف کامل حساب خود مطمئنی؟ این کار بازگشتناپذیر است.');">
|
||||||
|
<button type="submit" class="btn btn-outline" style="color:var(--danger); border-color:var(--danger);">حذف حساب من</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using JobsMedical.Web.Data;
|
using JobsMedical.Web.Data;
|
||||||
using JobsMedical.Web.Models;
|
using JobsMedical.Web.Models;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
@@ -115,6 +116,18 @@ public class ProfileModel : PageModel
|
|||||||
return RedirectToPage();
|
return RedirectToPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Permanently delete the account + its data (per the privacy policy).</summary>
|
||||||
|
public async Task<IActionResult> OnPostDeleteAccountAsync()
|
||||||
|
{
|
||||||
|
var uid = Uid;
|
||||||
|
// Detach anonymous browsing history (keep events, drop the user link), then remove the user.
|
||||||
|
await _db.Visitors.Where(v => v.UserId == uid)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(v => v.UserId, (int?)null));
|
||||||
|
await _db.Users.Where(u => u.Id == uid).ExecuteDeleteAsync(); // cascades profile/alerts/reviews/applications
|
||||||
|
await HttpContext.SignOutAsync(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
return RedirectToPage("/Index");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task LoadListsAsync()
|
private async Task LoadListsAsync()
|
||||||
{
|
{
|
||||||
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
|
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
|
||||||
|
|||||||
Reference in New Issue
Block a user