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:
soroush.asadi
2026-06-03 06:26:54 +03:30
parent 2fb86a435e
commit 563a40d1f4
30 changed files with 1761 additions and 27 deletions
+17 -1
View File
@@ -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,
+27 -3
View File
@@ -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");
+2 -1
View File
@@ -41,7 +41,8 @@ public enum PayType
{
PerShift = 0, // مقطوع برای هر شیفت
PerHour = 1, // ساعتی
Negotiable = 2 // توافقی
Negotiable = 2, // توافقی
Percentage = 3 // درصدی / سهم درآمد
}
public enum ApplicationStatus
+2 -1
View File
@@ -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];
}
}
+12 -2
View File
@@ -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 ---