Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa61efd46f | |||
| d87afb577c | |||
| 437258294b |
@@ -1,12 +1,16 @@
|
|||||||
using JobsMedical.Web.Models;
|
using JobsMedical.Web.Models;
|
||||||
|
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace JobsMedical.Web.Data;
|
namespace JobsMedical.Web.Data;
|
||||||
|
|
||||||
public class AppDbContext : DbContext
|
public class AppDbContext : DbContext, IDataProtectionKeyContext
|
||||||
{
|
{
|
||||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||||
|
|
||||||
|
/// <summary>DataProtection key ring — persisted so antiforgery/cookies survive deploys & replicas.</summary>
|
||||||
|
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
|
||||||
|
|
||||||
public DbSet<City> Cities => Set<City>();
|
public DbSet<City> Cities => Set<City>();
|
||||||
public DbSet<District> Districts => Set<District>();
|
public DbSet<District> Districts => Set<District>();
|
||||||
public DbSet<Role> Roles => Set<Role>();
|
public DbSet<Role> Roles => Set<Role>();
|
||||||
@@ -26,6 +30,7 @@ public class AppDbContext : DbContext
|
|||||||
public DbSet<Report> Reports => Set<Report>();
|
public DbSet<Report> Reports => Set<Report>();
|
||||||
public DbSet<FacilityDocument> FacilityDocuments => Set<FacilityDocument>();
|
public DbSet<FacilityDocument> FacilityDocuments => Set<FacilityDocument>();
|
||||||
public DbSet<JobAlert> JobAlerts => Set<JobAlert>();
|
public DbSet<JobAlert> JobAlerts => Set<JobAlert>();
|
||||||
|
public DbSet<Review> Reviews => Set<Review>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder b)
|
protected override void OnModelCreating(ModelBuilder b)
|
||||||
{
|
{
|
||||||
@@ -93,6 +98,13 @@ public class AppDbContext : DbContext
|
|||||||
.HasForeignKey(a => a.RoleId).OnDelete(DeleteBehavior.SetNull);
|
.HasForeignKey(a => a.RoleId).OnDelete(DeleteBehavior.SetNull);
|
||||||
b.Entity<JobAlert>().HasIndex(a => a.IsActive);
|
b.Entity<JobAlert>().HasIndex(a => a.IsActive);
|
||||||
|
|
||||||
|
// Reviews: one per (facility, user); remove with either.
|
||||||
|
b.Entity<Review>().HasIndex(r => new { r.FacilityId, r.UserId }).IsUnique();
|
||||||
|
b.Entity<Review>().HasOne(r => r.Facility).WithMany()
|
||||||
|
.HasForeignKey(r => r.FacilityId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
b.Entity<Review>().HasOne(r => r.User).WithMany()
|
||||||
|
.HasForeignKey(r => r.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
// Don't delete shifts/profiles just because a Role is removed.
|
// Don't delete shifts/profiles just because a Role is removed.
|
||||||
b.Entity<Shift>()
|
b.Entity<Shift>()
|
||||||
.HasOne(s => s.Role).WithMany(r => r.Shifts)
|
.HasOne(s => s.Role).WithMany(r => r.Shifts)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.0" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||||
<PackageReference Include="WebPush" Version="1.0.12" />
|
<PackageReference Include="WebPush" Version="1.0.12" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
+1267
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class DataProtectionKeys : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "DataProtectionKeys",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
FriendlyName = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Xml = table.Column<string>(type: "text", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "DataProtectionKeys");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Reviews : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Reviews",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
FacilityId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
UserId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Stars = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Comment = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||||
|
IsApproved = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Reviews", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Reviews_Facilities_FacilityId",
|
||||||
|
column: x => x.FacilityId,
|
||||||
|
principalTable: "Facilities",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Reviews_Users_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Reviews_FacilityId_UserId",
|
||||||
|
table: "Reviews",
|
||||||
|
columns: new[] { "FacilityId", "UserId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Reviews_UserId",
|
||||||
|
table: "Reviews",
|
||||||
|
column: "UserId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Reviews");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -709,6 +709,43 @@ namespace JobsMedical.Web.Migrations
|
|||||||
b.ToTable("Reports");
|
b.ToTable("Reports");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("JobsMedical.Web.Models.Review", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Comment")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("FacilityId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("IsApproved")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("Stars")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("UserId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.HasIndex("FacilityId", "UserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Reviews");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("JobsMedical.Web.Models.Role", b =>
|
modelBuilder.Entity("JobsMedical.Web.Models.Role", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -970,6 +1007,25 @@ namespace JobsMedical.Web.Migrations
|
|||||||
b.ToTable("WebPushSubscriptions");
|
b.ToTable("WebPushSubscriptions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("FriendlyName")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Xml")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("DataProtectionKeys");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("JobsMedical.Web.Models.Application", b =>
|
modelBuilder.Entity("JobsMedical.Web.Models.Application", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("JobsMedical.Web.Models.User", "Doctor")
|
b.HasOne("JobsMedical.Web.Models.User", "Doctor")
|
||||||
@@ -1147,6 +1203,25 @@ namespace JobsMedical.Web.Migrations
|
|||||||
b.Navigation("LinkedShift");
|
b.Navigation("LinkedShift");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("JobsMedical.Web.Models.Review", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("JobsMedical.Web.Models.Facility", "Facility")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FacilityId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("JobsMedical.Web.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Facility");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("JobsMedical.Web.Models.Shift", b =>
|
modelBuilder.Entity("JobsMedical.Web.Models.Shift", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("JobsMedical.Web.Models.Facility", "Facility")
|
b.HasOne("JobsMedical.Web.Models.Facility", "Facility")
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Models;
|
||||||
|
|
||||||
|
/// <summary>A کادر درمان's rating + review of a facility they worked with (1–5 stars + comment).
|
||||||
|
/// One review per user per facility. Shown immediately; an admin can hide/delete.</summary>
|
||||||
|
public class Review
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int FacilityId { get; set; }
|
||||||
|
public Facility Facility { get; set; } = null!;
|
||||||
|
|
||||||
|
public int UserId { get; set; }
|
||||||
|
public User User { get; set; } = null!;
|
||||||
|
|
||||||
|
public int Stars { get; set; } // 1..5
|
||||||
|
[MaxLength(1000)] public string? Comment { get; set; }
|
||||||
|
|
||||||
|
public bool IsApproved { get; set; } = true; // admin can hide
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
@page
|
||||||
|
@model JobsMedical.Web.Pages.Admin.ReviewsModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "مدیریت نظرات";
|
||||||
|
}
|
||||||
|
<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" style="max-width:820px;">
|
||||||
|
@if (Model.Msg is not null) { <div class="alert alert-success">@Model.Msg</div> }
|
||||||
|
@if (Model.Items.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="card empty-state">نظری ثبت نشده است.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var r in Model.Items)
|
||||||
|
{
|
||||||
|
<div class="card card-pad" style="margin-bottom:8px; @(r.IsApproved ? "" : "opacity:.6;")">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:10px;">
|
||||||
|
<div>
|
||||||
|
<strong>@r.Facility.Name</strong>
|
||||||
|
<span style="color:#f59e0b;">@(new string('★', r.Stars))</span>
|
||||||
|
@if (!r.IsApproved) { <span class="badge badge-type">پنهان</span> }
|
||||||
|
<div class="muted" style="font-size:13px;">@(r.User.FullName ?? "کاربر") · <span dir="ltr">@JalaliDate.ToPersianDigits(r.User.Phone)</span></div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(r.Comment)) { <p style="margin:6px 0 0;">@r.Comment</p> }
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:6px; flex-wrap:wrap;">
|
||||||
|
<form method="post" asp-page-handler="Toggle" asp-route-id="@r.Id"><button class="btn btn-outline" style="padding:4px 12px;">@(r.IsApproved ? "پنهانکردن" : "نمایش")</button></form>
|
||||||
|
<form method="post" asp-page-handler="Delete" asp-route-id="@r.Id" onsubmit="return confirm('حذف شود؟');"><button class="btn btn-outline" style="padding:4px 12px; color:var(--danger); border-color:var(--danger);">حذف</button></form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using JobsMedical.Web.Data;
|
||||||
|
using JobsMedical.Web.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Pages.Admin;
|
||||||
|
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public class ReviewsModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public ReviewsModel(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public List<Review> Items { get; private set; } = new();
|
||||||
|
[TempData] public string? Msg { get; set; }
|
||||||
|
|
||||||
|
public async Task OnGetAsync()
|
||||||
|
{
|
||||||
|
Items = await _db.Reviews.Include(r => r.Facility).Include(r => r.User)
|
||||||
|
.OrderByDescending(r => r.CreatedAt).Take(200).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostToggleAsync(int id)
|
||||||
|
{
|
||||||
|
var r = await _db.Reviews.FindAsync(id);
|
||||||
|
if (r is not null) { r.IsApproved = !r.IsApproved; await _db.SaveChangesAsync(); Msg = r.IsApproved ? "نمایش داده شد." : "پنهان شد."; }
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostDeleteAsync(int id)
|
||||||
|
{
|
||||||
|
var r = await _db.Reviews.FindAsync(id);
|
||||||
|
if (r is not null) { _db.Reviews.Remove(r); await _db.SaveChangesAsync(); Msg = "حذف شد."; }
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">
|
<div class="grid grid-3">
|
||||||
@foreach (var row in Model.Rows)
|
@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;">
|
<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>
|
<span class="facility" style="font-weight:800; font-size:16px;">@row.Facility.Name</span>
|
||||||
@if (row.Facility.IsVerified)
|
@if (row.Facility.IsVerified)
|
||||||
@@ -34,10 +34,9 @@
|
|||||||
<span class="pay" style="color:var(--primary-dark); font-weight:800;">
|
<span class="pay" style="color:var(--primary-dark); font-weight:800;">
|
||||||
@JalaliDate.ToPersianDigits(row.OpenShifts.ToString()) شیفت باز
|
@JalaliDate.ToPersianDigits(row.OpenShifts.ToString()) شیفت باز
|
||||||
</span>
|
</span>
|
||||||
<a class="btn btn-outline" style="padding:6px 14px;"
|
<span class="btn btn-outline" style="padding:6px 14px;">مشاهده مرکز</span>
|
||||||
asp-page="/Calendar/Index" asp-route-FacilityId="@row.Facility.Id">تقویم</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</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();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Text.Unicode;
|
|||||||
using JobsMedical.Web.Data;
|
using JobsMedical.Web.Data;
|
||||||
using JobsMedical.Web.Models;
|
using JobsMedical.Web.Models;
|
||||||
using JobsMedical.Web.Services;
|
using JobsMedical.Web.Services;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -77,6 +78,13 @@ builder.Services.AddSingleton(HtmlEncoder.Create(
|
|||||||
builder.Services.AddDbContext<AppDbContext>(opt =>
|
builder.Services.AddDbContext<AppDbContext>(opt =>
|
||||||
opt.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
|
opt.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
|
||||||
|
|
||||||
|
// Persist the DataProtection key ring in the DB so antiforgery tokens, auth cookies and the
|
||||||
|
// captcha survive deploys/restarts (otherwise a new key ring each boot logs everyone out and
|
||||||
|
// breaks antiforgery — the cause of the earlier admin lock-out).
|
||||||
|
builder.Services.AddDataProtection()
|
||||||
|
.PersistKeysToDbContext<AppDbContext>()
|
||||||
|
.SetApplicationName("hamkadr");
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Apply migrations + seed on startup (fine for MVP single-instance deploy).
|
// Apply migrations + seed on startup (fine for MVP single-instance deploy).
|
||||||
|
|||||||
@@ -120,6 +120,13 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.resume-link { font-size: 12px; color: var(--primary); font-weight: 600; }
|
.resume-link { font-size: 12px; color: var(--primary); font-weight: 600; }
|
||||||
.applicant-actions { display: inline-flex; gap: 6px; align-items: center; flex: 0 0 auto; }
|
.applicant-actions { display: inline-flex; gap: 6px; align-items: center; flex: 0 0 auto; }
|
||||||
|
|
||||||
|
/* Star rating input (5..1 in DOM; hover/checked fills downward) */
|
||||||
|
.star-input { display: inline-flex; font-size: 28px; line-height: 1; }
|
||||||
|
.star-input input { display: none; }
|
||||||
|
.star-input label { color: var(--line); cursor: pointer; padding: 0 1px; transition: color .1s; }
|
||||||
|
.star-input label:hover, .star-input label:hover ~ label,
|
||||||
|
.star-input input:checked ~ label { color: #f59e0b; }
|
||||||
|
|
||||||
/* Profile-completeness nudge */
|
/* Profile-completeness nudge */
|
||||||
.profile-nudge { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
|
.profile-nudge { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
|
||||||
background: #fff7ed; border: 1px solid var(--accent); border-radius: 12px; padding: 12px 16px; margin-bottom: 16px; }
|
background: #fff7ed; border: 1px solid var(--accent); border-radius: 12px; padding: 12px 16px; margin-bottom: 16px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user