diff --git a/README.md b/README.md index dd1605b..31397d0 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,10 @@ Then open the URL printed in the console (e.g. http://localhost:5020). | `/Account/Profile` | پروفایل: شیفت‌ها/موقعیت‌های ذخیره‌شده و اعلام تمایل‌ها | | `/Admin` | پنل مدیریت (نقش Admin): صف آگهی‌های خام | | `/Admin/Review/{id}` | بررسی خودکار (پارسر) و انتشار آگهی به‌صورت شیفت یا استخدام | +| `/Employer` | **پنل کارفرما**: مراکز من + شمار شیفت/استخدام/متقاضی | +| `/Employer/RegisterFacility` | ثبت مرکز درمانی (خودسرویس → نقش FacilityAdmin) | +| `/Employer/PostShift`، `/PostJob` | انتشار شیفت یا موقعیت استخدامی | +| `/Employer/Listings` | مدیریت آگهی‌ها (بستن/بازگشایی/حذف) + لیست متقاضیان با تماس | ## How recommendations work (Stage 1 — pattern engine) `RecommendationService` scores open shifts against (a) explicit `UserPreferences` and (b) recent @@ -56,7 +60,19 @@ Done: multi-role domain model, Postgres + migrations, RTL shell, browse/filter ( district, near-me), weekly Jalali calendar, shift detail + interest handoff, interest tracking + pattern-engine recommendation feed + preferences, self-hosted Vazirmatn + teal/coral palette, **hiring (استخدام) listings**, **admin queue + heuristic listing-parser** (raw channel post → -structured shift/job), **phone-OTP auth + visitor-history linking + profile**, Tehran seed data. +structured shift/job), **phone-OTP auth + visitor-history linking + profile**, **employer side** (self-serve facility +registration → FacilityAdmin role, post/manage shifts & jobs, applicants list with contact), +Tehran seed data. + +Both sides of the marketplace now work end-to-end: an employer self-registers a facility, posts a +shift, it appears publicly, a logged-in user applies, and the employer sees that applicant's +phone in their dashboard. + +### Compensation models +Shifts support fixed (مقطوع), hourly (ساعتی), **profit-share (درصدی / سهم درآمد)**, توافقی, or a +**choice** between a fixed amount and a share % ("… یا … به انتخاب شما"). `JalaliDate.PayLabel` +centralizes the display; `Shift.SharePercent` holds the percentage; the listing-parser detects +"۵۰٪ / درصد / سهم" from raw posts; and `/Shifts` has a "سهم درآمد" filter. ### Listing parser (Stage 1) `IListingParser` / `HeuristicListingParser` extracts kind (shift vs hire), role, shift type, diff --git a/src/JobsMedical.Web/Data/SeedData.cs b/src/JobsMedical.Web/Data/SeedData.cs index 96d0d31..8af0645 100644 --- a/src/JobsMedical.Web/Data/SeedData.cs +++ b/src/JobsMedical.Web/Data/SeedData.cs @@ -111,8 +111,25 @@ public static class SeedData for (var i = 0; i < count; i++) { var t = templates[rng.Next(templates.Length)]; - var negotiable = rng.Next(0, 4) == 0; var role = rolePool[rng.Next(rolePool.Length)]; + + // Vary the compensation model: fixed, profit-share, both (choose), or negotiable. + var payType = PayType.PerShift; + long? amount = t.Item5; + int? share = null; + if (t.Item1 == ShiftType.OnCall) { payType = PayType.Negotiable; amount = null; } + else + { + switch (rng.Next(0, 5)) + { + case 0: payType = PayType.Negotiable; amount = null; break; // توافقی + case 1: payType = PayType.Percentage; amount = null; // درصدی + share = rng.Next(0, 2) == 0 ? 50 : 60; break; + case 2: share = rng.Next(0, 2) == 0 ? 40 : 50; break; // مبلغ یا درصد (به انتخاب) + default: break; // مبلغ مقطوع + } + } + shifts.Add(new Shift { FacilityId = f.Id, @@ -123,8 +140,9 @@ public static class SeedData ShiftType = t.Item1, SpecialtyRequired = role.Name, Description = $"{t.Item4} - نیازمند {role.Name} مسلط به امور درمانگاه/اورژانس", - PayType = t.Item1 == ShiftType.OnCall || negotiable ? PayType.Negotiable : PayType.PerShift, - PayAmount = t.Item1 == ShiftType.OnCall || negotiable ? null : t.Item5, + PayType = payType, + PayAmount = amount, + SharePercent = share, Status = ShiftStatus.Open, Source = ShiftSource.Admin, }); @@ -176,6 +194,12 @@ public static class SeedData SourceChannel = "Divar - استخدام پزشک", RawText = "بیمارستان خصوصی جهت تکمیل کادر درمان به پزشک عمومی برای شیفت‌های روز نیازمند است.", Status = RawListingStatus.New, + }, + new RawListing + { + SourceChannel = "کانال درمانگاه‌های تهران", + RawText = "درمانگاه شبانه‌روزی نیازمند پزشک عمومی برای شیفت عصر، پرداخت ۵۰٪ سهم درآمد ویزیت. سعادت‌آباد.", + Status = RawListingStatus.New, }); await db.SaveChangesAsync(); } diff --git a/src/JobsMedical.Web/Migrations/20260602224558_AddSharePercent.Designer.cs b/src/JobsMedical.Web/Migrations/20260602224558_AddSharePercent.Designer.cs new file mode 100644 index 0000000..e425490 --- /dev/null +++ b/src/JobsMedical.Web/Migrations/20260602224558_AddSharePercent.Designer.cs @@ -0,0 +1,773 @@ +// +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("20260602224558_AddSharePercent")] + partial class AddSharePercent + { + /// + 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.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("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("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.HasKey("Id"); + + b.HasIndex("LinkedShiftId"); + + 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("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("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/20260602224558_AddSharePercent.cs b/src/JobsMedical.Web/Migrations/20260602224558_AddSharePercent.cs new file mode 100644 index 0000000..1027934 --- /dev/null +++ b/src/JobsMedical.Web/Migrations/20260602224558_AddSharePercent.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobsMedical.Web.Migrations +{ + /// + public partial class AddSharePercent : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SharePercent", + table: "Shifts", + type: "integer", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SharePercent", + table: "Shifts"); + } + } +} diff --git a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs index 3fddc23..e53a23f 100644 --- a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs +++ b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs @@ -413,6 +413,9 @@ namespace JobsMedical.Web.Migrations b.Property("RoleId") .HasColumnType("integer"); + b.Property("SharePercent") + .HasColumnType("integer"); + b.Property("ShiftType") .HasColumnType("integer"); diff --git a/src/JobsMedical.Web/Models/Enums.cs b/src/JobsMedical.Web/Models/Enums.cs index eaf58a5..08a8fdd 100644 --- a/src/JobsMedical.Web/Models/Enums.cs +++ b/src/JobsMedical.Web/Models/Enums.cs @@ -41,7 +41,8 @@ public enum PayType { PerShift = 0, // مقطوع برای هر شیفت PerHour = 1, // ساعتی - Negotiable = 2 // توافقی + Negotiable = 2, // توافقی + Percentage = 3 // درصدی / سهم درآمد } public enum ApplicationStatus diff --git a/src/JobsMedical.Web/Models/Shift.cs b/src/JobsMedical.Web/Models/Shift.cs index 27b9d25..76aff67 100644 --- a/src/JobsMedical.Web/Models/Shift.cs +++ b/src/JobsMedical.Web/Models/Shift.cs @@ -25,7 +25,8 @@ public class Shift public ShiftType ShiftType { get; set; } = ShiftType.Day; - public long? PayAmount { get; set; } // مبلغ (تومان)؛ null یعنی توافقی + public long? PayAmount { get; set; } // مبلغ مقطوع (تومان)؛ null اگر فقط درصدی/توافقی + public int? SharePercent { get; set; } // سهم درآمد (٪)؛ مثلاً ۵۰. می‌تواند همراه مبلغ هم باشد public PayType PayType { get; set; } = PayType.PerShift; [MaxLength(1500)] diff --git a/src/JobsMedical.Web/Pages/Account/Login.cshtml.cs b/src/JobsMedical.Web/Pages/Account/Login.cshtml.cs index 5644277..12bd931 100644 --- a/src/JobsMedical.Web/Pages/Account/Login.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Account/Login.cshtml.cs @@ -84,16 +84,8 @@ public class LoginModel : PageModel await _db.SaveChangesAsync(); } - var claims = new List - { - new(ClaimTypes.NameIdentifier, user.Id.ToString()), - new(ClaimTypes.MobilePhone, user.Phone), - new(ClaimTypes.Name, user.FullName ?? user.Phone), - new(ClaimTypes.Role, user.Role.ToString()), - }; - var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, - new ClaimsPrincipal(identity)); + AuthHelper.BuildPrincipal(user)); return LocalRedirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl); } diff --git a/src/JobsMedical.Web/Pages/Account/Profile.cshtml b/src/JobsMedical.Web/Pages/Account/Profile.cshtml index f9a5409..ea9dd90 100644 --- a/src/JobsMedical.Web/Pages/Account/Profile.cshtml +++ b/src/JobsMedical.Web/Pages/Account/Profile.cshtml @@ -29,6 +29,16 @@ تنظیم علاقه‌مندی‌ها + @if (User.IsInRole("FacilityAdmin") || User.IsInRole("Admin")) + { +

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

+ } + else + { +

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

+ } +

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

@if (Model.SavedShifts.Count == 0) { diff --git a/src/JobsMedical.Web/Pages/Admin/Review.cshtml b/src/JobsMedical.Web/Pages/Admin/Review.cshtml index 155c42f..86dc9ec 100644 --- a/src/JobsMedical.Web/Pages/Admin/Review.cshtml +++ b/src/JobsMedical.Web/Pages/Admin/Review.cshtml @@ -81,9 +81,9 @@
-
- - +
+
+
diff --git a/src/JobsMedical.Web/Pages/Admin/Review.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Review.cshtml.cs index 28ee247..7d226d2 100644 --- a/src/JobsMedical.Web/Pages/Admin/Review.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Admin/Review.cshtml.cs @@ -36,6 +36,7 @@ public class ReviewModel : PageModel [BindProperty] public TimeOnly StartTime { get; set; } [BindProperty] public TimeOnly EndTime { get; set; } [BindProperty] public long? PayAmount { get; set; } + [BindProperty] public int? SharePercent { get; set; } [BindProperty] public bool Negotiable { get; set; } // Job fields [BindProperty] public string? Title { get; set; } @@ -60,6 +61,7 @@ public class ReviewModel : PageModel (StartTime, EndTime) = DefaultTimes(ShiftType); ShiftDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1); Negotiable = Parsed.PayNegotiable; + SharePercent = Parsed.SharePercent; if (Parsed.PayAmount is not null) { PayAmount = Parsed.PayAmount; SalaryMin = Parsed.PayAmount; } Description = Raw.RawText; Title = Parsed.RoleName is not null ? $"استخدام {Parsed.RoleName}" : "موقعیت استخدامی"; @@ -84,8 +86,10 @@ public class ReviewModel : PageModel ShiftType = ShiftType, SpecialtyRequired = role?.Name ?? "", Description = Description, - PayType = Negotiable ? PayType.Negotiable : PayType.PerShift, + PayType = Negotiable ? PayType.Negotiable + : (PayAmount is null && SharePercent is not null ? PayType.Percentage : PayType.PerShift), PayAmount = Negotiable ? null : PayAmount, + SharePercent = Negotiable ? null : SharePercent, Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl, diff --git a/src/JobsMedical.Web/Pages/Employer/Index.cshtml b/src/JobsMedical.Web/Pages/Employer/Index.cshtml new file mode 100644 index 0000000..f299dbf --- /dev/null +++ b/src/JobsMedical.Web/Pages/Employer/Index.cshtml @@ -0,0 +1,68 @@ +@page +@model JobsMedical.Web.Pages.Employer.IndexModel +@{ + ViewData["Title"] = "پنل کارفرما"; + string TypeLabel(FacilityType t) => t switch + { + FacilityType.Hospital => "بیمارستان", + FacilityType.Clinic => "کلینیک", + _ => "درمانگاه", + }; +} + +
+
+

پنل مرکز درمانی

+

شیفت‌ها و موقعیت‌های استخدامی مرکز خود را مدیریت کن.

+
+
+ +
+ @if (Model.Facilities.Count == 0) + { +
+

هنوز مرکزی ثبت نکرده‌ای.

+ ثبت مرکز درمانی +
+ } + else + { +
+
+

انتشار فرصت جدید

+ شیفت یا موقعیت استخدامی منتشر کن +
+ +
+ +
+ @foreach (var c in Model.Facilities) + { +
+
+ @c.Facility.Name + @if (c.Facility.IsVerified) + { + ✓ تأیید شده + } + else + { + در انتظار تأیید + } +
+

+ @TypeLabel(c.Facility.Type) — 📍 @c.Facility.City?.Name@(c.Facility.District is not null ? "، " + c.Facility.District.Name : "") +

+
شیفت‌های باز@JalaliDate.ToPersianDigits(c.OpenShifts.ToString())
+
موقعیت‌های استخدامی@JalaliDate.ToPersianDigits(c.OpenJobs.ToString())
+
اعلام تمایل‌ها@JalaliDate.ToPersianDigits(c.Applicants.ToString())
+ مدیریت آگهی‌ها و متقاضیان +
+ } +
+ } +
diff --git a/src/JobsMedical.Web/Pages/Employer/Index.cshtml.cs b/src/JobsMedical.Web/Pages/Employer/Index.cshtml.cs new file mode 100644 index 0000000..d1cc5a1 --- /dev/null +++ b/src/JobsMedical.Web/Pages/Employer/Index.cshtml.cs @@ -0,0 +1,46 @@ +using System.Security.Claims; +using JobsMedical.Web.Data; +using JobsMedical.Web.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; + +namespace JobsMedical.Web.Pages.Employer; + +[Authorize] +public class IndexModel : PageModel +{ + private readonly AppDbContext _db; + public IndexModel(AppDbContext db) => _db = db; + + public record FacilityCard(Facility Facility, int OpenShifts, int OpenJobs, int Applicants); + public List Facilities { get; private set; } = new(); + + public async Task OnGetAsync() + { + var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + var today = DateOnly.FromDateTime(DateTime.UtcNow); + + var facilities = await _db.Facilities + .Include(f => f.City).Include(f => f.District) + .Where(f => f.OwnerUserId == userId) + .OrderBy(f => f.Name).ToListAsync(); + + foreach (var f in facilities) + { + var openShifts = await _db.Shifts.CountAsync(s => + s.FacilityId == f.Id && s.Status == ShiftStatus.Open && s.Date >= today); + var openJobs = await _db.JobOpenings.CountAsync(j => + j.FacilityId == f.Id && j.Status == ShiftStatus.Open); + + var shiftIds = await _db.Shifts.Where(s => s.FacilityId == f.Id).Select(s => s.Id).ToListAsync(); + var jobIds = await _db.JobOpenings.Where(j => j.FacilityId == f.Id).Select(j => j.Id).ToListAsync(); + var applicants = await _db.InterestEvents.CountAsync(e => + e.EventType == InterestEventType.Apply && + ((e.ShiftId != null && shiftIds.Contains(e.ShiftId.Value)) || + (e.JobOpeningId != null && jobIds.Contains(e.JobOpeningId.Value)))); + + Facilities.Add(new FacilityCard(f, openShifts, openJobs, applicants)); + } + } +} diff --git a/src/JobsMedical.Web/Pages/Employer/Listings.cshtml b/src/JobsMedical.Web/Pages/Employer/Listings.cshtml new file mode 100644 index 0000000..a4aed1f --- /dev/null +++ b/src/JobsMedical.Web/Pages/Employer/Listings.cshtml @@ -0,0 +1,128 @@ +@page +@model JobsMedical.Web.Pages.Employer.ListingsModel +@{ + ViewData["Title"] = "مدیریت آگهی‌ها"; + string StatusLabel(ShiftStatus s) => s switch + { + ShiftStatus.Open => "باز", + ShiftStatus.Filled => "پر شده", + ShiftStatus.Expired => "منقضی", + _ => "لغو شده", + }; +} + +
+
+

مدیریت آگهی‌ها — @Model.Facility?.Name

+

+ ← بازگشت به پنل + · + شیفت + · + استخدام +

+
+
+ +
+

شیفت‌ها

+ @if (Model.Shifts.Count == 0) + { +
هنوز شیفتی منتشر نکرده‌ای.
+ } + else + { + foreach (var row in Model.Shifts) + { + var s = row.Shift; +
+
+ @s.Role?.Name — @JalaliDate.ToLongDate(s.Date) — @JalaliDate.Time(s.StartTime) + @StatusLabel(s.Status) +
+

@JalaliDate.Toman(s.PayAmount)

+ +
+ متقاضیان (@JalaliDate.ToPersianDigits((row.Applicants.Count + row.Guests).ToString())) + @if (row.Applicants.Count == 0 && row.Guests == 0) + { +

هنوز کسی اعلام تمایل نکرده.

+ } + else + { +
    + @foreach (var a in row.Applicants) + { +
  • @(a.Name ?? "کاربر") — @JalaliDate.ToPersianDigits(a.Phone)
  • + } + @if (row.Guests > 0) + { +
  • @JalaliDate.ToPersianDigits(row.Guests.ToString()) بازدیدکننده‌ی مهمان (بدون ورود)
  • + } +
+ } +
+ +
+ @if (s.Status == ShiftStatus.Open) + { +
+ } + else + { +
+ } +
+
+
+ } + } + +

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

+ @if (Model.Jobs.Count == 0) + { +
هنوز موقعیتی منتشر نکرده‌ای.
+ } + else + { + foreach (var row in Model.Jobs) + { + var j = row.Job; +
+
+ @j.Title — @j.Role?.Name + @StatusLabel(j.Status) +
+
+ متقاضیان (@JalaliDate.ToPersianDigits((row.Applicants.Count + row.Guests).ToString())) + @if (row.Applicants.Count == 0 && row.Guests == 0) + { +

هنوز کسی اعلام تمایل نکرده.

+ } + else + { +
    + @foreach (var a in row.Applicants) + { +
  • @(a.Name ?? "کاربر") — @JalaliDate.ToPersianDigits(a.Phone)
  • + } + @if (row.Guests > 0) + { +
  • @JalaliDate.ToPersianDigits(row.Guests.ToString()) بازدیدکننده‌ی مهمان
  • + } +
+ } +
+
+ @if (j.Status == ShiftStatus.Open) + { +
+ } + else + { +
+ } +
+
+
+ } + } +
diff --git a/src/JobsMedical.Web/Pages/Employer/Listings.cshtml.cs b/src/JobsMedical.Web/Pages/Employer/Listings.cshtml.cs new file mode 100644 index 0000000..10b7d89 --- /dev/null +++ b/src/JobsMedical.Web/Pages/Employer/Listings.cshtml.cs @@ -0,0 +1,120 @@ +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 ListingsModel : PageModel +{ + private readonly AppDbContext _db; + public ListingsModel(AppDbContext db) => _db = db; + + public record Applicant(string? Name, string Phone, DateTime When); + public record ShiftRow(Shift Shift, List Applicants, int Guests); + public record JobRow(JobOpening Job, List Applicants, int Guests); + + public Facility? Facility { get; private set; } + public List Shifts { get; private set; } = new(); + public List Jobs { get; private set; } = new(); + + [BindProperty(SupportsGet = true)] public int FacilityId { get; set; } + + public async Task OnGetAsync() + { + if (!await OwnsAsync(FacilityId)) return Forbid(); + await LoadAsync(); + return Page(); + } + + // --- Lifecycle actions (all ownership-checked) --- + public Task OnPostCloseShiftAsync(int id) => MutateShift(id, s => s.Status = ShiftStatus.Filled); + public Task OnPostReopenShiftAsync(int id) => MutateShift(id, s => s.Status = ShiftStatus.Open); + public Task OnPostDeleteShiftAsync(int id) => MutateShift(id, s => _db.Shifts.Remove(s)); + public Task OnPostCloseJobAsync(int id) => MutateJob(id, j => j.Status = ShiftStatus.Filled); + public Task OnPostReopenJobAsync(int id) => MutateJob(id, j => j.Status = ShiftStatus.Open); + public Task OnPostDeleteJobAsync(int id) => MutateJob(id, j => _db.JobOpenings.Remove(j)); + + private async Task MutateShift(int id, Action apply) + { + var s = await _db.Shifts.FirstOrDefaultAsync(x => x.Id == id); + if (s is null || !await OwnsAsync(s.FacilityId)) return Forbid(); + apply(s); + await _db.SaveChangesAsync(); + return RedirectToPage(new { FacilityId = s.FacilityId }); + } + + private async Task MutateJob(int id, Action apply) + { + var j = await _db.JobOpenings.FirstOrDefaultAsync(x => x.Id == id); + if (j is null || !await OwnsAsync(j.FacilityId)) return Forbid(); + apply(j); + await _db.SaveChangesAsync(); + return RedirectToPage(new { FacilityId = j.FacilityId }); + } + + private async Task OwnsAsync(int facilityId) + { + var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + return await _db.Facilities.AnyAsync(f => f.Id == facilityId && f.OwnerUserId == userId); + } + + private async Task LoadAsync() + { + Facility = await _db.Facilities.Include(f => f.City).FirstOrDefaultAsync(f => f.Id == FacilityId); + + var shifts = await _db.Shifts.Include(s => s.Role) + .Where(s => s.FacilityId == FacilityId) + .OrderByDescending(s => s.Date).ToListAsync(); + var jobs = await _db.JobOpenings.Include(j => j.Role) + .Where(j => j.FacilityId == FacilityId) + .OrderByDescending(j => j.CreatedAt).ToListAsync(); + + // Pull all "Apply" events for these listings, then resolve applicant identities. + var shiftIds = shifts.Select(s => s.Id).ToList(); + var jobIds = jobs.Select(j => j.Id).ToList(); + var events = await _db.InterestEvents + .Where(e => e.EventType == InterestEventType.Apply && + ((e.ShiftId != null && shiftIds.Contains(e.ShiftId.Value)) || + (e.JobOpeningId != null && jobIds.Contains(e.JobOpeningId.Value)))) + .ToListAsync(); + + var visitorIds = events.Select(e => e.VisitorId).Distinct().ToList(); + var visitorUser = await _db.Visitors.Where(v => visitorIds.Contains(v.Id)) + .ToDictionaryAsync(v => v.Id, v => v.UserId); + var userIds = visitorUser.Values.Where(u => u != null).Select(u => u!.Value).Distinct().ToList(); + var users = await _db.Users.Where(u => userIds.Contains(u.Id)).ToDictionaryAsync(u => u.Id); + + (List applicants, int guests) Resolve(IEnumerable evs) + { + var applicants = new List(); + var guests = 0; + var seen = new HashSet(); + foreach (var e in evs.OrderByDescending(e => e.CreatedAt)) + { + var uid = visitorUser.GetValueOrDefault(e.VisitorId); + if (uid is int id && users.TryGetValue(id, out var u)) + { + if (seen.Add(id)) applicants.Add(new Applicant(u.FullName, u.Phone, e.CreatedAt)); + } + else guests++; + } + return (applicants, guests); + } + + Shifts = shifts.Select(s => + { + var (a, g) = Resolve(events.Where(e => e.ShiftId == s.Id)); + return new ShiftRow(s, a, g); + }).ToList(); + Jobs = jobs.Select(j => + { + var (a, g) = Resolve(events.Where(e => e.JobOpeningId == j.Id)); + return new JobRow(j, a, g); + }).ToList(); + } +} diff --git a/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml b/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml new file mode 100644 index 0000000..c3bdb8e --- /dev/null +++ b/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml @@ -0,0 +1,75 @@ +@page +@model JobsMedical.Web.Pages.Employer.PostJobModel +@{ + ViewData["Title"] = "انتشار موقعیت استخدامی"; +} + +

انتشار موقعیت استخدامی

+ +
+ @if (Model.Error is not null) + { +
@Model.Error
+ } + @if (Model.MyFacilities.Count == 0) + { +
+ ابتدا یک مرکز ثبت کن. + ثبت مرکز +
+ } + else + { +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ +
+
+ + +
+
+ + +
+ +
+ } +
diff --git a/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml.cs b/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml.cs new file mode 100644 index 0000000..feeb212 --- /dev/null +++ b/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml.cs @@ -0,0 +1,67 @@ +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 PostJobModel : PageModel +{ + private readonly AppDbContext _db; + public PostJobModel(AppDbContext db) => _db = db; + + public List MyFacilities { get; private set; } = new(); + public List Roles { get; private set; } = new(); + public string? Error { get; private set; } + + [BindProperty] public int FacilityId { get; set; } + [BindProperty] public int RoleId { get; set; } + [BindProperty] public string Title { get; set; } = ""; + [BindProperty] public EmploymentType EmploymentType { get; set; } + [BindProperty] public long? SalaryMin { get; set; } + [BindProperty] public long? SalaryMax { get; set; } + [BindProperty] public bool Negotiable { get; set; } + [BindProperty] public string? Description { get; set; } + [BindProperty] public string? Requirements { get; set; } + + public async Task OnGetAsync() => await LoadListsAsync(); + + public async Task OnPostAsync() + { + await LoadListsAsync(); + if (!MyFacilities.Any(f => f.Id == FacilityId)) + { + Error = "این مرکز متعلق به شما نیست."; + return Page(); + } + if (string.IsNullOrWhiteSpace(Title)) { Error = "عنوان موقعیت الزامی است."; return Page(); } + + _db.JobOpenings.Add(new JobOpening + { + FacilityId = FacilityId, + RoleId = RoleId, + Title = Title.Trim(), + EmploymentType = EmploymentType, + SalaryMin = Negotiable ? null : SalaryMin, + SalaryMax = Negotiable ? null : SalaryMax, + Description = Description, + Requirements = Requirements, + Status = ShiftStatus.Open, + Source = ShiftSource.Direct, + }); + await _db.SaveChangesAsync(); + return RedirectToPage("/Employer/Index"); + } + + private async Task LoadListsAsync() + { + var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + MyFacilities = await _db.Facilities.Include(f => f.City) + .Where(f => f.OwnerUserId == userId).OrderBy(f => f.Name).ToListAsync(); + Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync(); + } +} diff --git a/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml b/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml new file mode 100644 index 0000000..1312cd6 --- /dev/null +++ b/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml @@ -0,0 +1,82 @@ +@page +@model JobsMedical.Web.Pages.Employer.PostShiftModel +@{ + ViewData["Title"] = "انتشار شیفت"; +} + +

انتشار شیفت جدید

+ +
+ @if (Model.Error is not null) + { +
@Model.Error
+ } + @if (Model.MyFacilities.Count == 0) + { +
+ ابتدا یک مرکز ثبت کن. + ثبت مرکز +
+ } + else + { +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+ + +
+
+ + +
+
+

می‌توانی فقط مبلغ، فقط درصد، یا هر دو را وارد کنی؛ اگر هر دو پر شود به کاربر «به انتخاب شما» نمایش داده می‌شود.

+
+ +
+
+ + +
+ +
+ } +
diff --git a/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml.cs b/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml.cs new file mode 100644 index 0000000..ac5901e --- /dev/null +++ b/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml.cs @@ -0,0 +1,77 @@ +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 PostShiftModel : PageModel +{ + private readonly AppDbContext _db; + public PostShiftModel(AppDbContext db) => _db = db; + + public List MyFacilities { get; private set; } = new(); + public List Roles { get; private set; } = new(); + public string? Error { get; private set; } + + [BindProperty] public int FacilityId { get; set; } + [BindProperty] public int RoleId { get; set; } + [BindProperty] public DateOnly Date { get; set; } + [BindProperty] public ShiftType ShiftType { get; set; } + [BindProperty] public TimeOnly StartTime { get; set; } + [BindProperty] public TimeOnly EndTime { get; set; } + [BindProperty] public long? PayAmount { get; set; } + [BindProperty] public int? SharePercent { get; set; } // سهم درآمد (٪) + [BindProperty] public bool Negotiable { get; set; } + [BindProperty] public string? Description { get; set; } + + public async Task OnGetAsync() + { + await LoadListsAsync(); + Date = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1); + StartTime = new TimeOnly(8, 0); + EndTime = new TimeOnly(14, 0); + } + + public async Task OnPostAsync() + { + await LoadListsAsync(); + if (!MyFacilities.Any(f => f.Id == FacilityId)) + { + Error = "این مرکز متعلق به شما نیست."; + return Page(); + } + var role = await _db.Roles.FindAsync(RoleId); + _db.Shifts.Add(new Shift + { + FacilityId = FacilityId, + RoleId = RoleId, + Date = Date, + StartTime = StartTime, + EndTime = EndTime, + ShiftType = ShiftType, + SpecialtyRequired = role?.Name ?? "", + Description = Description, + PayType = Negotiable ? PayType.Negotiable + : (PayAmount is null && SharePercent is not null ? PayType.Percentage : PayType.PerShift), + PayAmount = Negotiable ? null : PayAmount, + SharePercent = Negotiable ? null : SharePercent, + Status = ShiftStatus.Open, + Source = ShiftSource.Direct, // posted directly by the facility + }); + await _db.SaveChangesAsync(); + return RedirectToPage("/Employer/Index"); + } + + private async Task LoadListsAsync() + { + var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + MyFacilities = await _db.Facilities.Include(f => f.City) + .Where(f => f.OwnerUserId == userId).OrderBy(f => f.Name).ToListAsync(); + Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync(); + } +} diff --git a/src/JobsMedical.Web/Pages/Employer/RegisterFacility.cshtml b/src/JobsMedical.Web/Pages/Employer/RegisterFacility.cshtml new file mode 100644 index 0000000..f3ec03e --- /dev/null +++ b/src/JobsMedical.Web/Pages/Employer/RegisterFacility.cshtml @@ -0,0 +1,67 @@ +@page +@model JobsMedical.Web.Pages.Employer.RegisterFacilityModel +@{ + ViewData["Title"] = "ثبت مرکز درمانی"; +} + +
+
+

ثبت مرکز درمانی

+

مرکز خود را ثبت کن تا بتوانی شیفت و موقعیت استخدامی منتشر کنی.

+
+
+ +
+ @if (Model.Error is not null) + { +
@Model.Error
+ } +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+
+

مختصات برای نمایش در فیلتر «نزدیک من» استفاده می‌شود.

+ +
+
diff --git a/src/JobsMedical.Web/Pages/Employer/RegisterFacility.cshtml.cs b/src/JobsMedical.Web/Pages/Employer/RegisterFacility.cshtml.cs new file mode 100644 index 0000000..18bbf25 --- /dev/null +++ b/src/JobsMedical.Web/Pages/Employer/RegisterFacility.cshtml.cs @@ -0,0 +1,84 @@ +using System.Security.Claims; +using JobsMedical.Web.Data; +using JobsMedical.Web.Models; +using JobsMedical.Web.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; + +namespace JobsMedical.Web.Pages.Employer; + +[Authorize] +public class RegisterFacilityModel : PageModel +{ + private readonly AppDbContext _db; + public RegisterFacilityModel(AppDbContext db) => _db = db; + + public List Cities { get; private set; } = new(); + public List Districts { get; private set; } = new(); + + [BindProperty] public string Name { get; set; } = ""; + [BindProperty] public FacilityType Type { get; set; } + [BindProperty] public int CityId { get; set; } + [BindProperty] public int? DistrictId { get; set; } + [BindProperty] public string? Address { get; set; } + [BindProperty] public string? Phone { get; set; } + [BindProperty] public string? BaleId { get; set; } + [BindProperty] public double? Lat { get; set; } + [BindProperty] public double? Lng { get; set; } + public string? Error { get; private set; } + + public async Task OnGetAsync() => await LoadListsAsync(); + + public async Task OnPostAsync() + { + await LoadListsAsync(); + if (string.IsNullOrWhiteSpace(Name) || CityId == 0) + { + Error = "نام مرکز و شهر الزامی است."; + return Page(); + } + + var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + var facility = new Facility + { + Name = Name.Trim(), + Type = Type, + CityId = CityId, + DistrictId = DistrictId, + Address = Address?.Trim(), + Phone = Phone?.Trim(), + BaleId = BaleId?.Trim(), + Lat = Lat, + Lng = Lng, + OwnerUserId = userId, + IsVerified = false, // platform verifies later + }; + _db.Facilities.Add(facility); + + // Promote the user to FacilityAdmin (keep Admin if already admin) and refresh the cookie. + var user = await _db.Users.FindAsync(userId); + if (user is not null && user.Role == UserRole.Doctor) + { + user.Role = UserRole.FacilityAdmin; + await _db.SaveChangesAsync(); + await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, + AuthHelper.BuildPrincipal(user)); + } + else + { + await _db.SaveChangesAsync(); + } + + return RedirectToPage("/Employer/Index"); + } + + private async Task LoadListsAsync() + { + Cities = await _db.Cities.OrderByDescending(c => c.IsActive).ThenBy(c => c.Name).ToListAsync(); + Districts = await _db.Districts.Where(d => d.IsActive).OrderBy(d => d.Name).ToListAsync(); + } +} diff --git a/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml b/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml index 6eed3c9..eae48dc 100644 --- a/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml @@ -35,6 +35,10 @@ { پنل مدیریت } + @if (User.IsInRole("FacilityAdmin")) + { + پنل کارفرما + } پروفایل
diff --git a/src/JobsMedical.Web/Pages/Shared/_RecommendationCard.cshtml b/src/JobsMedical.Web/Pages/Shared/_RecommendationCard.cshtml index 190c7de..763905c 100644 --- a/src/JobsMedical.Web/Pages/Shared/_RecommendationCard.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_RecommendationCard.cshtml @@ -32,7 +32,7 @@
- @JalaliDate.Toman(s.PayAmount) + @JalaliDate.PayLabel(s.PayType, s.PayAmount, s.SharePercent) جزئیات
diff --git a/src/JobsMedical.Web/Pages/Shared/_ShiftCard.cshtml b/src/JobsMedical.Web/Pages/Shared/_ShiftCard.cshtml index 9deaa21..4d07d22 100644 --- a/src/JobsMedical.Web/Pages/Shared/_ShiftCard.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_ShiftCard.cshtml @@ -31,7 +31,7 @@
📅 @JalaliDate.WeekDayName(Model.Date)، @JalaliDate.ToLongDate(Model.Date)
🕐 @JalaliDate.Time(Model.StartTime) تا @JalaliDate.Time(Model.EndTime)
- @JalaliDate.Toman(Model.PayAmount) + @JalaliDate.PayLabel(Model.PayType, Model.PayAmount, Model.SharePercent) جزئیات
diff --git a/src/JobsMedical.Web/Pages/Shifts/Details.cshtml b/src/JobsMedical.Web/Pages/Shifts/Details.cshtml index 9dced55..64c7a97 100644 --- a/src/JobsMedical.Web/Pages/Shifts/Details.cshtml +++ b/src/JobsMedical.Web/Pages/Shifts/Details.cshtml @@ -49,7 +49,7 @@
ساعت@JalaliDate.Time(s.StartTime) تا @JalaliDate.Time(s.EndTime)
مدت@JalaliDate.ToPersianDigits(s.DurationHours.ToString("0.#")) ساعت
نقش مورد نیاز@(s.Role?.Name ?? s.SpecialtyRequired)
-
حقوق@JalaliDate.Toman(s.PayAmount)
+
پرداخت@JalaliDate.PayLabel(s.PayType, s.PayAmount, s.SharePercent)
@if (!string.IsNullOrEmpty(s.Description)) @@ -74,10 +74,13 @@
diff --git a/src/JobsMedical.Web/Pages/Shifts/Index.cshtml.cs b/src/JobsMedical.Web/Pages/Shifts/Index.cshtml.cs index 7dcca3a..4f29f02 100644 --- a/src/JobsMedical.Web/Pages/Shifts/Index.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Shifts/Index.cshtml.cs @@ -18,6 +18,7 @@ public class IndexModel : PageModel [BindProperty(SupportsGet = true)] public int? FacilityId { get; set; } [BindProperty(SupportsGet = true)] public ShiftType? ShiftType { get; set; } [BindProperty(SupportsGet = true)] public bool PaidOnly { get; set; } + [BindProperty(SupportsGet = true)] public bool ShareOnly { get; set; } // فقط شیفت‌های سهم درآمد // "Near me": the browser sends the visitor's coordinates and we sort by distance. [BindProperty(SupportsGet = true)] public double? Lat { get; set; } @@ -56,6 +57,7 @@ public class IndexModel : PageModel if (FacilityId is not null) q = q.Where(s => s.FacilityId == FacilityId); if (ShiftType is not null) q = q.Where(s => s.ShiftType == ShiftType); if (PaidOnly) q = q.Where(s => s.PayAmount != null); + if (ShareOnly) q = q.Where(s => s.SharePercent != null); var results = await q.ToListAsync(); diff --git a/src/JobsMedical.Web/Services/AuthHelper.cs b/src/JobsMedical.Web/Services/AuthHelper.cs new file mode 100644 index 0000000..2ad2de9 --- /dev/null +++ b/src/JobsMedical.Web/Services/AuthHelper.cs @@ -0,0 +1,23 @@ +using System.Security.Claims; +using JobsMedical.Web.Models; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace JobsMedical.Web.Services; + +/// Builds the cookie principal for a user. Shared by login and by role changes +/// (e.g. when a user registers a facility and becomes a FacilityAdmin mid-session). +public static class AuthHelper +{ + public static ClaimsPrincipal BuildPrincipal(User user) + { + var claims = new List + { + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(ClaimTypes.MobilePhone, user.Phone), + new(ClaimTypes.Name, user.FullName ?? user.Phone), + new(ClaimTypes.Role, user.Role.ToString()), + }; + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + return new ClaimsPrincipal(identity); + } +} diff --git a/src/JobsMedical.Web/Services/JalaliDate.cs b/src/JobsMedical.Web/Services/JalaliDate.cs index f9d60e4..de11256 100644 --- a/src/JobsMedical.Web/Services/JalaliDate.cs +++ b/src/JobsMedical.Web/Services/JalaliDate.cs @@ -1,4 +1,5 @@ using System.Globalization; +using JobsMedical.Web.Models; namespace JobsMedical.Web.Services; @@ -88,4 +89,22 @@ public static class JalaliDate /// Format a Toman amount, e.g. "۱٬۵۰۰٬۰۰۰ تومان" or "توافقی" if null. public static string Toman(long? amount) => amount is null ? "توافقی" : ToPersianDigits(amount.Value.ToString("#,0")) + " تومان"; + + /// + /// Human compensation label covering all models: fixed/hourly amount, profit-share %, or + /// BOTH (shown as "… یا … (به انتخاب شما)"), falling back to "توافقی". This is how Iranian + /// shifts are actually advertised — a fixed كارانه, a درصد سهم درآمد, or a choice between them. + /// + public static string PayLabel(PayType payType, long? amount, int? sharePercent) + { + var parts = new List(); + if (amount is not null) + parts.Add(ToPersianDigits(amount.Value.ToString("#,0")) + " تومان" + (payType == PayType.PerHour ? " (ساعتی)" : "")); + if (sharePercent is not null) + parts.Add(ToPersianDigits(sharePercent.Value.ToString()) + "٪ سهم درآمد"); + + if (parts.Count == 0) return "توافقی"; + if (parts.Count > 1) return string.Join(" یا ", parts) + " (به انتخاب شما)"; + return parts[0]; + } } diff --git a/src/JobsMedical.Web/Services/ListingParser.cs b/src/JobsMedical.Web/Services/ListingParser.cs index 97dd8d5..49ad0b5 100644 --- a/src/JobsMedical.Web/Services/ListingParser.cs +++ b/src/JobsMedical.Web/Services/ListingParser.cs @@ -11,6 +11,7 @@ public class ParsedListing public ShiftType? ShiftType { get; set; } public EmploymentType? EmploymentType { get; set; } public long? PayAmount { get; set; } // shift pay or single salary figure + public int? SharePercent { get; set; } // profit-share % (درصدی / سهم درآمد) public bool PayNegotiable { get; set; } public string? CityName { get; set; } public string? DistrictName { get; set; } @@ -69,13 +70,22 @@ public class HeuristicListingParser : IListingParser p.DistrictName = knownDistricts.OrderByDescending(d => d.Length) .FirstOrDefault(d => text.Contains(Normalize(d))); - // --- Pay --- + // --- Profit share (درصدی / سهم) --- + var latinForShare = ToLatinDigits(text); + var share = Regex.Match(latinForShare, @"(\d{1,3})\s*(?:٪|%|درصد)"); + if (!share.Success) share = Regex.Match(latinForShare, @"(?:٪|%)\s*(\d{1,3})"); + if (share.Success && int.TryParse(share.Groups[1].Value, out var pct) && pct is > 0 and <= 100) + { p.SharePercent = pct; p.Notes.Add($"سهم درآمد: {pct}٪"); } + else if (ContainsAny(text, "درصدی", "سهم درآمد", "شراکت", "پورسانت")) + { p.Notes.Add("پرداخت درصدی/سهمی (درصد نامشخص)"); } + + // --- Fixed pay --- if (ContainsAny(text, "توافقی", "توافق")) { p.PayNegotiable = true; p.Notes.Add("حقوق: توافقی"); } else { var amount = ExtractAmount(text); if (amount is not null) { p.PayAmount = amount; p.Notes.Add($"حقوق تخمینی: {amount:#,0} تومان"); } - else p.Notes.Add("حقوق: تشخیص داده نشد"); + else if (p.SharePercent is null) p.Notes.Add("حقوق: تشخیص داده نشد"); } // --- Phone ---