From 6cfdd16c42336e1200b088fb00fb6e59f2efce6a Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 4 Jun 2026 00:19:32 +0330 Subject: [PATCH] =?UTF-8?q?Add=20gender=20requirement=20(=D8=A2=D9=82?= =?UTF-8?q?=D8=A7/=D8=AE=D8=A7=D9=86=D9=85/=D9=81=D8=B1=D9=82=DB=8C=20?= =?UTF-8?q?=D9=86=D9=85=DB=8C=E2=80=8C=DA=A9=D9=86=D8=AF)=20+=20employee?= =?UTF-8?q?=20(=DA=A9=D8=A7=D8=B1=D8=AC=D9=88)=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gender enum + GenderRequirement on Shift/JobOpening + Gender on UserPreferences (migration) - Employer PostShift/PostJob + admin Review have a gender select; parser detects آقا/خانم/مرد/زن - Gender badge on cards + detail; gender filter on Shifts/Jobs; gender in preferences - Recommendations exclude listings whose gender requirement conflicts with the person's gender - Two panels: new /Me employee (کارجو) panel (recommendations + saved + applied + prefs) alongside /Employer; nav routes by role; /Account/Profile → /Me Co-Authored-By: Claude Opus 4.8 --- src/JobsMedical.Web/Data/SeedData.cs | 4 +- ...260603204652_GenderRequirement.Designer.cs | 842 ++++++++++++++++++ .../20260603204652_GenderRequirement.cs | 51 ++ .../Migrations/AppDbContextModelSnapshot.cs | 9 + src/JobsMedical.Web/Models/Enums.cs | 11 + src/JobsMedical.Web/Models/JobOpening.cs | 2 + src/JobsMedical.Web/Models/Shift.cs | 2 + src/JobsMedical.Web/Models/UserPreferences.cs | 1 + .../Pages/Account/Profile.cshtml | 76 +- .../Pages/Account/Profile.cshtml.cs | 47 +- src/JobsMedical.Web/Pages/Admin/Review.cshtml | 9 + .../Pages/Admin/Review.cshtml.cs | 4 + .../Pages/Employer/PostJob.cshtml | 8 + .../Pages/Employer/PostJob.cshtml.cs | 2 + .../Pages/Employer/PostShift.cshtml | 8 + .../Pages/Employer/PostShift.cshtml.cs | 2 + src/JobsMedical.Web/Pages/Jobs/Details.cshtml | 4 + src/JobsMedical.Web/Pages/Jobs/Index.cshtml | 8 + .../Pages/Jobs/Index.cshtml.cs | 3 + src/JobsMedical.Web/Pages/Me/Index.cshtml | 77 ++ src/JobsMedical.Web/Pages/Me/Index.cshtml.cs | 62 ++ .../Pages/Preferences/Index.cshtml | 9 + .../Pages/Preferences/Index.cshtml.cs | 4 +- .../Pages/Shared/_JobCard.cshtml | 4 + .../Pages/Shared/_Layout.cshtml | 2 +- .../Pages/Shared/_RecommendationCard.cshtml | 4 + .../Pages/Shared/_ShiftCard.cshtml | 4 + .../Pages/Shifts/Details.cshtml | 4 + src/JobsMedical.Web/Pages/Shifts/Index.cshtml | 8 + .../Pages/Shifts/Index.cshtml.cs | 4 + .../Services/InterestService.cs | 4 +- src/JobsMedical.Web/Services/JalaliDate.cs | 8 + src/JobsMedical.Web/Services/ListingParser.cs | 7 + .../Services/RecommendationService.cs | 5 + src/JobsMedical.Web/wwwroot/css/site.css | 1 + 35 files changed, 1177 insertions(+), 123 deletions(-) create mode 100644 src/JobsMedical.Web/Migrations/20260603204652_GenderRequirement.Designer.cs create mode 100644 src/JobsMedical.Web/Migrations/20260603204652_GenderRequirement.cs create mode 100644 src/JobsMedical.Web/Pages/Me/Index.cshtml create mode 100644 src/JobsMedical.Web/Pages/Me/Index.cshtml.cs diff --git a/src/JobsMedical.Web/Data/SeedData.cs b/src/JobsMedical.Web/Data/SeedData.cs index 79f7d27..40c0b96 100644 --- a/src/JobsMedical.Web/Data/SeedData.cs +++ b/src/JobsMedical.Web/Data/SeedData.cs @@ -148,6 +148,8 @@ public static class SeedData PayType = payType, PayAmount = amount, SharePercent = share, + GenderRequirement = role.Name == "ماما" ? Gender.Female + : rng.Next(0, 4) == 0 ? (Gender)rng.Next(1, 3) : Gender.Any, Status = ShiftStatus.Open, Source = ShiftSource.Admin, }); @@ -170,7 +172,7 @@ public static class SeedData Status = ShiftStatus.Open }, new JobOpening { FacilityId = facilities[2].Id, RoleId = roles[3].Id, Title = "ماما جهت کلینیک زنان", EmploymentType = EmploymentType.PartTime, - SalaryMin = null, SalaryMax = null, + SalaryMin = null, SalaryMax = null, GenderRequirement = Gender.Female, Description = "همکاری پاره‌وقت ماما در کلینیک تخصصی زنان و زایمان.", Status = ShiftStatus.Open }, new JobOpening { FacilityId = facilities[4].Id, RoleId = roles[4].Id, diff --git a/src/JobsMedical.Web/Migrations/20260603204652_GenderRequirement.Designer.cs b/src/JobsMedical.Web/Migrations/20260603204652_GenderRequirement.Designer.cs new file mode 100644 index 0000000..80bbeaf --- /dev/null +++ b/src/JobsMedical.Web/Migrations/20260603204652_GenderRequirement.Designer.cs @@ -0,0 +1,842 @@ +// +using System; +using JobsMedical.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace JobsMedical.Web.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260603204652_GenderRequirement")] + partial class GenderRequirement + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("JobsMedical.Web.Models.AppSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AiApiKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AiAutoApprove") + .HasColumnType("boolean"); + + b.Property("AiEnabled") + .HasColumnType("boolean"); + + b.Property("AiEndpoint") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AiModel") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("AiSystemPrompt") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("AutoPublishMinConfidence") + .HasColumnType("integer"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Application", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorId") + .HasColumnType("integer"); + + b.Property("Message") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ShiftId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("ShiftId", "DoctorId") + .IsUnique(); + + b.ToTable("Applications"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.City", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Province") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.ToTable("Cities"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.District", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.ToTable("Districts"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.DoctorProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("CityId") + .HasColumnType("integer"); + + b.Property("IsVerified") + .HasColumnType("boolean"); + + b.Property("LicenseNo") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("Specialty") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("YearsExperience") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("RoleId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("DoctorProfiles"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Facility", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BaleId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CityId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DistrictId") + .HasColumnType("integer"); + + b.Property("IsVerified") + .HasColumnType("boolean"); + + b.Property("Lat") + .HasColumnType("double precision"); + + b.Property("Lng") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("integer"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("DistrictId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Facilities"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("JobOpeningId") + .HasColumnType("integer"); + + b.Property("ShiftId") + .HasColumnType("integer"); + + b.Property("VisitorId") + .IsRequired() + .HasColumnType("character varying(36)"); + + b.HasKey("Id"); + + b.HasIndex("JobOpeningId"); + + b.HasIndex("ShiftId"); + + b.HasIndex("VisitorId", "CreatedAt"); + + b.ToTable("InterestEvents"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EmploymentType") + .HasColumnType("integer"); + + b.Property("FacilityId") + .HasColumnType("integer"); + + b.Property("GenderRequirement") + .HasColumnType("integer"); + + b.Property("Requirements") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("SalaryMax") + .HasColumnType("bigint"); + + b.Property("SalaryMin") + .HasColumnType("bigint"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.HasIndex("RoleId"); + + b.HasIndex("Status"); + + b.ToTable("JobOpenings"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.RawListing", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Confidence") + .HasColumnType("integer"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FetchedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LinkedShiftId") + .HasColumnType("integer"); + + b.Property("ParsedJson") + .HasColumnType("text"); + + b.Property("RawText") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceChannel") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SourceUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("ValidationNotes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("ContentHash"); + + b.HasIndex("LinkedShiftId"); + + b.HasIndex("Status"); + + b.ToTable("RawListings"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Roles"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Shift", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .HasMaxLength(1500) + .HasColumnType("character varying(1500)"); + + b.Property("EndTime") + .HasColumnType("time without time zone"); + + b.Property("FacilityId") + .HasColumnType("integer"); + + b.Property("GenderRequirement") + .HasColumnType("integer"); + + b.Property("PayAmount") + .HasColumnType("bigint"); + + b.Property("PayType") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("SharePercent") + .HasColumnType("integer"); + + b.Property("ShiftType") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SpecialtyRequired") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StartTime") + .HasColumnType("time without time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.HasIndex("RoleId"); + + b.HasIndex("Date", "Status"); + + b.ToTable("Shifts"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FullName") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("IsPhoneVerified") + .HasColumnType("boolean"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Phone") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.UserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("integer"); + + b.Property("Gender") + .HasColumnType("integer"); + + b.Property("MinPay") + .HasColumnType("bigint"); + + b.Property("PreferredShiftType") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VisitorId") + .IsRequired() + .HasColumnType("character varying(36)"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("RoleId"); + + b.HasIndex("VisitorId") + .IsUnique(); + + b.ToTable("UserPreferences"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Visitor", b => + { + b.Property("Id") + .HasMaxLength(36) + .HasColumnType("character varying(36)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Visitors"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Application", b => + { + b.HasOne("JobsMedical.Web.Models.User", "Doctor") + .WithMany("Applications") + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobsMedical.Web.Models.Shift", "Shift") + .WithMany("Applications") + .HasForeignKey("ShiftId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Doctor"); + + b.Navigation("Shift"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.District", b => + { + b.HasOne("JobsMedical.Web.Models.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("City"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.DoctorProfile", b => + { + b.HasOne("JobsMedical.Web.Models.City", "City") + .WithMany() + .HasForeignKey("CityId"); + + b.HasOne("JobsMedical.Web.Models.Role", "Role") + .WithMany() + .HasForeignKey("RoleId"); + + b.HasOne("JobsMedical.Web.Models.User", "User") + .WithOne("DoctorProfile") + .HasForeignKey("JobsMedical.Web.Models.DoctorProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Facility", b => + { + b.HasOne("JobsMedical.Web.Models.City", "City") + .WithMany("Facilities") + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobsMedical.Web.Models.District", "District") + .WithMany("Facilities") + .HasForeignKey("DistrictId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("JobsMedical.Web.Models.User", "OwnerUser") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("City"); + + b.Navigation("District"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b => + { + b.HasOne("JobsMedical.Web.Models.JobOpening", "JobOpening") + .WithMany() + .HasForeignKey("JobOpeningId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("JobsMedical.Web.Models.Shift", "Shift") + .WithMany() + .HasForeignKey("ShiftId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("JobsMedical.Web.Models.Visitor", "Visitor") + .WithMany("Events") + .HasForeignKey("VisitorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobOpening"); + + b.Navigation("Shift"); + + b.Navigation("Visitor"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b => + { + b.HasOne("JobsMedical.Web.Models.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobsMedical.Web.Models.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.RawListing", b => + { + b.HasOne("JobsMedical.Web.Models.Shift", "LinkedShift") + .WithMany() + .HasForeignKey("LinkedShiftId"); + + b.Navigation("LinkedShift"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Shift", b => + { + b.HasOne("JobsMedical.Web.Models.Facility", "Facility") + .WithMany("Shifts") + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobsMedical.Web.Models.Role", "Role") + .WithMany("Shifts") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.UserPreferences", b => + { + b.HasOne("JobsMedical.Web.Models.City", "City") + .WithMany() + .HasForeignKey("CityId"); + + b.HasOne("JobsMedical.Web.Models.Role", "Role") + .WithMany() + .HasForeignKey("RoleId"); + + b.HasOne("JobsMedical.Web.Models.Visitor", "Visitor") + .WithOne("Preferences") + .HasForeignKey("JobsMedical.Web.Models.UserPreferences", "VisitorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("Role"); + + b.Navigation("Visitor"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Visitor", b => + { + b.HasOne("JobsMedical.Web.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.City", b => + { + b.Navigation("Facilities"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.District", b => + { + b.Navigation("Facilities"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Facility", b => + { + b.Navigation("Shifts"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Role", b => + { + b.Navigation("Shifts"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Shift", b => + { + b.Navigation("Applications"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.User", b => + { + b.Navigation("Applications"); + + b.Navigation("DoctorProfile"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Visitor", b => + { + b.Navigation("Events"); + + b.Navigation("Preferences"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/JobsMedical.Web/Migrations/20260603204652_GenderRequirement.cs b/src/JobsMedical.Web/Migrations/20260603204652_GenderRequirement.cs new file mode 100644 index 0000000..fd243a7 --- /dev/null +++ b/src/JobsMedical.Web/Migrations/20260603204652_GenderRequirement.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobsMedical.Web.Migrations +{ + /// + public partial class GenderRequirement : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Gender", + table: "UserPreferences", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "GenderRequirement", + table: "Shifts", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "GenderRequirement", + table: "JobOpenings", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Gender", + table: "UserPreferences"); + + migrationBuilder.DropColumn( + name: "GenderRequirement", + table: "Shifts"); + + migrationBuilder.DropColumn( + name: "GenderRequirement", + table: "JobOpenings"); + } + } +} diff --git a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs index 6d15fd1..721e58e 100644 --- a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs +++ b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs @@ -317,6 +317,9 @@ namespace JobsMedical.Web.Migrations b.Property("FacilityId") .HasColumnType("integer"); + b.Property("GenderRequirement") + .HasColumnType("integer"); + b.Property("Requirements") .HasMaxLength(1000) .HasColumnType("character varying(1000)"); @@ -464,6 +467,9 @@ namespace JobsMedical.Web.Migrations b.Property("FacilityId") .HasColumnType("integer"); + b.Property("GenderRequirement") + .HasColumnType("integer"); + b.Property("PayAmount") .HasColumnType("bigint"); @@ -553,6 +559,9 @@ namespace JobsMedical.Web.Migrations b.Property("CityId") .HasColumnType("integer"); + b.Property("Gender") + .HasColumnType("integer"); + b.Property("MinPay") .HasColumnType("bigint"); diff --git a/src/JobsMedical.Web/Models/Enums.cs b/src/JobsMedical.Web/Models/Enums.cs index 6326faf..13f50df 100644 --- a/src/JobsMedical.Web/Models/Enums.cs +++ b/src/JobsMedical.Web/Models/Enums.cs @@ -76,6 +76,17 @@ public enum ListingKind Job = 1 } +/// +/// Gender. On a listing it's the requirement (Any = فرقی نمی‌کند); on a person it's their gender +/// (Any = unspecified). +/// +public enum Gender +{ + Any = 0, // فرقی نمی‌کند / نامشخص + Male = 1, // آقا + Female = 2 // خانم +} + /// How ingested listings get onto the site. public enum IngestionMode { diff --git a/src/JobsMedical.Web/Models/JobOpening.cs b/src/JobsMedical.Web/Models/JobOpening.cs index 4d8b511..72417db 100644 --- a/src/JobsMedical.Web/Models/JobOpening.cs +++ b/src/JobsMedical.Web/Models/JobOpening.cs @@ -32,6 +32,8 @@ public class JobOpening [MaxLength(1000)] public string? Requirements { get; set; } // شرایط احراز + public Gender GenderRequirement { get; set; } = Gender.Any; // جنسیت مورد نیاز + public ShiftStatus Status { get; set; } = ShiftStatus.Open; public ShiftSource Source { get; set; } = ShiftSource.Admin; diff --git a/src/JobsMedical.Web/Models/Shift.cs b/src/JobsMedical.Web/Models/Shift.cs index 76aff67..186a49f 100644 --- a/src/JobsMedical.Web/Models/Shift.cs +++ b/src/JobsMedical.Web/Models/Shift.cs @@ -29,6 +29,8 @@ public class Shift public int? SharePercent { get; set; } // سهم درآمد (٪)؛ مثلاً ۵۰. می‌تواند همراه مبلغ هم باشد public PayType PayType { get; set; } = PayType.PerShift; + public Gender GenderRequirement { get; set; } = Gender.Any; // جنسیت مورد نیاز + [MaxLength(1500)] public string? Description { get; set; } // توضیحات diff --git a/src/JobsMedical.Web/Models/UserPreferences.cs b/src/JobsMedical.Web/Models/UserPreferences.cs index 5a2d117..6cbd2b9 100644 --- a/src/JobsMedical.Web/Models/UserPreferences.cs +++ b/src/JobsMedical.Web/Models/UserPreferences.cs @@ -19,6 +19,7 @@ public class UserPreferences public ShiftType? PreferredShiftType { get; set; } // نوع شیفت ترجیحی public long? MinPay { get; set; } // حداقل حقوق مورد انتظار (تومان) + public Gender Gender { get; set; } = Gender.Any; // جنسیت کارجو (برای تطبیق با نیاز آگهی) public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; diff --git a/src/JobsMedical.Web/Pages/Account/Profile.cshtml b/src/JobsMedical.Web/Pages/Account/Profile.cshtml index ea9dd90..8740f75 100644 --- a/src/JobsMedical.Web/Pages/Account/Profile.cshtml +++ b/src/JobsMedical.Web/Pages/Account/Profile.cshtml @@ -1,77 +1,3 @@ @page @model JobsMedical.Web.Pages.Account.ProfileModel -@{ - ViewData["Title"] = "پروفایل من"; - string RoleLabel(UserRole r) => r switch - { - UserRole.Admin => "مدیر", - UserRole.FacilityAdmin => "مدیر مرکز درمانی", - _ => "کادر درمان", - }; -} - -
-
-

پروفایل من

-

- 📱 @JalaliDate.ToPersianDigits(Model.CurrentUser?.Phone ?? "") - — @RoleLabel(Model.CurrentUser?.Role ?? UserRole.Doctor) -

-
-
- -
-
-
-

علاقه‌مندی‌هایت را کامل کن

- تا پیشنهادهای دقیق‌تری بگیری -
- تنظیم علاقه‌مندی‌ها -
- - @if (User.IsInRole("FacilityAdmin") || User.IsInRole("Admin")) - { -

→ ورود به پنل کارفرما

- } - else - { -

مرکز درمانی هستی و می‌خواهی شیفت یا استخدام منتشر کنی؟ - مرکز خود را ثبت کن

- } - -

شیفت‌های ذخیره‌شده

- @if (Model.SavedShifts.Count == 0) - { -
هنوز شیفتی ذخیره نکرده‌ای.
- } - else - { -
- @foreach (var s in Model.SavedShifts) { } -
- } - -

شیفت‌هایی که اعلام تمایل کردی

- @if (Model.AppliedShifts.Count == 0) - { -
هنوز برای شیفتی اعلام تمایل نکرده‌ای.
- } - else - { -
- @foreach (var s in Model.AppliedShifts) { } -
- } - -

موقعیت‌های استخدامی که اعلام تمایل کردی

- @if (Model.AppliedJobs.Count == 0) - { -
هنوز برای موقعیتی اعلام تمایل نکرده‌ای.
- } - else - { -
- @foreach (var j in Model.AppliedJobs) { } -
- } -
+@* Redirects to /Me (employee panel). *@ diff --git a/src/JobsMedical.Web/Pages/Account/Profile.cshtml.cs b/src/JobsMedical.Web/Pages/Account/Profile.cshtml.cs index 8ed888d..dfda8c4 100644 --- a/src/JobsMedical.Web/Pages/Account/Profile.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Account/Profile.cshtml.cs @@ -1,53 +1,12 @@ -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.Account; +// Profile was merged into the employee panel (/Me). Keep the old URL working. [Authorize] public class ProfileModel : PageModel { - private readonly AppDbContext _db; - public ProfileModel(AppDbContext db) => _db = db; - - public User? CurrentUser { get; private set; } - public List SavedShifts { get; private set; } = new(); - public List AppliedJobs { get; private set; } = new(); - public List AppliedShifts { get; private set; } = new(); - - public async Task OnGetAsync() - { - var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); - CurrentUser = await _db.Users.FindAsync(userId); - - // All visitor ids this account has been linked to (across devices). - var visitorIds = await _db.Visitors.Where(v => v.UserId == userId).Select(v => v.Id).ToListAsync(); - - var events = await _db.InterestEvents - .Where(e => visitorIds.Contains(e.VisitorId)) - .OrderByDescending(e => e.CreatedAt) - .ToListAsync(); - - var savedShiftIds = events.Where(e => e.EventType == InterestEventType.Save && e.ShiftId != null) - .Select(e => e.ShiftId!.Value).Distinct().ToList(); - var appliedShiftIds = events.Where(e => e.EventType == InterestEventType.Apply && e.ShiftId != null) - .Select(e => e.ShiftId!.Value).Distinct().ToList(); - var appliedJobIds = events.Where(e => e.EventType == InterestEventType.Apply && e.JobOpeningId != null) - .Select(e => e.JobOpeningId!.Value).Distinct().ToList(); - - SavedShifts = await ShiftsByIds(savedShiftIds); - AppliedShifts = await ShiftsByIds(appliedShiftIds); - AppliedJobs = await _db.JobOpenings - .Include(j => j.Facility).ThenInclude(f => f.City).Include(j => j.Role) - .Where(j => appliedJobIds.Contains(j.Id)).ToListAsync(); - } - - private Task> ShiftsByIds(List ids) => _db.Shifts - .Include(s => s.Facility).ThenInclude(f => f.City) - .Include(s => s.Facility).ThenInclude(f => f.District) - .Include(s => s.Role) - .Where(s => ids.Contains(s.Id)).ToListAsync(); + public IActionResult OnGet() => RedirectToPage("/Me/Index"); } diff --git a/src/JobsMedical.Web/Pages/Admin/Review.cshtml b/src/JobsMedical.Web/Pages/Admin/Review.cshtml index 86dc9ec..b1f06e2 100644 --- a/src/JobsMedical.Web/Pages/Admin/Review.cshtml +++ b/src/JobsMedical.Web/Pages/Admin/Review.cshtml @@ -63,6 +63,15 @@ +
+ + +
+
diff --git a/src/JobsMedical.Web/Pages/Admin/Review.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Review.cshtml.cs index 7d226d2..c2e5e55 100644 --- a/src/JobsMedical.Web/Pages/Admin/Review.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Admin/Review.cshtml.cs @@ -38,6 +38,7 @@ public class ReviewModel : PageModel [BindProperty] public long? PayAmount { get; set; } [BindProperty] public int? SharePercent { get; set; } [BindProperty] public bool Negotiable { get; set; } + [BindProperty] public Gender GenderRequirement { get; set; } // Job fields [BindProperty] public string? Title { get; set; } [BindProperty] public EmploymentType EmploymentType { get; set; } @@ -62,6 +63,7 @@ public class ReviewModel : PageModel ShiftDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1); Negotiable = Parsed.PayNegotiable; SharePercent = Parsed.SharePercent; + GenderRequirement = Parsed.Gender; if (Parsed.PayAmount is not null) { PayAmount = Parsed.PayAmount; SalaryMin = Parsed.PayAmount; } Description = Raw.RawText; Title = Parsed.RoleName is not null ? $"استخدام {Parsed.RoleName}" : "موقعیت استخدامی"; @@ -90,6 +92,7 @@ public class ReviewModel : PageModel : (PayAmount is null && SharePercent is not null ? PayType.Percentage : PayType.PerShift), PayAmount = Negotiable ? null : PayAmount, SharePercent = Negotiable ? null : SharePercent, + GenderRequirement = GenderRequirement, Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl, @@ -109,6 +112,7 @@ public class ReviewModel : PageModel EmploymentType = EmploymentType, SalaryMin = Negotiable ? null : SalaryMin, SalaryMax = Negotiable ? null : SalaryMax, + GenderRequirement = GenderRequirement, Description = Description, Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, diff --git a/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml b/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml index c3bdb8e..0ad9aea 100644 --- a/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml +++ b/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml @@ -43,6 +43,14 @@ }
+
+ + +
+
+ + +
diff --git a/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml.cs b/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml.cs index ac5901e..d8a1a39 100644 --- a/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml.cs @@ -27,6 +27,7 @@ public class PostShiftModel : PageModel [BindProperty] public long? PayAmount { get; set; } [BindProperty] public int? SharePercent { get; set; } // سهم درآمد (٪) [BindProperty] public bool Negotiable { get; set; } + [BindProperty] public Gender GenderRequirement { get; set; } [BindProperty] public string? Description { get; set; } public async Task OnGetAsync() @@ -60,6 +61,7 @@ public class PostShiftModel : PageModel : (PayAmount is null && SharePercent is not null ? PayType.Percentage : PayType.PerShift), PayAmount = Negotiable ? null : PayAmount, SharePercent = Negotiable ? null : SharePercent, + GenderRequirement = GenderRequirement, Status = ShiftStatus.Open, Source = ShiftSource.Direct, // posted directly by the facility }); diff --git a/src/JobsMedical.Web/Pages/Jobs/Details.cshtml b/src/JobsMedical.Web/Pages/Jobs/Details.cshtml index 0f0702e..d275443 100644 --- a/src/JobsMedical.Web/Pages/Jobs/Details.cshtml +++ b/src/JobsMedical.Web/Pages/Jobs/Details.cshtml @@ -50,6 +50,10 @@

مشخصات موقعیت

نوع همکاری@empLabel
نقش@j.Role?.Name
+ @if (j.GenderRequirement != Gender.Any) + { +
جنسیت@JalaliDate.GenderLabel(j.GenderRequirement)
+ }
حقوق ماهانه@salary
diff --git a/src/JobsMedical.Web/Pages/Jobs/Index.cshtml b/src/JobsMedical.Web/Pages/Jobs/Index.cshtml index dcfc715..9d5b7ca 100644 --- a/src/JobsMedical.Web/Pages/Jobs/Index.cshtml +++ b/src/JobsMedical.Web/Pages/Jobs/Index.cshtml @@ -65,6 +65,14 @@ }
+
+ + +
+
+ + +

برای حذف آگهی‌هایی که با جنسیت شما هم‌خوان نیستند.

+
+
+ + +