[Verify+Complaints] Facility document review + facility complaints; card location line
Card: move location to its own line above the date in the shift card (job card already did). Verification workflow: employers upload documents (license/permit) on a new Employer/Verify page; uploading marks the facility Pending. Admins see pending facilities with their documents on Admin/Facilities, can download each doc, and approve (تأیید شد) or reject with a reason. Documents stored as bytea in the DB (survives deploys via the existing volume); served only to the owner or an admin via /facility-doc/{id}. Facility model gains Verification status enum + note + requested-at; IsVerified kept in sync. Complaints: registered users/visitors can file a شکایت about a facility from shift/job detail pages (targets ReportTargetType.Facility, surfaces in Admin/Reports as مرکز). Migration backfills existing verified facilities to Verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ public class AppDbContext : DbContext
|
|||||||
public DbSet<WebPushSubscription> WebPushSubscriptions => Set<WebPushSubscription>();
|
public DbSet<WebPushSubscription> WebPushSubscriptions => Set<WebPushSubscription>();
|
||||||
public DbSet<Notification> Notifications => Set<Notification>();
|
public DbSet<Notification> Notifications => Set<Notification>();
|
||||||
public DbSet<Report> Reports => Set<Report>();
|
public DbSet<Report> Reports => Set<Report>();
|
||||||
|
public DbSet<FacilityDocument> FacilityDocuments => Set<FacilityDocument>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder b)
|
protected override void OnModelCreating(ModelBuilder b)
|
||||||
{
|
{
|
||||||
@@ -77,6 +78,11 @@ public class AppDbContext : DbContext
|
|||||||
.HasOne(f => f.District).WithMany(d => d.Facilities)
|
.HasOne(f => f.District).WithMany(d => d.Facilities)
|
||||||
.HasForeignKey(f => f.DistrictId).OnDelete(DeleteBehavior.SetNull);
|
.HasForeignKey(f => f.DistrictId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
// Verification documents belong to a facility; remove them with it.
|
||||||
|
b.Entity<FacilityDocument>()
|
||||||
|
.HasOne(d => d.Facility).WithMany(f => f.Documents)
|
||||||
|
.HasForeignKey(d => d.FacilityId).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)
|
||||||
|
|||||||
+1126
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class FacilityVerificationDocs : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Verification",
|
||||||
|
table: "Facilities",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "VerificationNote",
|
||||||
|
table: "Facilities",
|
||||||
|
type: "character varying(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "VerificationRequestedAt",
|
||||||
|
table: "Facilities",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "FacilityDocuments",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
FacilityId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
FileName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
ContentType = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||||
|
Size = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
Data = table.Column<byte[]>(type: "bytea", nullable: false),
|
||||||
|
UploadedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_FacilityDocuments", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_FacilityDocuments_Facilities_FacilityId",
|
||||||
|
column: x => x.FacilityId,
|
||||||
|
principalTable: "Facilities",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FacilityDocuments_FacilityId",
|
||||||
|
table: "FacilityDocuments",
|
||||||
|
column: "FacilityId");
|
||||||
|
|
||||||
|
// Backfill: already-verified facilities get Verification = Verified (2).
|
||||||
|
migrationBuilder.Sql("UPDATE \"Facilities\" SET \"Verification\" = 2 WHERE \"IsVerified\" = true;");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "FacilityDocuments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Verification",
|
||||||
|
table: "Facilities");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "VerificationNote",
|
||||||
|
table: "Facilities");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "VerificationRequestedAt",
|
||||||
|
table: "Facilities");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -337,6 +337,16 @@ namespace JobsMedical.Web.Migrations
|
|||||||
b.Property<int>("Type")
|
b.Property<int>("Type")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Verification")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("VerificationNote")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("VerificationRequestedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("CityId");
|
b.HasIndex("CityId");
|
||||||
@@ -348,6 +358,44 @@ namespace JobsMedical.Web.Migrations
|
|||||||
b.ToTable("Facilities");
|
b.ToTable("Facilities");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("JobsMedical.Web.Models.FacilityDocument", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ContentType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Data")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("bytea");
|
||||||
|
|
||||||
|
b.Property<int>("FacilityId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<long>("Size")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UploadedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FacilityId");
|
||||||
|
|
||||||
|
b.ToTable("FacilityDocuments");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b =>
|
modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
@@ -902,6 +950,17 @@ namespace JobsMedical.Web.Migrations
|
|||||||
b.Navigation("OwnerUser");
|
b.Navigation("OwnerUser");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("JobsMedical.Web.Models.FacilityDocument", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("JobsMedical.Web.Models.Facility", "Facility")
|
||||||
|
.WithMany("Documents")
|
||||||
|
.HasForeignKey("FacilityId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Facility");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b =>
|
modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("JobsMedical.Web.Models.JobOpening", "JobOpening")
|
b.HasOne("JobsMedical.Web.Models.JobOpening", "JobOpening")
|
||||||
@@ -1030,6 +1089,8 @@ namespace JobsMedical.Web.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("JobsMedical.Web.Models.Facility", b =>
|
modelBuilder.Entity("JobsMedical.Web.Models.Facility", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("Documents");
|
||||||
|
|
||||||
b.Navigation("Shifts");
|
b.Navigation("Shifts");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -96,3 +96,6 @@ public enum IngestionMode
|
|||||||
|
|
||||||
public enum ReportTargetType { Shift = 0, Job = 1, Facility = 2, User = 3 }
|
public enum ReportTargetType { Shift = 0, Job = 1, Facility = 2, User = 3 }
|
||||||
public enum ReportStatus { Open = 0, Resolved = 1, Dismissed = 2 }
|
public enum ReportStatus { Open = 0, Resolved = 1, Dismissed = 2 }
|
||||||
|
|
||||||
|
/// <summary>Facility verification lifecycle. Facility.IsVerified stays in sync (true only when Verified).</summary>
|
||||||
|
public enum VerificationStatus { Unverified = 0, Pending = 1, Verified = 2, Rejected = 3 }
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ public class Facility
|
|||||||
[MaxLength(50)]
|
[MaxLength(50)]
|
||||||
public string? BaleId { get; set; } // شناسه بله برای ارتباط
|
public string? BaleId { get; set; } // شناسه بله برای ارتباط
|
||||||
|
|
||||||
public bool IsVerified { get; set; } // نشان «تأیید شده»
|
public bool IsVerified { get; set; } // نشان «تأیید شده» (true only when Verification == Verified)
|
||||||
|
|
||||||
|
/// <summary>Verification workflow: employer requests review (+docs) → admin approves/rejects.</summary>
|
||||||
|
public VerificationStatus Verification { get; set; } = VerificationStatus.Unverified;
|
||||||
|
[MaxLength(500)] public string? VerificationNote { get; set; } // admin reject reason / note
|
||||||
|
public DateTime? VerificationRequestedAt { get; set; }
|
||||||
|
|
||||||
// Phase 2: facility self-serve. Null in MVP (admin manages).
|
// Phase 2: facility self-serve. Null in MVP (admin manages).
|
||||||
public int? OwnerUserId { get; set; }
|
public int? OwnerUserId { get; set; }
|
||||||
@@ -42,4 +47,7 @@ public class Facility
|
|||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
public ICollection<Shift> Shifts { get; set; } = new List<Shift>();
|
public ICollection<Shift> Shifts { get; set; } = new List<Shift>();
|
||||||
|
|
||||||
|
/// <summary>Documents the employer uploaded to prove the facility is real (license, etc.).</summary>
|
||||||
|
public ICollection<FacilityDocument> Documents { get; set; } = new List<FacilityDocument>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A verification document an employer uploads for their facility (license, permit, ID…).
|
||||||
|
/// Stored as bytes in the DB so it survives deploys via the existing Postgres volume/backups
|
||||||
|
/// (no separate file volume to mount). Only the facility owner and admins can read it back.
|
||||||
|
/// </summary>
|
||||||
|
public class FacilityDocument
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int FacilityId { get; set; }
|
||||||
|
public Facility Facility { get; set; } = null!;
|
||||||
|
|
||||||
|
[MaxLength(200)] public string FileName { get; set; } = "";
|
||||||
|
[MaxLength(120)] public string ContentType { get; set; } = "application/octet-stream";
|
||||||
|
public long Size { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Raw file bytes (images/PDF). Capped at the upload handler (a few MB).</summary>
|
||||||
|
public byte[] Data { get; set; } = Array.Empty<byte>();
|
||||||
|
|
||||||
|
public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -15,25 +15,27 @@
|
|||||||
<h1>تأیید مراکز درمانی</h1>
|
<h1>تأیید مراکز درمانی</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
<a asp-page="/Admin/Index">← صف آگهیها</a>
|
<a asp-page="/Admin/Index">← صف آگهیها</a>
|
||||||
· @JalaliDate.ToPersianDigits(Model.Pending.Count.ToString()) مرکز در انتظار تأیید
|
· @JalaliDate.ToPersianDigits(Model.Awaiting.Count.ToString()) مرکز منتظر بررسی
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container section">
|
<div class="container section">
|
||||||
<h2 style="font-size:20px;">در انتظار تأیید</h2>
|
@if (Model.Msg is not null) { <div class="alert alert-success">@Model.Msg</div> }
|
||||||
@if (Model.Pending.Count == 0)
|
|
||||||
|
<h2 style="font-size:20px;">منتظر بررسی (مدارک ارسالشده)</h2>
|
||||||
|
@if (Model.Awaiting.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="card empty-state">مرکزی در انتظار تأیید نیست.</div>
|
<div class="card empty-state">مرکزی منتظر بررسی نیست.</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
foreach (var f in Model.Pending)
|
foreach (var f in Model.Awaiting)
|
||||||
{
|
{
|
||||||
<div class="card card-pad" style="margin-bottom:10px;">
|
<div class="card card-pad" style="margin-bottom:10px;">
|
||||||
<div class="row" style="display:flex; justify-content:space-between; align-items:center; gap:10px;">
|
<div class="row" style="display:flex; justify-content:space-between; align-items:flex-start; gap:10px;">
|
||||||
<div>
|
<div>
|
||||||
<strong>@f.Name</strong> — @TypeLabel(f.Type)
|
<strong>@f.Name</strong> — @TypeLabel(f.Type) <span class="badge badge-type">در حال بررسی</span>
|
||||||
<div class="muted" style="font-size:13px;">
|
<div class="muted" style="font-size:13px;">
|
||||||
📍 @f.City?.Name@(f.District is not null ? "، " + f.District.Name : "")
|
📍 @f.City?.Name@(f.District is not null ? "، " + f.District.Name : "")
|
||||||
@if (f.OwnerUser is not null) { <text> · مالک: <span dir="ltr">@JalaliDate.ToPersianDigits(f.OwnerUser.Phone)</span></text> }
|
@if (f.OwnerUser is not null) { <text> · مالک: <span dir="ltr">@JalaliDate.ToPersianDigits(f.OwnerUser.Phone)</span></text> }
|
||||||
@@ -41,8 +43,32 @@
|
|||||||
</div>
|
</div>
|
||||||
@if (!string.IsNullOrEmpty(f.Address)) { <div class="muted" style="font-size:13px;">@f.Address</div> }
|
@if (!string.IsNullOrEmpty(f.Address)) { <div class="muted" style="font-size:13px;">@f.Address</div> }
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin:10px 0;">
|
||||||
|
<strong style="font-size:13px;">مدارک (@JalaliDate.ToPersianDigits(f.Documents.Count.ToString())):</strong>
|
||||||
|
@if (f.Documents.Count == 0)
|
||||||
|
{
|
||||||
|
<span class="muted" style="font-size:13px;"> — مدرکی بارگذاری نشده.</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-top:6px;">
|
||||||
|
@foreach (var d in f.Documents)
|
||||||
|
{
|
||||||
|
<a class="btn btn-outline" style="padding:6px 12px; font-size:13px;" href="/facility-doc/@d.Id" target="_blank">📎 @d.FileName</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:flex-end;">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<button asp-page-handler="Verify" asp-route-id="@f.Id" class="btn btn-accent" style="white-space:nowrap;">✓ تأیید</button>
|
<button asp-page-handler="Verify" asp-route-id="@f.Id" class="btn btn-accent" style="white-space:nowrap;">✓ تأیید شد</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" style="display:flex; gap:6px; flex:1; min-width:220px;">
|
||||||
|
<input type="text" name="note" placeholder="دلیل رد (اختیاری)" style="flex:1;" />
|
||||||
|
<button asp-page-handler="Reject" asp-route-id="@f.Id" class="btn btn-outline" style="white-space:nowrap; color:var(--danger); border-color:var(--danger);">رد</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,4 +97,24 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (Model.Others.Count > 0)
|
||||||
|
{
|
||||||
|
<h2 style="font-size:20px; margin-top:30px;">سایر مراکز (بدون درخواست تأیید)</h2>
|
||||||
|
foreach (var f in Model.Others)
|
||||||
|
{
|
||||||
|
<div class="card card-pad" style="margin-bottom:10px;">
|
||||||
|
<div class="row" style="display:flex; justify-content:space-between; align-items:center; gap:10px;">
|
||||||
|
<div>
|
||||||
|
<strong>@f.Name</strong> — @TypeLabel(f.Type)
|
||||||
|
@if (f.Verification == JobsMedical.Web.Models.VerificationStatus.Rejected) { <span class="badge badge-gender">رد شده</span> }
|
||||||
|
<div class="muted" style="font-size:13px;">📍 @f.City?.Name@(f.District is not null ? "، " + f.District.Name : "")</div>
|
||||||
|
</div>
|
||||||
|
<form method="post">
|
||||||
|
<button asp-page-handler="Verify" asp-route-id="@f.Id" class="btn btn-accent" style="white-space:nowrap;">✓ تأیید مستقیم</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,20 +13,45 @@ public class FacilitiesModel : PageModel
|
|||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
public FacilitiesModel(AppDbContext db) => _db = db;
|
public FacilitiesModel(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
public List<Facility> Pending { get; private set; } = new();
|
public List<Facility> Awaiting { get; private set; } = new(); // requested review (Pending)
|
||||||
|
public List<Facility> Others { get; private set; } = new(); // unverified / rejected, no pending request
|
||||||
public List<Facility> Verified { get; private set; } = new();
|
public List<Facility> Verified { get; private set; } = new();
|
||||||
|
[TempData] public string? Msg { get; set; }
|
||||||
|
|
||||||
public async Task OnGetAsync() => await LoadAsync();
|
public async Task OnGetAsync() => await LoadAsync();
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostVerifyAsync(int id) => await SetVerified(id, true);
|
public async Task<IActionResult> OnPostVerifyAsync(int id)
|
||||||
public async Task<IActionResult> OnPostUnverifyAsync(int id) => await SetVerified(id, false);
|
|
||||||
|
|
||||||
private async Task<IActionResult> SetVerified(int id, bool value)
|
|
||||||
{
|
{
|
||||||
var f = await _db.Facilities.FindAsync(id);
|
var f = await _db.Facilities.FindAsync(id);
|
||||||
if (f is null) return NotFound();
|
if (f is null) return NotFound();
|
||||||
f.IsVerified = value;
|
f.IsVerified = true;
|
||||||
|
f.Verification = VerificationStatus.Verified;
|
||||||
|
f.VerificationNote = null;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
Msg = $"«{f.Name}» تأیید شد.";
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostRejectAsync(int id, string? note)
|
||||||
|
{
|
||||||
|
var f = await _db.Facilities.FindAsync(id);
|
||||||
|
if (f is null) return NotFound();
|
||||||
|
f.IsVerified = false;
|
||||||
|
f.Verification = VerificationStatus.Rejected;
|
||||||
|
f.VerificationNote = string.IsNullOrWhiteSpace(note) ? "مدارک کافی نبود." : note.Trim();
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
Msg = $"«{f.Name}» رد شد.";
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostUnverifyAsync(int id)
|
||||||
|
{
|
||||||
|
var f = await _db.Facilities.FindAsync(id);
|
||||||
|
if (f is null) return NotFound();
|
||||||
|
f.IsVerified = false;
|
||||||
|
f.Verification = VerificationStatus.Unverified;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
Msg = $"تأیید «{f.Name}» لغو شد.";
|
||||||
return RedirectToPage();
|
return RedirectToPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,8 +59,10 @@ public class FacilitiesModel : PageModel
|
|||||||
{
|
{
|
||||||
var all = await _db.Facilities
|
var all = await _db.Facilities
|
||||||
.Include(f => f.City).Include(f => f.District).Include(f => f.OwnerUser)
|
.Include(f => f.City).Include(f => f.District).Include(f => f.OwnerUser)
|
||||||
.OrderBy(f => f.Name).ToListAsync();
|
.Include(f => f.Documents)
|
||||||
Pending = all.Where(f => !f.IsVerified).ToList();
|
.OrderByDescending(f => f.VerificationRequestedAt).ThenBy(f => f.Name).ToListAsync();
|
||||||
|
Awaiting = all.Where(f => f.Verification == VerificationStatus.Pending).ToList();
|
||||||
Verified = all.Where(f => f.IsVerified).ToList();
|
Verified = all.Where(f => f.IsVerified).ToList();
|
||||||
|
Others = all.Where(f => !f.IsVerified && f.Verification != VerificationStatus.Pending).ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,13 +45,20 @@
|
|||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<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;">@c.Facility.Name</span>
|
<span class="facility" style="font-weight:800; font-size:16px;">@c.Facility.Name</span>
|
||||||
@if (c.Facility.IsVerified)
|
@switch (c.Facility.Verification)
|
||||||
{
|
{
|
||||||
<span class="badge badge-verified">✓ تأیید شده</span>
|
case JobsMedical.Web.Models.VerificationStatus.Verified:
|
||||||
}
|
<span class="badge badge-verified">✓ تأیید شده</span>
|
||||||
else
|
break;
|
||||||
{
|
case JobsMedical.Web.Models.VerificationStatus.Pending:
|
||||||
<span class="badge badge-type">در انتظار تأیید</span>
|
<span class="badge badge-type">در حال بررسی</span>
|
||||||
|
break;
|
||||||
|
case JobsMedical.Web.Models.VerificationStatus.Rejected:
|
||||||
|
<span class="badge badge-gender">رد شده</span>
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
<span class="badge badge-type">تأیید نشده</span>
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<p class="muted" style="margin:8px 0;">
|
<p class="muted" style="margin:8px 0;">
|
||||||
@@ -61,6 +68,12 @@
|
|||||||
<div class="info-row"><span class="k">موقعیتهای استخدامی</span><span class="v">@JalaliDate.ToPersianDigits(c.OpenJobs.ToString())</span></div>
|
<div class="info-row"><span class="k">موقعیتهای استخدامی</span><span class="v">@JalaliDate.ToPersianDigits(c.OpenJobs.ToString())</span></div>
|
||||||
<div class="info-row"><span class="k">اعلام تمایلها</span><span class="v" style="color:var(--accent)">@JalaliDate.ToPersianDigits(c.Applicants.ToString())</span></div>
|
<div class="info-row"><span class="k">اعلام تمایلها</span><span class="v" style="color:var(--accent)">@JalaliDate.ToPersianDigits(c.Applicants.ToString())</span></div>
|
||||||
<a class="btn btn-outline btn-block" style="margin-top:12px;" asp-page="/Employer/Listings" asp-route-facilityId="@c.Facility.Id">مدیریت آگهیها و متقاضیان</a>
|
<a class="btn btn-outline btn-block" style="margin-top:12px;" asp-page="/Employer/Listings" asp-route-facilityId="@c.Facility.Id">مدیریت آگهیها و متقاضیان</a>
|
||||||
|
@if (c.Facility.Verification != JobsMedical.Web.Models.VerificationStatus.Verified)
|
||||||
|
{
|
||||||
|
<a class="btn btn-outline btn-block" style="margin-top:8px;" asp-page="/Employer/Verify" asp-route-id="@c.Facility.Id">
|
||||||
|
@(c.Facility.Verification == JobsMedical.Web.Models.VerificationStatus.Pending ? "مشاهده/افزودن مدارک تأیید" : "درخواست تأیید و بارگذاری مدارک")
|
||||||
|
</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
@page "{id:int}"
|
||||||
|
@model JobsMedical.Web.Pages.Employer.VerifyModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "تأیید مرکز درمانی";
|
||||||
|
var v = Model.Facility.Verification;
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="container">
|
||||||
|
<h1>تأیید مرکز: @Model.Facility.Name</h1>
|
||||||
|
<p class="muted"><a asp-page="/Employer/Index">← بازگشت به پنل</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container section" style="max-width:680px;">
|
||||||
|
@if (Model.Msg is not null) { <div class="alert alert-success">@Model.Msg</div> }
|
||||||
|
|
||||||
|
<div class="card card-pad" style="margin-bottom:14px;">
|
||||||
|
<div class="row" style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<strong>وضعیت تأیید</strong>
|
||||||
|
@switch (v)
|
||||||
|
{
|
||||||
|
case JobsMedical.Web.Models.VerificationStatus.Verified:
|
||||||
|
<span class="badge badge-verified">✓ تأیید شده</span>
|
||||||
|
break;
|
||||||
|
case JobsMedical.Web.Models.VerificationStatus.Pending:
|
||||||
|
<span class="badge badge-type">در حال بررسی</span>
|
||||||
|
break;
|
||||||
|
case JobsMedical.Web.Models.VerificationStatus.Rejected:
|
||||||
|
<span class="badge badge-gender">رد شده</span>
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
<span class="badge badge-type">تأیید نشده</span>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (v == JobsMedical.Web.Models.VerificationStatus.Rejected && !string.IsNullOrWhiteSpace(Model.Facility.VerificationNote))
|
||||||
|
{
|
||||||
|
<p class="muted" style="margin:8px 0 0; color:var(--danger);">دلیل رد: @Model.Facility.VerificationNote — میتوانید مدارک اصلاحشده را دوباره بارگذاری کنید.</p>
|
||||||
|
}
|
||||||
|
else if (v == JobsMedical.Web.Models.VerificationStatus.Pending)
|
||||||
|
{
|
||||||
|
<p class="muted" style="margin:8px 0 0;">مدارک شما ارسال شد و توسط تیم پشتیبانی بررسی میشود.</p>
|
||||||
|
}
|
||||||
|
else if (v == JobsMedical.Web.Models.VerificationStatus.Verified)
|
||||||
|
{
|
||||||
|
<p class="muted" style="margin:8px 0 0;">این مرکز تأیید شده و نشان «✓ تأیید شده» را در آگهیها نمایش میدهد.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad" style="margin-bottom:14px;">
|
||||||
|
<h3 style="margin-top:0;">بارگذاری مدارک</h3>
|
||||||
|
<p class="muted" style="font-size:13px; margin-top:0;">مجوز فعالیت، پروانه مطب/مرکز، یا هر سندی که واقعیبودن مرکز را نشان دهد. تصویر (JPG/PNG/WebP) یا PDF، حداکثر ۵ مگابایت برای هر فایل.</p>
|
||||||
|
<form method="post" asp-page-handler="Upload" asp-route-id="@Model.Facility.Id" enctype="multipart/form-data">
|
||||||
|
<div class="filter-group">
|
||||||
|
<input type="file" name="files" multiple accept="image/jpeg,image/png,image/webp,application/pdf" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-accent btn-block btn-lg">بارگذاری و ارسال برای بررسی</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 style="margin-top:0;">مدارک بارگذاریشده (@JalaliDate.ToPersianDigits(Model.Docs.Count.ToString()))</h3>
|
||||||
|
@if (Model.Docs.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="muted">هنوز مدرکی بارگذاری نشده است.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||||
|
@foreach (var d in Model.Docs)
|
||||||
|
{
|
||||||
|
<div class="info-row" style="align-items:center;">
|
||||||
|
<span class="k"><a href="/facility-doc/@d.Id" target="_blank">@d.FileName</a> <span class="muted" style="font-size:12px;">(@JalaliDate.ToPersianDigits((d.Size / 1024).ToString()) کیلوبایت)</span></span>
|
||||||
|
<span class="v">
|
||||||
|
<form method="post" asp-page-handler="DeleteDoc" asp-route-id="@Model.Facility.Id" asp-route-docId="@d.Id" style="display:inline;" onsubmit="return confirm('حذف این مدرک؟');">
|
||||||
|
<button type="submit" class="btn btn-outline" style="padding:4px 12px; color:var(--danger); border-color:var(--danger);">حذف</button>
|
||||||
|
</form>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
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.Employer;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
public class VerifyModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public VerifyModel(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public Facility Facility { get; private set; } = null!;
|
||||||
|
public List<FacilityDocument> Docs { get; private set; } = new();
|
||||||
|
[TempData] public string? Msg { get; set; }
|
||||||
|
|
||||||
|
private static readonly string[] Allowed = { "image/jpeg", "image/png", "image/webp", "application/pdf" };
|
||||||
|
private const long MaxBytes = 5 * 1024 * 1024; // 5 MB per file
|
||||||
|
|
||||||
|
private int Uid => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync(int id)
|
||||||
|
{
|
||||||
|
var f = await _db.Facilities.Include(x => x.City).Include(x => x.District)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == Uid);
|
||||||
|
if (f is null) return NotFound();
|
||||||
|
Facility = f;
|
||||||
|
Docs = await _db.FacilityDocuments.Where(d => d.FacilityId == id)
|
||||||
|
.OrderByDescending(d => d.UploadedAt).ToListAsync();
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostUploadAsync(int id, List<IFormFile> files)
|
||||||
|
{
|
||||||
|
var f = await _db.Facilities.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == Uid);
|
||||||
|
if (f is null) return NotFound();
|
||||||
|
|
||||||
|
int added = 0;
|
||||||
|
foreach (var file in files ?? new())
|
||||||
|
{
|
||||||
|
if (file.Length == 0) continue;
|
||||||
|
if (file.Length > MaxBytes) { Msg = "هر فایل باید کمتر از ۵ مگابایت باشد."; continue; }
|
||||||
|
var ct = (file.ContentType ?? "").ToLowerInvariant();
|
||||||
|
if (!Allowed.Contains(ct)) { Msg = "فقط تصویر (JPG/PNG/WebP) یا PDF مجاز است."; continue; }
|
||||||
|
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await file.CopyToAsync(ms);
|
||||||
|
_db.FacilityDocuments.Add(new FacilityDocument
|
||||||
|
{
|
||||||
|
FacilityId = id,
|
||||||
|
FileName = Path.GetFileName(file.FileName),
|
||||||
|
ContentType = ct,
|
||||||
|
Size = file.Length,
|
||||||
|
Data = ms.ToArray(),
|
||||||
|
});
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (added > 0)
|
||||||
|
{
|
||||||
|
if (f.Verification != VerificationStatus.Verified)
|
||||||
|
{
|
||||||
|
f.Verification = VerificationStatus.Pending; // submitting docs = request review
|
||||||
|
f.VerificationRequestedAt = DateTime.UtcNow;
|
||||||
|
f.VerificationNote = null;
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
Msg = $"{added} سند بارگذاری و برای بررسی ارسال شد.";
|
||||||
|
}
|
||||||
|
return RedirectToPage(new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostDeleteDocAsync(int id, int docId)
|
||||||
|
{
|
||||||
|
var f = await _db.Facilities.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == Uid);
|
||||||
|
if (f is null) return NotFound();
|
||||||
|
var d = await _db.FacilityDocuments.FirstOrDefaultAsync(x => x.Id == docId && x.FacilityId == id);
|
||||||
|
if (d is not null) { _db.FacilityDocuments.Remove(d); await _db.SaveChangesAsync(); }
|
||||||
|
return RedirectToPage(new { id });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,6 +106,20 @@
|
|||||||
<button type="submit" class="btn btn-outline btn-block" style="margin-top:6px;">ارسال گزارش</button>
|
<button type="submit" class="btn btn-outline btn-block" style="margin-top:6px;">ارسال گزارش</button>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
|
@if (j.Facility is not null)
|
||||||
|
{
|
||||||
|
<details style="margin-top:6px;">
|
||||||
|
<summary class="muted" style="font-size:12px; cursor:pointer;">شکایت از این مرکز (@j.Facility.Name)</summary>
|
||||||
|
<form method="post" action="/report" style="margin-top:8px;">
|
||||||
|
<input type="hidden" name="targetType" value="Facility" />
|
||||||
|
<input type="hidden" name="targetId" value="@j.Facility.Id" />
|
||||||
|
<input type="hidden" name="label" value="@j.Facility.Name" />
|
||||||
|
<input type="hidden" name="returnUrl" value="/Jobs/Details/@j.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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -22,12 +22,12 @@
|
|||||||
{
|
{
|
||||||
<span class="badge badge-gender">@JalaliDate.GenderLabel(Model.GenderRequirement)</span>
|
<span class="badge badge-gender">@JalaliDate.GenderLabel(Model.GenderRequirement)</span>
|
||||||
}
|
}
|
||||||
<span>📍 @Model.Facility?.City?.Name@(Model.Facility?.District is not null ? "، " + Model.Facility.District.Name : "")</span>
|
|
||||||
@if (Model.Facility?.IsVerified == true)
|
@if (Model.Facility?.IsVerified == true)
|
||||||
{
|
{
|
||||||
<span class="badge badge-verified">✓ تأیید شده</span>
|
<span class="badge badge-verified">✓ تأیید شده</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row loc-row">📍 @Model.Facility?.City?.Name@(Model.Facility?.District is not null ? "، " + Model.Facility.District.Name : "")</div>
|
||||||
@if (Model.DistanceKm is double km)
|
@if (Model.DistanceKm is double km)
|
||||||
{
|
{
|
||||||
<div class="row"><span class="badge badge-distance">📍 @JalaliDate.ToPersianDigits(km.ToString("0.#")) کیلومتر از شما</span></div>
|
<div class="row"><span class="badge badge-distance">📍 @JalaliDate.ToPersianDigits(km.ToString("0.#")) کیلومتر از شما</span></div>
|
||||||
|
|||||||
@@ -125,6 +125,17 @@
|
|||||||
<button type="submit" class="btn btn-outline btn-block" style="margin-top:6px;">ارسال گزارش</button>
|
<button type="submit" class="btn btn-outline btn-block" style="margin-top:6px;">ارسال گزارش</button>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
|
<details style="margin-top:6px;">
|
||||||
|
<summary class="muted" style="font-size:12px; cursor:pointer;">شکایت از این مرکز (@f.Name)</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="/Shifts/Details/@s.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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -208,6 +208,17 @@ app.MapGet("/notifications/stream", async (HttpContext ctx, NotificationHub hub)
|
|||||||
finally { unsubscribe(); }
|
finally { unsubscribe(); }
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
// Serve a facility verification document — only the facility owner or an admin may read it.
|
||||||
|
app.MapGet("/facility-doc/{id:int}", async (int id, HttpContext ctx, AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var doc = await db.FacilityDocuments.Include(d => d.Facility).FirstOrDefaultAsync(d => d.Id == id);
|
||||||
|
if (doc is null) return Results.NotFound();
|
||||||
|
var isAdmin = ctx.User.IsInRole("Admin");
|
||||||
|
var uid = int.TryParse(ctx.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var n) ? n : (int?)null;
|
||||||
|
if (!isAdmin && doc.Facility.OwnerUserId != uid) return Results.Forbid();
|
||||||
|
return Results.File(doc.Data, doc.ContentType, doc.FileName);
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
// User-submitted report against a listing (abuse/fake/wrong info).
|
// User-submitted report against a listing (abuse/fake/wrong info).
|
||||||
app.MapPost("/report", async (HttpContext ctx, AppDbContext db, VisitorContext vc,
|
app.MapPost("/report", async (HttpContext ctx, AppDbContext db, VisitorContext vc,
|
||||||
[FromForm] string targetType, [FromForm] int targetId, [FromForm] string reason,
|
[FromForm] string targetType, [FromForm] int targetId, [FromForm] string reason,
|
||||||
|
|||||||
Reference in New Issue
Block a user