Add hiring, AI parser+admin, OTP auth, employer dashboard, profit-share pay
- Hiring (استخدام) listings: JobOpening + /Jobs browse/detail + home section - Heuristic Persian listing-parser + admin queue (/Admin) → publish shift/job - Phone-OTP cookie auth + visitor-history linking + profile; Admin role gate - Employer side: self-serve facility registration, dashboard, post/manage shifts & jobs, applicants list with contact - Compensation models: fixed / hourly / profit-share (درصدی) / negotiable / choice (به انتخاب شما); SharePercent + JalaliDate.PayLabel; parser + filter Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,773 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("DoctorId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<int>("ShiftId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Province")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Cities");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.District", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CityId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<int?>("CityId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsVerified")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LicenseNo")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<int?>("RoleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Specialty")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Address")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("BaleId")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<int>("CityId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("DistrictId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsVerified")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<double?>("Lat")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("Lng")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int?>("OwnerUserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<int>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("EventType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("JobOpeningId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("ShiftId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<int>("EmploymentType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("FacilityId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Requirements")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<int>("RoleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long?>("SalaryMax")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long?>("SalaryMin")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("Source")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("FetchedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("LinkedShiftId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ParsedJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RawText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceChannel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LinkedShiftId");
|
||||
|
||||
b.ToTable("RawListings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.Role", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Roles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.Shift", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(1500)
|
||||
.HasColumnType("character varying(1500)");
|
||||
|
||||
b.Property<TimeOnly>("EndTime")
|
||||
.HasColumnType("time without time zone");
|
||||
|
||||
b.Property<int>("FacilityId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long?>("PayAmount")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("PayType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("RoleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("SharePercent")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ShiftType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Source")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("SpecialtyRequired")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<TimeOnly>("StartTime")
|
||||
.HasColumnType("time without time zone");
|
||||
|
||||
b.Property<int>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FullName")
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("character varying(150)");
|
||||
|
||||
b.Property<bool>("IsPhoneVerified")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Phone")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.UserPreferences", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int?>("CityId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long?>("MinPay")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int?>("PreferredShiftType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("RoleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("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<string>("Id")
|
||||
.HasMaxLength(36)
|
||||
.HasColumnType("character varying(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobsMedical.Web.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSharePercent : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SharePercent",
|
||||
table: "Shifts",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SharePercent",
|
||||
table: "Shifts");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -413,6 +413,9 @@ namespace JobsMedical.Web.Migrations
|
||||
b.Property<int>("RoleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("SharePercent")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ShiftType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@ public enum PayType
|
||||
{
|
||||
PerShift = 0, // مقطوع برای هر شیفت
|
||||
PerHour = 1, // ساعتی
|
||||
Negotiable = 2 // توافقی
|
||||
Negotiable = 2, // توافقی
|
||||
Percentage = 3 // درصدی / سهم درآمد
|
||||
}
|
||||
|
||||
public enum ApplicationStatus
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -84,16 +84,8 @@ public class LoginModel : PageModel
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,16 @@
|
||||
<a class="btn btn-outline" asp-page="/Preferences/Index">تنظیم علاقهمندیها</a>
|
||||
</div>
|
||||
|
||||
@if (User.IsInRole("FacilityAdmin") || User.IsInRole("Admin"))
|
||||
{
|
||||
<p><a asp-page="/Employer/Index">→ ورود به پنل کارفرما</a></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted">مرکز درمانی هستی و میخواهی شیفت یا استخدام منتشر کنی؟
|
||||
<a asp-page="/Employer/RegisterFacility">مرکز خود را ثبت کن</a></p>
|
||||
}
|
||||
|
||||
<h2 style="font-size:20px;">شیفتهای ذخیرهشده</h2>
|
||||
@if (Model.SavedShifts.Count == 0)
|
||||
{
|
||||
|
||||
@@ -81,9 +81,9 @@
|
||||
<div style="flex:1;"><label>شروع</label><input type="time" name="StartTime" value="@Model.StartTime.ToString("HH:mm")" dir="ltr" /></div>
|
||||
<div style="flex:1;"><label>پایان</label><input type="time" name="EndTime" value="@Model.EndTime.ToString("HH:mm")" dir="ltr" /></div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>حقوق هر شیفت (تومان)</label>
|
||||
<input type="number" name="PayAmount" value="@Model.PayAmount" dir="ltr" />
|
||||
<div class="filter-group" style="display:flex; gap:8px;">
|
||||
<div style="flex:1;"><label>مبلغ مقطوع (تومان)</label><input type="number" name="PayAmount" value="@Model.PayAmount" dir="ltr" /></div>
|
||||
<div style="flex:1;"><label>یا سهم درآمد (٪)</label><input type="number" name="SharePercent" value="@Model.SharePercent" min="1" max="100" dir="ltr" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
@page
|
||||
@model JobsMedical.Web.Pages.Employer.IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "پنل کارفرما";
|
||||
string TypeLabel(FacilityType t) => t switch
|
||||
{
|
||||
FacilityType.Hospital => "بیمارستان",
|
||||
FacilityType.Clinic => "کلینیک",
|
||||
_ => "درمانگاه",
|
||||
};
|
||||
}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="container">
|
||||
<h1>پنل مرکز درمانی</h1>
|
||||
<p class="muted">شیفتها و موقعیتهای استخدامی مرکز خود را مدیریت کن.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container section">
|
||||
@if (Model.Facilities.Count == 0)
|
||||
{
|
||||
<div class="card empty-state">
|
||||
<p>هنوز مرکزی ثبت نکردهای.</p>
|
||||
<a class="btn btn-accent btn-lg" asp-page="/Employer/RegisterFacility">ثبت مرکز درمانی</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="rec-banner">
|
||||
<div>
|
||||
<h2 style="margin:0 0 4px;">انتشار فرصت جدید</h2>
|
||||
<span style="opacity:.9; font-size:14px;">شیفت یا موقعیت استخدامی منتشر کن</span>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<a class="btn btn-outline" asp-page="/Employer/PostShift">+ شیفت</a>
|
||||
<a class="btn btn-outline" asp-page="/Employer/PostJob">+ استخدام</a>
|
||||
<a class="btn btn-outline" asp-page="/Employer/RegisterFacility">+ مرکز</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-3">
|
||||
@foreach (var c in Model.Facilities)
|
||||
{
|
||||
<div class="card card-pad">
|
||||
<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>
|
||||
@if (c.Facility.IsVerified)
|
||||
{
|
||||
<span class="badge badge-verified">✓ تأیید شده</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge badge-type">در انتظار تأیید</span>
|
||||
}
|
||||
</div>
|
||||
<p class="muted" style="margin:8px 0;">
|
||||
@TypeLabel(c.Facility.Type) — 📍 @c.Facility.City?.Name@(c.Facility.District is not null ? "، " + c.Facility.District.Name : "")
|
||||
</p>
|
||||
<div class="info-row"><span class="k">شیفتهای باز</span><span class="v">@JalaliDate.ToPersianDigits(c.OpenShifts.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>
|
||||
<a class="btn btn-outline btn-block" style="margin-top:12px;" asp-page="/Employer/Listings" asp-route-facilityId="@c.Facility.Id">مدیریت آگهیها و متقاضیان</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -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<FacilityCard> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 => "منقضی",
|
||||
_ => "لغو شده",
|
||||
};
|
||||
}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="container">
|
||||
<h1>مدیریت آگهیها — @Model.Facility?.Name</h1>
|
||||
<p class="muted">
|
||||
<a asp-page="/Employer/Index">← بازگشت به پنل</a>
|
||||
· <a asp-page="/Employer/PostShift">+ شیفت</a>
|
||||
· <a asp-page="/Employer/PostJob">+ استخدام</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container section">
|
||||
<h2 style="font-size:20px;">شیفتها</h2>
|
||||
@if (Model.Shifts.Count == 0)
|
||||
{
|
||||
<div class="card empty-state">هنوز شیفتی منتشر نکردهای.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var row in Model.Shifts)
|
||||
{
|
||||
var s = row.Shift;
|
||||
<div class="card card-pad" style="margin-bottom:12px;">
|
||||
<div class="row" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<strong>@s.Role?.Name — @JalaliDate.ToLongDate(s.Date) — @JalaliDate.Time(s.StartTime)</strong>
|
||||
<span class="badge @(s.Status == ShiftStatus.Open ? "badge-verified" : "badge-type")">@StatusLabel(s.Status)</span>
|
||||
</div>
|
||||
<p class="muted" style="margin:6px 0;">@JalaliDate.Toman(s.PayAmount)</p>
|
||||
|
||||
<div style="border-top:1px solid var(--line); padding-top:10px; margin-top:6px;">
|
||||
<strong style="font-size:14px;">متقاضیان (@JalaliDate.ToPersianDigits((row.Applicants.Count + row.Guests).ToString()))</strong>
|
||||
@if (row.Applicants.Count == 0 && row.Guests == 0)
|
||||
{
|
||||
<p class="muted" style="font-size:13px; margin:6px 0 0;">هنوز کسی اعلام تمایل نکرده.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul style="margin:6px 0 0; padding-inline-start:18px; font-size:13.5px;">
|
||||
@foreach (var a in row.Applicants)
|
||||
{
|
||||
<li>@(a.Name ?? "کاربر") — <span dir="ltr">@JalaliDate.ToPersianDigits(a.Phone)</span></li>
|
||||
}
|
||||
@if (row.Guests > 0)
|
||||
{
|
||||
<li class="muted">@JalaliDate.ToPersianDigits(row.Guests.ToString()) بازدیدکنندهی مهمان (بدون ورود)</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:8px; margin-top:12px;">
|
||||
@if (s.Status == ShiftStatus.Open)
|
||||
{
|
||||
<form method="post"><button asp-page-handler="CloseShift" asp-route-id="@s.Id" class="btn btn-outline" style="padding:6px 12px;">بستن (پر شد)</button></form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form method="post"><button asp-page-handler="ReopenShift" asp-route-id="@s.Id" class="btn btn-outline" style="padding:6px 12px;">بازگشایی</button></form>
|
||||
}
|
||||
<form method="post" onsubmit="return confirm('این شیفت حذف شود؟');"><button asp-page-handler="DeleteShift" asp-route-id="@s.Id" class="btn btn-outline" style="padding:6px 12px; color:var(--danger); border-color:var(--danger);">حذف</button></form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<h2 style="font-size:20px; margin-top:30px;">موقعیتهای استخدامی</h2>
|
||||
@if (Model.Jobs.Count == 0)
|
||||
{
|
||||
<div class="card empty-state">هنوز موقعیتی منتشر نکردهای.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var row in Model.Jobs)
|
||||
{
|
||||
var j = row.Job;
|
||||
<div class="card card-pad" style="margin-bottom:12px;">
|
||||
<div class="row" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<strong>@j.Title — @j.Role?.Name</strong>
|
||||
<span class="badge @(j.Status == ShiftStatus.Open ? "badge-verified" : "badge-type")">@StatusLabel(j.Status)</span>
|
||||
</div>
|
||||
<div style="border-top:1px solid var(--line); padding-top:10px; margin-top:8px;">
|
||||
<strong style="font-size:14px;">متقاضیان (@JalaliDate.ToPersianDigits((row.Applicants.Count + row.Guests).ToString()))</strong>
|
||||
@if (row.Applicants.Count == 0 && row.Guests == 0)
|
||||
{
|
||||
<p class="muted" style="font-size:13px; margin:6px 0 0;">هنوز کسی اعلام تمایل نکرده.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul style="margin:6px 0 0; padding-inline-start:18px; font-size:13.5px;">
|
||||
@foreach (var a in row.Applicants)
|
||||
{
|
||||
<li>@(a.Name ?? "کاربر") — <span dir="ltr">@JalaliDate.ToPersianDigits(a.Phone)</span></li>
|
||||
}
|
||||
@if (row.Guests > 0)
|
||||
{
|
||||
<li class="muted">@JalaliDate.ToPersianDigits(row.Guests.ToString()) بازدیدکنندهی مهمان</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; margin-top:12px;">
|
||||
@if (j.Status == ShiftStatus.Open)
|
||||
{
|
||||
<form method="post"><button asp-page-handler="CloseJob" asp-route-id="@j.Id" class="btn btn-outline" style="padding:6px 12px;">بستن</button></form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form method="post"><button asp-page-handler="ReopenJob" asp-route-id="@j.Id" class="btn btn-outline" style="padding:6px 12px;">بازگشایی</button></form>
|
||||
}
|
||||
<form method="post" onsubmit="return confirm('این موقعیت حذف شود؟');"><button asp-page-handler="DeleteJob" asp-route-id="@j.Id" class="btn btn-outline" style="padding:6px 12px; color:var(--danger); border-color:var(--danger);">حذف</button></form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -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<Applicant> Applicants, int Guests);
|
||||
public record JobRow(JobOpening Job, List<Applicant> Applicants, int Guests);
|
||||
|
||||
public Facility? Facility { get; private set; }
|
||||
public List<ShiftRow> Shifts { get; private set; } = new();
|
||||
public List<JobRow> Jobs { get; private set; } = new();
|
||||
|
||||
[BindProperty(SupportsGet = true)] public int FacilityId { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
if (!await OwnsAsync(FacilityId)) return Forbid();
|
||||
await LoadAsync();
|
||||
return Page();
|
||||
}
|
||||
|
||||
// --- Lifecycle actions (all ownership-checked) ---
|
||||
public Task<IActionResult> OnPostCloseShiftAsync(int id) => MutateShift(id, s => s.Status = ShiftStatus.Filled);
|
||||
public Task<IActionResult> OnPostReopenShiftAsync(int id) => MutateShift(id, s => s.Status = ShiftStatus.Open);
|
||||
public Task<IActionResult> OnPostDeleteShiftAsync(int id) => MutateShift(id, s => _db.Shifts.Remove(s));
|
||||
public Task<IActionResult> OnPostCloseJobAsync(int id) => MutateJob(id, j => j.Status = ShiftStatus.Filled);
|
||||
public Task<IActionResult> OnPostReopenJobAsync(int id) => MutateJob(id, j => j.Status = ShiftStatus.Open);
|
||||
public Task<IActionResult> OnPostDeleteJobAsync(int id) => MutateJob(id, j => _db.JobOpenings.Remove(j));
|
||||
|
||||
private async Task<IActionResult> MutateShift(int id, Action<Shift> 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<IActionResult> MutateJob(int id, Action<JobOpening> 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<bool> 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<Applicant> applicants, int guests) Resolve(IEnumerable<InterestEvent> evs)
|
||||
{
|
||||
var applicants = new List<Applicant>();
|
||||
var guests = 0;
|
||||
var seen = new HashSet<int>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
@page
|
||||
@model JobsMedical.Web.Pages.Employer.PostJobModel
|
||||
@{
|
||||
ViewData["Title"] = "انتشار موقعیت استخدامی";
|
||||
}
|
||||
|
||||
<div class="page-head"><div class="container"><h1>انتشار موقعیت استخدامی</h1></div></div>
|
||||
|
||||
<div class="container section" style="max-width:560px;">
|
||||
@if (Model.Error is not null)
|
||||
{
|
||||
<div class="alert" style="background:#fdeaea; color:var(--danger);">@Model.Error</div>
|
||||
}
|
||||
@if (Model.MyFacilities.Count == 0)
|
||||
{
|
||||
<div class="card empty-state">
|
||||
ابتدا یک مرکز ثبت کن.
|
||||
<a class="btn btn-accent" asp-page="/Employer/RegisterFacility">ثبت مرکز</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form method="post" class="card card-pad">
|
||||
<div class="filter-group">
|
||||
<label>مرکز درمانی</label>
|
||||
<select name="FacilityId">
|
||||
@foreach (var f in Model.MyFacilities)
|
||||
{
|
||||
<option value="@f.Id">@f.Name — @f.City?.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>عنوان موقعیت</label>
|
||||
<input type="text" name="Title" value="@Model.Title" placeholder="مثلاً استخدام پرستار بخش اورژانس" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>نقش</label>
|
||||
<select name="RoleId">
|
||||
@foreach (var r in Model.Roles)
|
||||
{
|
||||
<option value="@r.Id">@r.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>نوع همکاری</label>
|
||||
<select name="EmploymentType">
|
||||
<option value="0">تماموقت</option>
|
||||
<option value="1">پارهوقت</option>
|
||||
<option value="2">قراردادی</option>
|
||||
<option value="3">طرح</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group" style="display:flex; gap:8px;">
|
||||
<div style="flex:1;"><label>حقوق ماهانه از</label><input type="number" name="SalaryMin" value="@Model.SalaryMin" dir="ltr" /></div>
|
||||
<div style="flex:1;"><label>تا</label><input type="number" name="SalaryMax" value="@Model.SalaryMax" dir="ltr" /></div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:600;">
|
||||
<input type="checkbox" name="Negotiable" value="true" style="width:auto;" checked="@Model.Negotiable" /> توافقی
|
||||
</label>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>شرح موقعیت</label>
|
||||
<textarea name="Description" rows="3">@Model.Description</textarea>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>شرایط احراز</label>
|
||||
<textarea name="Requirements" rows="2">@Model.Requirements</textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-accent btn-block btn-lg">انتشار موقعیت</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
@@ -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<Facility> MyFacilities { get; private set; } = new();
|
||||
public List<Role> 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<IActionResult> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
@page
|
||||
@model JobsMedical.Web.Pages.Employer.PostShiftModel
|
||||
@{
|
||||
ViewData["Title"] = "انتشار شیفت";
|
||||
}
|
||||
|
||||
<div class="page-head"><div class="container"><h1>انتشار شیفت جدید</h1></div></div>
|
||||
|
||||
<div class="container section" style="max-width:560px;">
|
||||
@if (Model.Error is not null)
|
||||
{
|
||||
<div class="alert" style="background:#fdeaea; color:var(--danger);">@Model.Error</div>
|
||||
}
|
||||
@if (Model.MyFacilities.Count == 0)
|
||||
{
|
||||
<div class="card empty-state">
|
||||
ابتدا یک مرکز ثبت کن.
|
||||
<a class="btn btn-accent" asp-page="/Employer/RegisterFacility">ثبت مرکز</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form method="post" class="card card-pad">
|
||||
<div class="filter-group">
|
||||
<label>مرکز درمانی</label>
|
||||
<select name="FacilityId">
|
||||
@foreach (var f in Model.MyFacilities)
|
||||
{
|
||||
<option value="@f.Id">@f.Name — @f.City?.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>نقش مورد نیاز</label>
|
||||
<select name="RoleId">
|
||||
@foreach (var r in Model.Roles)
|
||||
{
|
||||
<option value="@r.Id">@r.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>تاریخ (میلادی)</label>
|
||||
<input type="date" name="Date" value="@Model.Date.ToString("yyyy-MM-dd")" dir="ltr" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>نوع شیفت</label>
|
||||
<select name="ShiftType">
|
||||
<option value="0">صبح</option>
|
||||
<option value="1">عصر</option>
|
||||
<option value="2">شب</option>
|
||||
<option value="3">آنکال</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group" style="display:flex; gap:8px;">
|
||||
<div style="flex:1;"><label>شروع</label><input type="time" name="StartTime" value="@Model.StartTime.ToString("HH:mm")" dir="ltr" /></div>
|
||||
<div style="flex:1;"><label>پایان</label><input type="time" name="EndTime" value="@Model.EndTime.ToString("HH:mm")" dir="ltr" /></div>
|
||||
</div>
|
||||
<div class="filter-group" style="display:flex; gap:8px;">
|
||||
<div style="flex:1;">
|
||||
<label>مبلغ مقطوع هر شیفت (تومان)</label>
|
||||
<input type="number" name="PayAmount" value="@Model.PayAmount" dir="ltr" />
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
<label>یا سهم درآمد (٪)</label>
|
||||
<input type="number" name="SharePercent" value="@Model.SharePercent" min="1" max="100" dir="ltr" placeholder="مثلاً ۵۰" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted" style="font-size:12px;">میتوانی فقط مبلغ، فقط درصد، یا هر دو را وارد کنی؛ اگر هر دو پر شود به کاربر «به انتخاب شما» نمایش داده میشود.</p>
|
||||
<div class="filter-group">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:600;">
|
||||
<input type="checkbox" name="Negotiable" value="true" style="width:auto;" checked="@Model.Negotiable" /> توافقی (بدون مبلغ مشخص)
|
||||
</label>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>توضیحات</label>
|
||||
<textarea name="Description" rows="3">@Model.Description</textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-accent btn-block btn-lg">انتشار شیفت</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
@@ -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<Facility> MyFacilities { get; private set; } = new();
|
||||
public List<Role> 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<IActionResult> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
@page
|
||||
@model JobsMedical.Web.Pages.Employer.RegisterFacilityModel
|
||||
@{
|
||||
ViewData["Title"] = "ثبت مرکز درمانی";
|
||||
}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="container">
|
||||
<h1>ثبت مرکز درمانی</h1>
|
||||
<p class="muted">مرکز خود را ثبت کن تا بتوانی شیفت و موقعیت استخدامی منتشر کنی.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container section" style="max-width:560px;">
|
||||
@if (Model.Error is not null)
|
||||
{
|
||||
<div class="alert" style="background:#fdeaea; color:var(--danger);">@Model.Error</div>
|
||||
}
|
||||
<form method="post" class="card card-pad">
|
||||
<div class="filter-group">
|
||||
<label>نام مرکز *</label>
|
||||
<input type="text" name="Name" value="@Model.Name" placeholder="مثلاً بیمارستان مهر" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>نوع مرکز</label>
|
||||
<select name="Type">
|
||||
<option value="0">بیمارستان</option>
|
||||
<option value="1">کلینیک</option>
|
||||
<option value="2">درمانگاه</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>شهر *</label>
|
||||
<select name="CityId">
|
||||
<option value="0">انتخاب کنید…</option>
|
||||
@foreach (var c in Model.Cities)
|
||||
{
|
||||
<option value="@c.Id" selected="@(Model.CityId == c.Id)">@c.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>محله / منطقه</label>
|
||||
<select name="DistrictId">
|
||||
<option value="">—</option>
|
||||
@foreach (var d in Model.Districts)
|
||||
{
|
||||
<option value="@d.Id" selected="@(Model.DistrictId == d.Id)">@d.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>آدرس</label>
|
||||
<input type="text" name="Address" value="@Model.Address" />
|
||||
</div>
|
||||
<div class="filter-group" style="display:flex; gap:8px;">
|
||||
<div style="flex:1;"><label>تلفن</label><input type="tel" name="Phone" value="@Model.Phone" dir="ltr" /></div>
|
||||
<div style="flex:1;"><label>شناسه بله</label><input type="text" name="BaleId" value="@Model.BaleId" dir="ltr" /></div>
|
||||
</div>
|
||||
<div class="filter-group" style="display:flex; gap:8px;">
|
||||
<div style="flex:1;"><label>عرض جغرافیایی (اختیاری)</label><input type="number" step="any" name="Lat" value="@Model.Lat" dir="ltr" /></div>
|
||||
<div style="flex:1;"><label>طول جغرافیایی (اختیاری)</label><input type="number" step="any" name="Lng" value="@Model.Lng" dir="ltr" /></div>
|
||||
</div>
|
||||
<p class="muted" style="font-size:12px;">مختصات برای نمایش در فیلتر «نزدیک من» استفاده میشود.</p>
|
||||
<button type="submit" class="btn btn-accent btn-block btn-lg">ثبت مرکز و ورود به پنل</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -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<City> Cities { get; private set; } = new();
|
||||
public List<District> 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<IActionResult> 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();
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,10 @@
|
||||
{
|
||||
<a asp-page="/Admin/Index" style="margin-inline-end:14px; font-weight:600;">پنل مدیریت</a>
|
||||
}
|
||||
@if (User.IsInRole("FacilityAdmin"))
|
||||
{
|
||||
<a asp-page="/Employer/Index" style="margin-inline-end:14px; font-weight:600;">پنل کارفرما</a>
|
||||
}
|
||||
<a asp-page="/Account/Profile" style="margin-inline-end:10px; font-weight:600;">پروفایل</a>
|
||||
<form method="post" asp-page="/Account/Logout" style="display:inline;">
|
||||
<button type="submit" class="btn btn-outline" style="padding:7px 14px;">خروج</button>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<span class="pay">@JalaliDate.Toman(s.PayAmount)</span>
|
||||
<span class="pay">@JalaliDate.PayLabel(s.PayType, s.PayAmount, s.SharePercent)</span>
|
||||
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<div class="row">📅 @JalaliDate.WeekDayName(Model.Date)، @JalaliDate.ToLongDate(Model.Date)</div>
|
||||
<div class="row">🕐 @JalaliDate.Time(Model.StartTime) تا @JalaliDate.Time(Model.EndTime)</div>
|
||||
<div class="foot">
|
||||
<span class="pay">@JalaliDate.Toman(Model.PayAmount)</span>
|
||||
<span class="pay">@JalaliDate.PayLabel(Model.PayType, Model.PayAmount, Model.SharePercent)</span>
|
||||
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<div class="info-row"><span class="k">ساعت</span><span class="v">@JalaliDate.Time(s.StartTime) تا @JalaliDate.Time(s.EndTime)</span></div>
|
||||
<div class="info-row"><span class="k">مدت</span><span class="v">@JalaliDate.ToPersianDigits(s.DurationHours.ToString("0.#")) ساعت</span></div>
|
||||
<div class="info-row"><span class="k">نقش مورد نیاز</span><span class="v">@(s.Role?.Name ?? s.SpecialtyRequired)</span></div>
|
||||
<div class="info-row"><span class="k">حقوق</span><span class="v" style="color:var(--primary-dark)">@JalaliDate.Toman(s.PayAmount)</span></div>
|
||||
<div class="info-row"><span class="k">پرداخت</span><span class="v" style="color:var(--primary-dark)">@JalaliDate.PayLabel(s.PayType, s.PayAmount, s.SharePercent)</span></div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(s.Description))
|
||||
@@ -74,10 +74,13 @@
|
||||
|
||||
<aside>
|
||||
<div class="card card-pad">
|
||||
<div class="pay" style="font-size:20px; margin-bottom:6px; color:var(--primary-dark); font-weight:800;">
|
||||
@JalaliDate.Toman(s.PayAmount)
|
||||
<div class="pay" style="font-size:19px; margin-bottom:6px; color:var(--primary-dark); font-weight:800;">
|
||||
@JalaliDate.PayLabel(s.PayType, s.PayAmount, s.SharePercent)
|
||||
</div>
|
||||
<p class="muted" style="font-size:13px; margin-top:0;">@(s.PayType == PayType.Negotiable ? "توافقی با مرکز درمانی" : "برای هر شیفت")</p>
|
||||
@if (s.PayAmount is not null && s.SharePercent is not null)
|
||||
{
|
||||
<p class="muted" style="font-size:13px; margin-top:0;">میتوانی هنگام هماهنگی، یکی از دو حالت را با مرکز انتخاب کنی.</p>
|
||||
}
|
||||
@if (Model.Saved)
|
||||
{
|
||||
<div class="alert alert-success" style="margin-bottom:12px;">✓ این فرصت ذخیره شد و در پیشنهادهای شما لحاظ میشود.</div>
|
||||
|
||||
@@ -95,6 +95,13 @@
|
||||
فقط شیفتهای با حقوق مشخص
|
||||
</label>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:600;">
|
||||
<input type="checkbox" name="ShareOnly" value="true" style="width:auto;"
|
||||
onchange="this.form.submit()" checked="@Model.ShareOnly" />
|
||||
فقط شیفتهای سهم درآمد (درصدی)
|
||||
</label>
|
||||
</div>
|
||||
<a asp-page="/Shifts/Index" class="btn btn-outline btn-block">حذف فیلترها</a>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Security.Claims;
|
||||
using JobsMedical.Web.Models;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
|
||||
namespace JobsMedical.Web.Services;
|
||||
|
||||
/// <summary>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).</summary>
|
||||
public static class AuthHelper
|
||||
{
|
||||
public static ClaimsPrincipal BuildPrincipal(User user)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Globalization;
|
||||
using JobsMedical.Web.Models;
|
||||
|
||||
namespace JobsMedical.Web.Services;
|
||||
|
||||
@@ -88,4 +89,22 @@ public static class JalaliDate
|
||||
/// <summary>Format a Toman amount, e.g. "۱٬۵۰۰٬۰۰۰ تومان" or "توافقی" if null.</summary>
|
||||
public static string Toman(long? amount)
|
||||
=> amount is null ? "توافقی" : ToPersianDigits(amount.Value.ToString("#,0")) + " تومان";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static string PayLabel(PayType payType, long? amount, int? sharePercent)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user