Compare commits
78 Commits
5e5d7f80ef
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f0e0b82375 | |||
| 923a3fb90e | |||
| aaeb37e1af | |||
| a97c556770 | |||
| 5fcdb8599f | |||
| ccc5a954dd | |||
| e3750b7d43 | |||
| fce13aaeb0 | |||
| 9fc83b231b | |||
| 2d4ea3a762 | |||
| c1c914df9f | |||
| 39c866f4c7 | |||
| fdeefb7625 | |||
| 1f628d971e | |||
| b3e7123d74 | |||
| 219207ad68 | |||
| 410fc86c60 | |||
| b223d3af2d | |||
| 2b7ac96472 | |||
| 0334cac3dc | |||
| 98fc01be8e | |||
| 33450a37ea | |||
| 17da713a35 | |||
| 92802d0da0 | |||
| c778b87e79 | |||
| b1d0d0d4fd | |||
| cdb58eeb86 | |||
| 7bbb4e385e | |||
| fbf8deaa8c | |||
| d39546389e | |||
| 5c04658faf | |||
| 845d0c9013 | |||
| 3e65c88765 | |||
| 1c580e0f7a | |||
| b48e7dbc65 | |||
| bb8c6c3be5 | |||
| 7740d9f8d7 | |||
| f118db55ef | |||
| da55f82c6c | |||
| 88eca92333 | |||
| 8be275596b | |||
| e2011d335e | |||
| a16a805869 | |||
| baa617daa9 | |||
| 7e17e7ccb3 | |||
| f1a00cb955 | |||
| cdca4ad264 | |||
| 5e1b2ee979 | |||
| 3edd21d2b6 | |||
| 142136ebc9 | |||
| 9bc3fdec79 | |||
| a432fce858 | |||
| 8d0a403b36 | |||
| 21befd5b1e | |||
| fb7bfad9ce | |||
| e582597b20 | |||
| 85a5191c45 | |||
| 993c34758f | |||
| 4ab6ce29c9 | |||
| 704b68be16 | |||
| d62929ca0d | |||
| 4c0b29addf | |||
| 0cf5b30dd8 | |||
| 38031cb189 | |||
| b71d8b362b | |||
| 337b510540 | |||
| efbf998caf | |||
| a03dcb1157 | |||
| 380243b669 | |||
| cf5e0011c4 | |||
| 59fb30ac77 | |||
| 753a14286f | |||
| 62e9bf1353 | |||
| c92744fb50 | |||
| 69e2a12a3a | |||
| bcf90f2437 | |||
| 6cf7c6b573 | |||
| 1e96526bd9 |
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"dotnet-ef": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"commands": [
|
||||||
|
"dotnet-ef"
|
||||||
|
],
|
||||||
|
"rollForward": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ public class AppDbContext : DbContext, IDataProtectionKeyContext
|
|||||||
public DbSet<JobAlert> JobAlerts => Set<JobAlert>();
|
public DbSet<JobAlert> JobAlerts => Set<JobAlert>();
|
||||||
public DbSet<IngestionRun> IngestionRuns => Set<IngestionRun>();
|
public DbSet<IngestionRun> IngestionRuns => Set<IngestionRun>();
|
||||||
public DbSet<Review> Reviews => Set<Review>();
|
public DbSet<Review> Reviews => Set<Review>();
|
||||||
|
public DbSet<Like> Likes => Set<Like>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder b)
|
protected override void OnModelCreating(ModelBuilder b)
|
||||||
{
|
{
|
||||||
@@ -156,9 +157,22 @@ public class AppDbContext : DbContext, IDataProtectionKeyContext
|
|||||||
.HasForeignKey(t => t.DistrictId).OnDelete(DeleteBehavior.SetNull);
|
.HasForeignKey(t => t.DistrictId).OnDelete(DeleteBehavior.SetNull);
|
||||||
b.Entity<TalentListing>().HasIndex(t => t.Status);
|
b.Entity<TalentListing>().HasIndex(t => t.Status);
|
||||||
b.Entity<TalentListing>().HasIndex(t => new { t.CityId, t.RoleId });
|
b.Entity<TalentListing>().HasIndex(t => new { t.CityId, t.RoleId });
|
||||||
|
// A ContactMethod belongs to exactly one of talent / shift / job (all optional FKs).
|
||||||
b.Entity<ContactMethod>()
|
b.Entity<ContactMethod>()
|
||||||
.HasOne(c => c.TalentListing).WithMany(t => t.Contacts)
|
.HasOne(c => c.TalentListing).WithMany(t => t.Contacts)
|
||||||
.HasForeignKey(c => c.TalentListingId).OnDelete(DeleteBehavior.Cascade);
|
.HasForeignKey(c => c.TalentListingId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
b.Entity<ContactMethod>()
|
||||||
|
.HasOne(c => c.Shift).WithMany(s => s.Contacts)
|
||||||
|
.HasForeignKey(c => c.ShiftId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
b.Entity<ContactMethod>()
|
||||||
|
.HasOne(c => c.JobOpening).WithMany(j => j.Contacts)
|
||||||
|
.HasForeignKey(c => c.JobOpeningId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
// One like per user per listing; fast count by target.
|
||||||
|
b.Entity<Like>().HasIndex(l => new { l.UserId, l.TargetType, l.TargetId }).IsUnique();
|
||||||
|
b.Entity<Like>().HasIndex(l => new { l.TargetType, l.TargetId });
|
||||||
|
b.Entity<Like>().HasOne(l => l.User).WithMany()
|
||||||
|
.HasForeignKey(l => l.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
b.Entity<WebPushSubscription>().HasIndex(s => s.Endpoint).IsUnique();
|
b.Entity<WebPushSubscription>().HasIndex(s => s.Endpoint).IsUnique();
|
||||||
|
|
||||||
@@ -171,5 +185,14 @@ public class AppDbContext : DbContext, IDataProtectionKeyContext
|
|||||||
// Dedupe ingested listings by content hash.
|
// Dedupe ingested listings by content hash.
|
||||||
b.Entity<RawListing>().HasIndex(r => r.ContentHash);
|
b.Entity<RawListing>().HasIndex(r => r.ContentHash);
|
||||||
b.Entity<RawListing>().HasIndex(r => r.Status);
|
b.Entity<RawListing>().HasIndex(r => r.Status);
|
||||||
|
// A RawListing only LINKS to the post it produced — it must outlive that post (it's the
|
||||||
|
// dedupe cache). So deleting a Shift/Talent NULLs the back-reference rather than orphaning a
|
||||||
|
// dangling FK or blocking the delete. LinkedTalentId previously had no FK at all (orphan risk).
|
||||||
|
b.Entity<RawListing>()
|
||||||
|
.HasOne(r => r.LinkedShift).WithMany()
|
||||||
|
.HasForeignKey(r => r.LinkedShiftId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
b.Entity<RawListing>()
|
||||||
|
.HasOne(r => r.LinkedTalent).WithMany()
|
||||||
|
.HasForeignKey(r => r.LinkedTalentId).OnDelete(DeleteBehavior.SetNull);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1581
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RawListingLinkFks : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_RawListings_Shifts_LinkedShiftId",
|
||||||
|
table: "RawListings");
|
||||||
|
|
||||||
|
// LinkedTalentId never had an FK before, so existing rows may point at deleted talent.
|
||||||
|
// Null those orphans first, otherwise AddForeignKey below fails on a populated DB.
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"UPDATE \"RawListings\" r SET \"LinkedTalentId\" = NULL " +
|
||||||
|
"WHERE r.\"LinkedTalentId\" IS NOT NULL " +
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM \"TalentListings\" t WHERE t.\"Id\" = r.\"LinkedTalentId\");");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_RawListings_LinkedTalentId",
|
||||||
|
table: "RawListings",
|
||||||
|
column: "LinkedTalentId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_RawListings_Shifts_LinkedShiftId",
|
||||||
|
table: "RawListings",
|
||||||
|
column: "LinkedShiftId",
|
||||||
|
principalTable: "Shifts",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_RawListings_TalentListings_LinkedTalentId",
|
||||||
|
table: "RawListings",
|
||||||
|
column: "LinkedTalentId",
|
||||||
|
principalTable: "TalentListings",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_RawListings_Shifts_LinkedShiftId",
|
||||||
|
table: "RawListings");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_RawListings_TalentListings_LinkedTalentId",
|
||||||
|
table: "RawListings");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_RawListings_LinkedTalentId",
|
||||||
|
table: "RawListings");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_RawListings_Shifts_LinkedShiftId",
|
||||||
|
table: "RawListings",
|
||||||
|
column: "LinkedShiftId",
|
||||||
|
principalTable: "Shifts",
|
||||||
|
principalColumn: "Id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RawListingGeo : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<double>(
|
||||||
|
name: "Lat",
|
||||||
|
table: "RawListings",
|
||||||
|
type: "double precision",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<double>(
|
||||||
|
name: "Lng",
|
||||||
|
table: "RawListings",
|
||||||
|
type: "double precision",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Lat",
|
||||||
|
table: "RawListings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Lng",
|
||||||
|
table: "RawListings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1617
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ShiftJobContacts : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "TalentListingId",
|
||||||
|
table: "ContactMethods",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "integer");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "JobOpeningId",
|
||||||
|
table: "ContactMethods",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ShiftId",
|
||||||
|
table: "ContactMethods",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ContactMethods_JobOpeningId",
|
||||||
|
table: "ContactMethods",
|
||||||
|
column: "JobOpeningId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ContactMethods_ShiftId",
|
||||||
|
table: "ContactMethods",
|
||||||
|
column: "ShiftId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_ContactMethods_JobOpenings_JobOpeningId",
|
||||||
|
table: "ContactMethods",
|
||||||
|
column: "JobOpeningId",
|
||||||
|
principalTable: "JobOpenings",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_ContactMethods_Shifts_ShiftId",
|
||||||
|
table: "ContactMethods",
|
||||||
|
column: "ShiftId",
|
||||||
|
principalTable: "Shifts",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_ContactMethods_JobOpenings_JobOpeningId",
|
||||||
|
table: "ContactMethods");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_ContactMethods_Shifts_ShiftId",
|
||||||
|
table: "ContactMethods");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ContactMethods_JobOpeningId",
|
||||||
|
table: "ContactMethods");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ContactMethods_ShiftId",
|
||||||
|
table: "ContactMethods");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "JobOpeningId",
|
||||||
|
table: "ContactMethods");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ShiftId",
|
||||||
|
table: "ContactMethods");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "TalentListingId",
|
||||||
|
table: "ContactMethods",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "integer",
|
||||||
|
oldNullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1635
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ListingApproxCoords : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<double>(
|
||||||
|
name: "Lat",
|
||||||
|
table: "TalentListings",
|
||||||
|
type: "double precision",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<double>(
|
||||||
|
name: "Lng",
|
||||||
|
table: "TalentListings",
|
||||||
|
type: "double precision",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<double>(
|
||||||
|
name: "Lat",
|
||||||
|
table: "Shifts",
|
||||||
|
type: "double precision",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<double>(
|
||||||
|
name: "Lng",
|
||||||
|
table: "Shifts",
|
||||||
|
type: "double precision",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<double>(
|
||||||
|
name: "Lat",
|
||||||
|
table: "JobOpenings",
|
||||||
|
type: "double precision",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<double>(
|
||||||
|
name: "Lng",
|
||||||
|
table: "JobOpenings",
|
||||||
|
type: "double precision",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Lat",
|
||||||
|
table: "TalentListings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Lng",
|
||||||
|
table: "TalentListings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Lat",
|
||||||
|
table: "Shifts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Lng",
|
||||||
|
table: "Shifts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Lat",
|
||||||
|
table: "JobOpenings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Lng",
|
||||||
|
table: "JobOpenings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1644
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class IranEstekhdamSource : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IranEstekhdamEnabled",
|
||||||
|
table: "AppSettings",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "IranEstekhdamMaxAds",
|
||||||
|
table: "AppSettings",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 40);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IranEstekhdamUseProxy",
|
||||||
|
table: "AppSettings",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IranEstekhdamEnabled",
|
||||||
|
table: "AppSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IranEstekhdamMaxAds",
|
||||||
|
table: "AppSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IranEstekhdamUseProxy",
|
||||||
|
table: "AppSettings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class MedboomSource : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "MedboomEnabled",
|
||||||
|
table: "AppSettings",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "MedboomMaxAds",
|
||||||
|
table: "AppSettings",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 40);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "MedboomUseProxy",
|
||||||
|
table: "AppSettings",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MedboomEnabled",
|
||||||
|
table: "AppSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MedboomMaxAds",
|
||||||
|
table: "AppSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MedboomUseProxy",
|
||||||
|
table: "AppSettings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Likes : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Likes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
UserId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
TargetType = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
TargetId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Likes", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Likes_Users_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Likes_TargetType_TargetId",
|
||||||
|
table: "Likes",
|
||||||
|
columns: new[] { "TargetType", "TargetId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Likes_UserId_TargetType_TargetId",
|
||||||
|
table: "Likes",
|
||||||
|
columns: new[] { "UserId", "TargetType", "TargetId" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Likes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,24 @@ namespace JobsMedical.Web.Migrations
|
|||||||
.HasMaxLength(1000)
|
.HasMaxLength(1000)
|
||||||
.HasColumnType("character varying(1000)");
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<bool>("IranEstekhdamEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("IranEstekhdamMaxAds")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("IranEstekhdamUseProxy")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("MedboomEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("MedboomMaxAds")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("MedboomUseProxy")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<bool>("MedjobsEnabled")
|
b.Property<bool>("MedjobsEnabled")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
@@ -293,10 +311,16 @@ namespace JobsMedical.Web.Migrations
|
|||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int?>("JobOpeningId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("ShiftId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int>("SortOrder")
|
b.Property<int>("SortOrder")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int>("TalentListingId")
|
b.Property<int?>("TalentListingId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int>("Type")
|
b.Property<int>("Type")
|
||||||
@@ -309,6 +333,10 @@ namespace JobsMedical.Web.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("JobOpeningId");
|
||||||
|
|
||||||
|
b.HasIndex("ShiftId");
|
||||||
|
|
||||||
b.HasIndex("TalentListingId");
|
b.HasIndex("TalentListingId");
|
||||||
|
|
||||||
b.ToTable("ContactMethods");
|
b.ToTable("ContactMethods");
|
||||||
@@ -654,6 +682,12 @@ namespace JobsMedical.Web.Migrations
|
|||||||
b.Property<int>("GenderRequirement")
|
b.Property<int>("GenderRequirement")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<double?>("Lat")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<double?>("Lng")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
b.Property<string>("Requirements")
|
b.Property<string>("Requirements")
|
||||||
.HasMaxLength(1000)
|
.HasMaxLength(1000)
|
||||||
.HasColumnType("character varying(1000)");
|
.HasColumnType("character varying(1000)");
|
||||||
@@ -693,6 +727,36 @@ namespace JobsMedical.Web.Migrations
|
|||||||
b.ToTable("JobOpenings");
|
b.ToTable("JobOpenings");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("JobsMedical.Web.Models.Like", 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>("TargetId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("TargetType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("UserId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TargetType", "TargetId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "TargetType", "TargetId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Likes");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("JobsMedical.Web.Models.Notification", b =>
|
modelBuilder.Entity("JobsMedical.Web.Models.Notification", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
@@ -748,12 +812,18 @@ namespace JobsMedical.Web.Migrations
|
|||||||
b.Property<DateTime>("FetchedAt")
|
b.Property<DateTime>("FetchedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<double?>("Lat")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
b.Property<int?>("LinkedShiftId")
|
b.Property<int?>("LinkedShiftId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int?>("LinkedTalentId")
|
b.Property<int?>("LinkedTalentId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<double?>("Lng")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
b.Property<string>("ParsedJson")
|
b.Property<string>("ParsedJson")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
@@ -783,6 +853,8 @@ namespace JobsMedical.Web.Migrations
|
|||||||
|
|
||||||
b.HasIndex("LinkedShiftId");
|
b.HasIndex("LinkedShiftId");
|
||||||
|
|
||||||
|
b.HasIndex("LinkedTalentId");
|
||||||
|
|
||||||
b.HasIndex("Status");
|
b.HasIndex("Status");
|
||||||
|
|
||||||
b.ToTable("RawListings");
|
b.ToTable("RawListings");
|
||||||
@@ -924,6 +996,12 @@ namespace JobsMedical.Web.Migrations
|
|||||||
b.Property<int>("GenderRequirement")
|
b.Property<int>("GenderRequirement")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<double?>("Lat")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<double?>("Lng")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
b.Property<long?>("PayAmount")
|
b.Property<long?>("PayAmount")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
@@ -1002,6 +1080,12 @@ namespace JobsMedical.Web.Migrations
|
|||||||
b.Property<bool>("IsLicensed")
|
b.Property<bool>("IsLicensed")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<double?>("Lat")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<double?>("Lng")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
b.Property<long?>("PayAmount")
|
b.Property<long?>("PayAmount")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
@@ -1253,11 +1337,24 @@ namespace JobsMedical.Web.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("JobsMedical.Web.Models.ContactMethod", b =>
|
modelBuilder.Entity("JobsMedical.Web.Models.ContactMethod", b =>
|
||||||
{
|
{
|
||||||
|
b.HasOne("JobsMedical.Web.Models.JobOpening", "JobOpening")
|
||||||
|
.WithMany("Contacts")
|
||||||
|
.HasForeignKey("JobOpeningId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("JobsMedical.Web.Models.Shift", "Shift")
|
||||||
|
.WithMany("Contacts")
|
||||||
|
.HasForeignKey("ShiftId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
b.HasOne("JobsMedical.Web.Models.TalentListing", "TalentListing")
|
b.HasOne("JobsMedical.Web.Models.TalentListing", "TalentListing")
|
||||||
.WithMany("Contacts")
|
.WithMany("Contacts")
|
||||||
.HasForeignKey("TalentListingId")
|
.HasForeignKey("TalentListingId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
.IsRequired();
|
|
||||||
|
b.Navigation("JobOpening");
|
||||||
|
|
||||||
|
b.Navigation("Shift");
|
||||||
|
|
||||||
b.Navigation("TalentListing");
|
b.Navigation("TalentListing");
|
||||||
});
|
});
|
||||||
@@ -1400,6 +1497,17 @@ namespace JobsMedical.Web.Migrations
|
|||||||
b.Navigation("Role");
|
b.Navigation("Role");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("JobsMedical.Web.Models.Like", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("JobsMedical.Web.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("JobsMedical.Web.Models.Notification", b =>
|
modelBuilder.Entity("JobsMedical.Web.Models.Notification", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("JobsMedical.Web.Models.User", "User")
|
b.HasOne("JobsMedical.Web.Models.User", "User")
|
||||||
@@ -1415,9 +1523,17 @@ namespace JobsMedical.Web.Migrations
|
|||||||
{
|
{
|
||||||
b.HasOne("JobsMedical.Web.Models.Shift", "LinkedShift")
|
b.HasOne("JobsMedical.Web.Models.Shift", "LinkedShift")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("LinkedShiftId");
|
.HasForeignKey("LinkedShiftId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("JobsMedical.Web.Models.TalentListing", "LinkedTalent")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LinkedTalentId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.Navigation("LinkedShift");
|
b.Navigation("LinkedShift");
|
||||||
|
|
||||||
|
b.Navigation("LinkedTalent");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("JobsMedical.Web.Models.Review", b =>
|
modelBuilder.Entity("JobsMedical.Web.Models.Review", b =>
|
||||||
@@ -1534,6 +1650,11 @@ namespace JobsMedical.Web.Migrations
|
|||||||
b.Navigation("Shifts");
|
b.Navigation("Shifts");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Contacts");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("JobsMedical.Web.Models.Role", b =>
|
modelBuilder.Entity("JobsMedical.Web.Models.Role", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Shifts");
|
b.Navigation("Shifts");
|
||||||
@@ -1542,6 +1663,8 @@ namespace JobsMedical.Web.Migrations
|
|||||||
modelBuilder.Entity("JobsMedical.Web.Models.Shift", b =>
|
modelBuilder.Entity("JobsMedical.Web.Models.Shift", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Applications");
|
b.Navigation("Applications");
|
||||||
|
|
||||||
|
b.Navigation("Contacts");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("JobsMedical.Web.Models.TalentListing", b =>
|
modelBuilder.Entity("JobsMedical.Web.Models.TalentListing", b =>
|
||||||
|
|||||||
@@ -81,6 +81,18 @@ public class AppSetting
|
|||||||
/// <summary>Max ads to fetch per ingestion run (be polite; dedupe skips already-seen).</summary>
|
/// <summary>Max ads to fetch per ingestion run (be polite; dedupe skips already-seen).</summary>
|
||||||
public int MedjobsMaxAds { get; set; } = 40;
|
public int MedjobsMaxAds { get; set; } = 40;
|
||||||
|
|
||||||
|
/// <summary>Scrape iranestekhdam.ir clinical job ads (crawled via its monthly ad sitemaps;
|
||||||
|
/// employer ads at named facilities, filtered to clinical-role slugs).</summary>
|
||||||
|
public bool IranEstekhdamEnabled { get; set; } = false;
|
||||||
|
public int IranEstekhdamMaxAds { get; set; } = 40;
|
||||||
|
public bool IranEstekhdamUseProxy { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>Scrape medboom.ir clinical ads (WordPress board; doctor/dentist-heavy, hiring +
|
||||||
|
/// availability; crawled via its WP sitemap, Tehran-only for launch).</summary>
|
||||||
|
public bool MedboomEnabled { get; set; } = false;
|
||||||
|
public int MedboomMaxAds { get; set; } = 40;
|
||||||
|
public bool MedboomUseProxy { get; set; } = false;
|
||||||
|
|
||||||
// --- SMS OTP (Kavenegar). When off, the code is shown on screen (dev only). ---
|
// --- SMS OTP (Kavenegar). When off, the code is shown on screen (dev only). ---
|
||||||
public bool SmsEnabled { get; set; } = false;
|
public bool SmsEnabled { get; set; } = false;
|
||||||
[MaxLength(200)] public string? SmsApiKey { get; set; }
|
[MaxLength(200)] public string? SmsApiKey { get; set; }
|
||||||
@@ -138,23 +150,48 @@ public class AppSetting
|
|||||||
: s.Split(new[] { '\n', '\r', ',', '،' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
: s.Split(new[] { '\n', '\r', ',', '،' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
/// <summary>The fixed, code-owned system prompt the AI follows. It is hardcoded (shown read-only
|
||||||
|
/// in admin) so it can't drift or be broken by an edit. The authoritative output-key schema is
|
||||||
|
/// appended automatically by <c>OpenAiCompatibleAuditor</c>, so this text stays behavioral.</summary>
|
||||||
public const string DefaultPrompt = """
|
public const string DefaultPrompt = """
|
||||||
تو دستیار بررسی آگهیهای کاری حوزه درمان برای پلتفرم «همکادر» هستی.
|
تو دستیار دستهبندی آگهیهای کادر درمان تهران برای پلتفرم «همکادر» هستی. هر ورودی یک متن خام از
|
||||||
هر آگهی خام را بخوان و تصمیم بگیر:
|
کانالهای تلگرام/بله/دیوار است. وظیفه: (۱) تشخیص نوع، (۲) استخراج دقیق فیلدها و دستهبندی فرد،
|
||||||
- approve: آگهی واقعی و مرتبط با کادر درمان است و اطلاعات کافی دارد.
|
(۳) تصمیم تأیید/رد/بررسی. فقط یک شیء JSON معتبر برگردان؛ هیچ متن اضافهای ننویس.
|
||||||
- reject: تبلیغ، اسپم، نامرتبط، یا فاقد اطلاعات حداقلی است.
|
|
||||||
- review: مرتبط است اما ناقص/مبهم و نیاز به بررسی انسانی دارد.
|
نوع (kind):
|
||||||
سه نوع آگهی داریم:
|
• shift = مرکز درمانی برای بازهٔ زمانی مشخص نیرو میخواهد.
|
||||||
- shift: مرکز درمانی برای یک شیفت نیرو میخواهد.
|
• job = مرکز درمانی استخدام دائم/قراردادی دارد.
|
||||||
- job: مرکز درمانی برای استخدام دائم نیرو میخواهد.
|
• talent= فردی از کادر درمان خودش را «آماده به کار / آماده همکاری» معرفی میکند
|
||||||
- talent: خودِ کادر درمان اعلام «آماده به کار / آماده همکاری» کرده است.
|
(سمت عرضه؛ مرکز ندارد و شماره تماسِ خودِ فرد مهمترین فیلد است).
|
||||||
نقش، شهر/محله، نوع شیفت/همکاری، مبلغ یا درصد سهم، عنوان، نام مرکز، و شماره تماس را در صورت وجود استخراج کن.
|
|
||||||
برای talent: نام فرد، سال سابقه و پروانهدار بودن را هم استخراج کن.
|
نقش (role) و گروه (category):
|
||||||
فقط با یک شیء JSON پاسخ بده با کلیدهای:
|
اول سعی کن نقش را با یکی از نقشهای رایج تطبیق دهی: پزشک عمومی، پزشک متخصص، پرستار،
|
||||||
decision (approve|reject|review)، confidence (0-100)، reason (فارسی کوتاه)،
|
پرستار سالمندان، ماما، تکنسین اتاق عمل، تکنسین فوریتهای پزشکی، کارشناس آزمایشگاه، دندانپزشک.
|
||||||
kind (shift|job|talent)، role، city، district، shiftType (day|evening|night|oncall)،
|
نقش را به «حرفهٔ پایه» بنویس، نه با پیشوند/پسوندِ توصیفی. گروهِ سنی، بخش، سطح، یا جنسیت را در
|
||||||
employmentType (fulltime|parttime|contract|plan)، payAmount (عدد تومان یا null)،
|
نقش نیاور و بهجایش در tags (و جنسیت را در فیلد gender) بگذار:
|
||||||
sharePercent (0-100 یا null)، title، facilityName، phone،
|
«پرستار کودک» → نقش «پرستار» + تگ «کودک»
|
||||||
personName، yearsExperience (عدد یا null)، isLicensed (true|false).
|
«پرستار آقا» → نقش «پرستار» + جنسیت «آقا»
|
||||||
|
«پرستار اورژانس» → نقش «پرستار» + تگ «اورژانس»
|
||||||
|
«کارآموز تکنسین داروخانه» → نقش «تکنسین داروخانه» + تگ «کارآموز»
|
||||||
|
فقط وقتی نقشِ جدید بساز که یک «حرفهٔ پایهٔ متفاوت» باشد که در فهرست نیست (مثل «تکنسین داروخانه»،
|
||||||
|
«کارشناس رادیولوژی»، «شنواییسنج»). نقش جدید را کوتاه و رسمی بنویس، نه جمله.
|
||||||
|
category را فقط یکی از این پنج گروه بگذار: پزشک | پرستار | ماما | تکنسین | دندانپزشک.
|
||||||
|
اگر نقش در هیچکدام نگنجید، category = «سایر». هرگز گروهِ جدید نساز.
|
||||||
|
|
||||||
|
مهارتها/الزامات (tags): فقط کلیدواژههای بالینی و مرتبط را بهصورت آرایه برگردان — مهارت،
|
||||||
|
بخش، گواهی، گروه سنی، سطح، یا شرط (مثل "ICU"، "NICU"، "دیالیز"، "اتاق عمل"، "کودک"، "سالمند",
|
||||||
|
"MMT"، "CPR"، "پروانهدار"، "خانم"، "آقا"). هرگز مبلغ/پرداخت/توافقی، شماره تماس، شهر/محله، یا
|
||||||
|
جملهٔ ناقص را بهعنوان تگ نگذار. اگر چیزی نبود [].
|
||||||
|
|
||||||
|
شهر (city): فقط نام شهر (مثل «تهران»). محله/منطقه را در district بگذار.
|
||||||
|
|
||||||
|
تصمیم (decision):
|
||||||
|
• approve = آگهیِ واقعیِ مرتبط با کادر درمان تهران با اطلاعات کافی.
|
||||||
|
• reject = اسپم/تبلیغ/نامرتبط/خارج از کادر درمانِ تهران.
|
||||||
|
• review = مرتبط ولی مبهم/ناقص.
|
||||||
|
confidence را ۰ تا ۱۰۰ بده و reason را کوتاه و فارسی بنویس.
|
||||||
|
|
||||||
|
برای talent: personName، yearsExperience، isLicensed (پروانهدار) و phone (ارقام لاتین)
|
||||||
|
را در صورت ذکر پر کن. هر فیلدِ نامشخص = null.
|
||||||
""";
|
""";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,24 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
namespace JobsMedical.Web.Models;
|
namespace JobsMedical.Web.Models;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One contact channel for an applicant («آماده به کار») listing. A listing can carry several —
|
/// One contact channel for a listing — an applicant («آماده به کار»), a <see cref="Shift"/>, or a
|
||||||
/// e.g. three phones + an email + an Instagram page. <see cref="Value"/> holds the raw handle /
|
/// <see cref="JobOpening"/>. A listing can carry several — e.g. three phones + an email + an
|
||||||
/// number / address; <see cref="Type"/> decides how it's linked (tel:, mailto:, t.me/…, etc.).
|
/// Instagram page. <see cref="Value"/> holds the raw handle / number / address; <see cref="Type"/>
|
||||||
|
/// decides how it's linked (tel:, mailto:, t.me/…, etc.). Exactly one owner FK is set.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ContactMethod
|
public class ContactMethod
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
|
|
||||||
public int TalentListingId { get; set; }
|
// Owner — exactly one of these is non-null.
|
||||||
public TalentListing TalentListing { get; set; } = null!;
|
public int? TalentListingId { get; set; }
|
||||||
|
public TalentListing? TalentListing { get; set; }
|
||||||
|
|
||||||
|
public int? ShiftId { get; set; }
|
||||||
|
public Shift? Shift { get; set; }
|
||||||
|
|
||||||
|
public int? JobOpeningId { get; set; }
|
||||||
|
public JobOpening? JobOpening { get; set; }
|
||||||
|
|
||||||
public ContactType Type { get; set; }
|
public ContactType Type { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,9 @@ public enum ContactType
|
|||||||
Other = 8 // سایر
|
Other = 8 // سایر
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>What a <see cref="Like"/> points at.</summary>
|
||||||
|
public enum LikeTargetType { Shift = 0, Job = 1, Talent = 2 }
|
||||||
|
|
||||||
public enum ReportTargetType { Shift = 0, Job = 1, Facility = 2, User = 3 }
|
public enum ReportTargetType { Shift = 0, Job = 1, Facility = 2, User = 3 }
|
||||||
public enum ReportStatus { Open = 0, Resolved = 1, Dismissed = 2 }
|
public enum ReportStatus { Open = 0, Resolved = 1, Dismissed = 2 }
|
||||||
|
|
||||||
|
|||||||
@@ -40,8 +40,16 @@ public class JobOpening
|
|||||||
[MaxLength(500)]
|
[MaxLength(500)]
|
||||||
public string? SourceUrl { get; set; }
|
public string? SourceUrl { get; set; }
|
||||||
|
|
||||||
|
// APPROXIMATE coords from the source ad (Divar) for aggregated openings without a facility address.
|
||||||
|
public double? Lat { get; set; }
|
||||||
|
public double? Lng { get; set; }
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>Contact channels harvested from the source ad (aggregated openings). When empty, the
|
||||||
|
/// detail page falls back to the facility's phone.</summary>
|
||||||
|
public ICollection<ContactMethod> Contacts { get; set; } = new List<ContactMethod>();
|
||||||
|
|
||||||
// Transient: distance (km) when "near me" is active. Not persisted.
|
// Transient: distance (km) when "near me" is active. Not persisted.
|
||||||
[NotMapped] public double? DistanceKm { get; set; }
|
[NotMapped] public double? DistanceKm { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace JobsMedical.Web.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A logged-in user's «پسندیدن» of a listing (shift / job / talent). One row per (user, listing);
|
||||||
|
/// toggling removes it. Polymorphic by <see cref="TargetType"/> + <see cref="TargetId"/> so one table
|
||||||
|
/// covers all three listing kinds. The count of rows for a target is the public "likes" number.
|
||||||
|
/// </summary>
|
||||||
|
public class Like
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int UserId { get; set; }
|
||||||
|
public User User { get; set; } = null!;
|
||||||
|
|
||||||
|
public LikeTargetType TargetType { get; set; }
|
||||||
|
public int TargetId { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -25,10 +25,16 @@ public class RawListing
|
|||||||
public Shift? LinkedShift { get; set; }
|
public Shift? LinkedShift { get; set; }
|
||||||
|
|
||||||
public int? LinkedTalentId { get; set; } // آگهی «آماده به کار» ساختهشده از این متن
|
public int? LinkedTalentId { get; set; } // آگهی «آماده به کار» ساختهشده از این متن
|
||||||
|
public TalentListing? LinkedTalent { get; set; }
|
||||||
|
|
||||||
[MaxLength(500)]
|
[MaxLength(500)]
|
||||||
public string? SourceUrl { get; set; }
|
public string? SourceUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Approximate coordinates harvested from the source (e.g. Divar's fuzzed map center).
|
||||||
|
/// Carried through the review queue so a manual publish can still place the facility on the map.</summary>
|
||||||
|
public double? Lat { get; set; }
|
||||||
|
public double? Lng { get; set; }
|
||||||
|
|
||||||
/// <summary>SHA-256 of the normalized text — used to dedupe across ingestion runs.</summary>
|
/// <summary>SHA-256 of the normalized text — used to dedupe across ingestion runs.</summary>
|
||||||
[MaxLength(64)]
|
[MaxLength(64)]
|
||||||
public string? ContentHash { get; set; }
|
public string? ContentHash { get; set; }
|
||||||
|
|||||||
@@ -40,10 +40,19 @@ public class Shift
|
|||||||
[MaxLength(500)]
|
[MaxLength(500)]
|
||||||
public string? SourceUrl { get; set; } // لینک منبع در صورت جمعآوری از کانال
|
public string? SourceUrl { get; set; } // لینک منبع در صورت جمعآوری از کانال
|
||||||
|
|
||||||
|
// APPROXIMATE coords from the source ad (Divar's privacy-fuzzed center) for aggregated shifts
|
||||||
|
// whose facility has no address. Shown as a «محدودهٔ تقریبی» circle, never a precise pin.
|
||||||
|
public double? Lat { get; set; }
|
||||||
|
public double? Lng { get; set; }
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
public ICollection<Application> Applications { get; set; } = new List<Application>();
|
public ICollection<Application> Applications { get; set; } = new List<Application>();
|
||||||
|
|
||||||
|
/// <summary>Contact channels harvested from the source ad (aggregated shifts). When empty, the
|
||||||
|
/// detail page falls back to the facility's phone.</summary>
|
||||||
|
public ICollection<ContactMethod> Contacts { get; set; } = new List<ContactMethod>();
|
||||||
|
|
||||||
// Transient: distance (km) from the visitor when "near me" is active. Not persisted.
|
// Transient: distance (km) from the visitor when "near me" is active. Not persisted.
|
||||||
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
|
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
|
||||||
public double? DistanceKm { get; set; }
|
public double? DistanceKm { get; set; }
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ public class TalentListing
|
|||||||
[MaxLength(500)]
|
[MaxLength(500)]
|
||||||
public string? SourceUrl { get; set; }
|
public string? SourceUrl { get; set; }
|
||||||
|
|
||||||
|
// APPROXIMATE coords from the source ad (Divar) — an applicant has no facility, so this is the
|
||||||
|
// only location we have. Shown as a «محدودهٔ تقریبی» circle (the area they're available in).
|
||||||
|
public double? Lat { get; set; }
|
||||||
|
public double? Lng { get; set; }
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
// Transient: distance (km) when "near me" is active. Not persisted.
|
// Transient: distance (km) when "near me" is active. Not persisted.
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
<h1>پنل مدیریت — جمعآوری و صف آگهیها</h1>
|
<h1>پنل مدیریت — جمعآوری و صف آگهیها</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
آگهیهای جمعآوریشده از منابع را بررسی، ساختارمند و منتشر کن.
|
آگهیهای جمعآوریشده از منابع را بررسی، ساختارمند و منتشر کن.
|
||||||
(@JalaliDate.ToPersianDigits(Model.Queue.Count.ToString()) در صف،
|
(@JalaliDate.ToPersianDigits(Model.QueueTotal.ToString()) در صف،
|
||||||
@JalaliDate.ToPersianDigits(Model.Flagged.Count.ToString()) پرچمخورده)
|
@JalaliDate.ToPersianDigits(Model.FlaggedTotal.ToString()) پرچمخورده)
|
||||||
· <a asp-page="/Admin/Overview">داشبورد</a>
|
· <a asp-page="/Admin/Overview">داشبورد</a>
|
||||||
· <a asp-page="/Admin/Users">کاربران</a>
|
· <a asp-page="/Admin/Users">کاربران</a>
|
||||||
· <a asp-page="/Admin/Facilities">مراکز</a>
|
· <a asp-page="/Admin/Facilities">مراکز</a>
|
||||||
@@ -40,6 +40,77 @@
|
|||||||
<p class="muted" style="font-size:11px; margin:8px 0 0;">
|
<p class="muted" style="font-size:11px; margin:8px 0 0;">
|
||||||
موتور: واکشی ← حذف تکراری ← تجزیه ← اعتبارسنجی ← صف بررسی.
|
موتور: واکشی ← حذف تکراری ← تجزیه ← اعتبارسنجی ← صف بررسی.
|
||||||
</p>
|
</p>
|
||||||
|
<form method="post" onsubmit="return confirm('⚠ همهی آیتمهای جمعآوریشده (کش) و همهی آگهیهای منتشرشده از جمعآوری حذف میشوند (آگهیهای ثبتشده توسط مراکز دستنخورده میمانند)، سپس همهچیز با هوش مصنوعی دوباره جمعآوری و افزوده میشود. این کار بازگشتناپذیر است. ادامه میدهی؟');">
|
||||||
|
<button type="submit" asp-page-handler="PurgeAndReingest" class="btn btn-outline btn-block" style="margin-top:8px; color:var(--danger); border-color:var(--danger);">
|
||||||
|
🔄 پاکسازی کش و جمعآوری مجدد با هوش مصنوعی
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted" style="font-size:11px; margin:6px 0 0;">
|
||||||
|
کش حذف تکراری و آگهیهای جمعآوریشده پاک و از نو با AI پردازش میشوند. (آگهیهای مراکز حذف نمیشوند.)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" onsubmit="return confirm('آگهیهای «آماده به کار» از روی متنِ خامِ ذخیرهشده (بدون واکشی) دوباره با هوش مصنوعی پردازش میشوند — برای پاکسازی (حذف موارد تکراری، اصلاح نقش/گروه/تگ، افزودن موقعیت تقریبی). شیفت/استخدام دستنخورده میمانند (برای حفظ SEO). هیچ آیتمی از دست نمیرود. در پسزمینه اجرا میشود. ادامه؟');">
|
||||||
|
<button type="submit" asp-page-handler="ReprocessStored" class="btn btn-primary btn-block" style="margin-top:10px;">
|
||||||
|
🧹 پردازش مجددِ «آماده به کار»ها (امن برای SEO)
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted" style="font-size:11px; margin:6px 0 0;">
|
||||||
|
توصیهشده برای پاکسازیِ آمادهبهکارها: متنِ خام نگه داشته میشود و فقط با منطقِ جدید (یکنفر=یکآگهی، نقش پایه، گروه ثابت، تگ تمیز، موقعیت تقریبی) بازساخته میشوند. صفحاتِ «آماده به کار» ایندکس نمیشوند، پس آدرسِ ایندکسشدهای تغییر نمیکند؛ شیفت/استخدام بهمرور با ایمیجستِ تازه پاک میشوند.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" onsubmit="return confirm('برای آگهیهای جمعآوریشدهٔ تهران که موقعیت روی نقشه ندارند، از روی متنِ آگهی محلهٔ تقریبی پیدا و مختصات تنظیم میشود. شناسه و آدرس صفحات تغییر نمیکند (امن برای SEO). ادامه؟');">
|
||||||
|
<button type="submit" asp-page-handler="BackfillCoords" class="btn btn-primary btn-block" style="margin-top:10px;">
|
||||||
|
📍 تکمیل موقعیتِ نقشه برای آگهیهای موجود
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted" style="font-size:11px; margin:6px 0 0;">
|
||||||
|
شیفت/استخدام/آمادهبهکارِ جمعآوریشدهای که مختصات ندارند، از روی محلهٔ ذکرشده در متنِ آگهی روی نقشه قرار میگیرند (محدودهٔ تقریبی). فقط مختصاتِ خالی پر میشود؛ موقعیتِ واقعیِ مراکز دستنخورده میماند.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<button type="submit" asp-page-handler="BackfillPay" class="btn btn-primary btn-block" style="margin-top:10px;">
|
||||||
|
💰 استخراجِ حقوق برای آگهیهای «توافقی»
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted" style="font-size:11px; margin:6px 0 0;">
|
||||||
|
آگهیهایی که حقوقشان «توافقی» است ولی در متن مبلغ دارند (مثل «۴۰ تا ۵۰ تومان» = میلیون)، مبلغشان استخراج و ثبت میشود (درجا، بدون تغییر شناسه/آدرس).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" onsubmit="return confirm('آگهیهای جمعآوریشدهٔ شیفت/استخدام که اکنون خارج از حوزهاند (خدمات منزل/نظافت، تبلیغاتی/آموزشی، اسپم) و استخدامهای تکراری «بایگانی» میشوند: از سایت پنهان میشوند ولی ردیفشان نگه داشته میشود (قابل بازگشت). آگهیهای معتبر و شناسه/آدرسشان دستنخورده میماند. ادامه؟');">
|
||||||
|
<button type="submit" asp-page-handler="PurgeInvalid" class="btn btn-outline btn-block" style="margin-top:10px; color:var(--danger); border-color:var(--danger);">
|
||||||
|
🧽 بایگانیِ درجای آگهیهای خارج از حوزه و تکراری (شیفت/استخدام)
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted" style="font-size:11px; margin:6px 0 0;">
|
||||||
|
فقط آگهیهایی که با صافیِ فعلی «خارج از حوزه» تشخیص داده میشوند (نه صرفاً ناقص) و استخدامهای تکراری بایگانی میشوند (وضعیت «بایگانی»، نه حذف). آگهیهای معتبر دستنخوردهاند، پس آدرسِ ایندکسشدهشان تغییر نمیکند؛ صفحهٔ موارد بایگانیشده ۴۱۰ Gone میدهد تا گوگل تمیز حذفشان کند.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" onsubmit="return confirm('مراکز درمانیِ تکراری ادغام و مراکزِ بینام/نامعتبر (مثل «بیمارستان هستم» یا «از مدجابز») حذف میشوند؛ آگهیهایشان به مرکزِ معتبر یا «نامشخص» منتقل میشود. مراکزِ ثبتشده توسط کارفرما یا تأییدشده دستنخورده میمانند. ادامه؟');">
|
||||||
|
<button type="submit" asp-page-handler="CleanFacilities" class="btn btn-primary btn-block" style="margin-top:10px;">
|
||||||
|
🏥 ادغام مراکز تکراری و حذف مراکز بینام
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<button type="submit" asp-page-handler="RecorrectRoles" class="btn btn-primary btn-block" style="margin-top:10px;">
|
||||||
|
🩺 اصلاح نقشِ آگهیهای «پزشک عمومی» (دندانپزشک/متخصص و …)
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" onsubmit="return confirm('نقشهای تکراری/ترکیبی/غلطاملایی (مثل «پرستار کودک» سهتایی، «پرستار و بهیار»، «بیهیار») در نقشهای اصلی ادغام و حذف میشوند؛ آگهیهایشان به نقشِ معتبر منتقل میشود. ادامه؟');">
|
||||||
|
<button type="submit" asp-page-handler="MergeRoles" class="btn btn-primary btn-block" style="margin-top:10px;">
|
||||||
|
🏷️ ادغام نقشهای تکراری/ترکیبی/غلطاملایی
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted" style="font-size:11px; margin:6px 0 0;">
|
||||||
|
نقشهای هممعنا (تکراری، ترکیبی مثل «پرستار و بهیار»، یا غلطاملایی مثل «بیهیار») در یک نقشِ پایه ادغام میشوند تا فهرستِ نقشها تمیز شود. مدیریتِ دستی در <a asp-page="/Admin/Roles">نقشها</a>.
|
||||||
|
</p>
|
||||||
|
<p class="muted" style="font-size:11px; margin:6px 0 0;">
|
||||||
|
آگهیهایی که هوش مصنوعی به اشتباه «پزشک عمومی» زده ولی متنشان نقش دیگری دارد، از روی متن اصلاح میشوند (درجا، بدون تغییر شناسه/آدرس).
|
||||||
|
</p>
|
||||||
|
<p class="muted" style="font-size:11px; margin:6px 0 0;">
|
||||||
|
مراکز تکراری (با تطبیقِ فارسی) در یک رکورد ادغام و مراکزِ بدونِ نامِ واقعی به «نامشخص» منتقل میشوند. آگهیها حفظ میشوند؛ فقط مراکزِ جمعآوریشده و مدیریتنشده پاک میشوند.
|
||||||
|
</p>
|
||||||
|
|
||||||
<hr style="border:none; border-top:1px solid var(--line); margin:16px 0;" />
|
<hr style="border:none; border-top:1px solid var(--line); margin:16px 0;" />
|
||||||
|
|
||||||
@@ -85,7 +156,7 @@
|
|||||||
@foreach (var run in Model.Runs)
|
@foreach (var run in Model.Runs)
|
||||||
{
|
{
|
||||||
<tr style="border-top:1px solid var(--line);" title="@run.Detail">
|
<tr style="border-top:1px solid var(--line);" title="@run.Detail">
|
||||||
<td style="padding:6px 8px;">@JalaliDate.ToLongDate(DateOnly.FromDateTime(run.RunAt)) @run.RunAt.ToString("HH:mm")</td>
|
<td style="padding:6px 8px;">@JalaliDate.DateTimeLabel(run.RunAt)</td>
|
||||||
<td style="padding:6px 8px;">@JalaliDate.ToPersianDigits(run.Fetched.ToString())</td>
|
<td style="padding:6px 8px;">@JalaliDate.ToPersianDigits(run.Fetched.ToString())</td>
|
||||||
<td style="padding:6px 8px;">@JalaliDate.ToPersianDigits(run.Queued.ToString())</td>
|
<td style="padding:6px 8px;">@JalaliDate.ToPersianDigits(run.Queued.ToString())</td>
|
||||||
<td style="padding:6px 8px; color:var(--primary-dark); font-weight:700;">@JalaliDate.ToPersianDigits(run.Published.ToString())</td>
|
<td style="padding:6px 8px; color:var(--primary-dark); font-weight:700;">@JalaliDate.ToPersianDigits(run.Published.ToString())</td>
|
||||||
@@ -110,9 +181,19 @@
|
|||||||
{
|
{
|
||||||
<partial name="_RawListingRow" model="r" />
|
<partial name="_RawListingRow" model="r" />
|
||||||
}
|
}
|
||||||
|
@if (Model.QueuePages > 1)
|
||||||
|
{
|
||||||
|
<div class="row" style="display:flex; gap:10px; justify-content:center; align-items:center; margin-top:14px;">
|
||||||
|
@if (Model.QueuePage > 1)
|
||||||
|
{ <a class="btn btn-outline" asp-route-q="@(Model.QueuePage - 1)" asp-route-f="@Model.FlaggedPage">→ قبلی</a> }
|
||||||
|
<span class="muted">صفحه @JalaliDate.ToPersianDigits(Model.QueuePage.ToString()) از @JalaliDate.ToPersianDigits(Model.QueuePages.ToString())</span>
|
||||||
|
@if (Model.QueuePage < Model.QueuePages)
|
||||||
|
{ <a class="btn btn-outline" asp-route-q="@(Model.QueuePage + 1)" asp-route-f="@Model.FlaggedPage">بعدی ←</a> }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (Model.Flagged.Count > 0)
|
@if (Model.FlaggedTotal > 0)
|
||||||
{
|
{
|
||||||
<h2 style="font-size:20px; margin-top:28px;">پرچمخورده (ناقص/مشکوک)</h2>
|
<h2 style="font-size:20px; margin-top:28px;">پرچمخورده (ناقص/مشکوک)</h2>
|
||||||
<p class="muted" style="font-size:13px;">اعتبارسنجی اینها را کامل ندانست؛ در صورت صحت میتوانی منتشرشان کنی.</p>
|
<p class="muted" style="font-size:13px;">اعتبارسنجی اینها را کامل ندانست؛ در صورت صحت میتوانی منتشرشان کنی.</p>
|
||||||
@@ -120,6 +201,16 @@
|
|||||||
{
|
{
|
||||||
<partial name="_RawListingRow" model="r" />
|
<partial name="_RawListingRow" model="r" />
|
||||||
}
|
}
|
||||||
|
@if (Model.FlaggedPages > 1)
|
||||||
|
{
|
||||||
|
<div class="row" style="display:flex; gap:10px; justify-content:center; align-items:center; margin-top:14px;">
|
||||||
|
@if (Model.FlaggedPage > 1)
|
||||||
|
{ <a class="btn btn-outline" asp-route-q="@Model.QueuePage" asp-route-f="@(Model.FlaggedPage - 1)">→ قبلی</a> }
|
||||||
|
<span class="muted">صفحه @JalaliDate.ToPersianDigits(Model.FlaggedPage.ToString()) از @JalaliDate.ToPersianDigits(Model.FlaggedPages.ToString())</span>
|
||||||
|
@if (Model.FlaggedPage < Model.FlaggedPages)
|
||||||
|
{ <a class="btn btn-outline" asp-route-q="@Model.QueuePage" asp-route-f="@(Model.FlaggedPage + 1)">بعدی ←</a> }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,15 +13,26 @@ public class IndexModel : PageModel
|
|||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly IngestionService _ingest;
|
private readonly IngestionService _ingest;
|
||||||
|
private readonly IServiceScopeFactory _scopes;
|
||||||
|
private readonly ILogger<IndexModel> _log;
|
||||||
|
|
||||||
public IndexModel(AppDbContext db, IngestionService ingest)
|
public IndexModel(AppDbContext db, IngestionService ingest, IServiceScopeFactory scopes, ILogger<IndexModel> log)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_ingest = ingest;
|
_ingest = ingest;
|
||||||
|
_scopes = scopes;
|
||||||
|
_log = log;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<RawListing> Queue { get; private set; } = new();
|
public List<RawListing> Queue { get; private set; } = new();
|
||||||
public List<RawListing> Flagged { get; private set; } = new();
|
public List<RawListing> Flagged { get; private set; } = new();
|
||||||
|
public const int PageSize = 20;
|
||||||
|
public int QueuePage { get; private set; } = 1;
|
||||||
|
public int QueueTotal { get; private set; }
|
||||||
|
public int FlaggedPage { get; private set; } = 1;
|
||||||
|
public int FlaggedTotal { get; private set; }
|
||||||
|
public int QueuePages => Math.Max(1, (int)Math.Ceiling(QueueTotal / (double)PageSize));
|
||||||
|
public int FlaggedPages => Math.Max(1, (int)Math.Ceiling(FlaggedTotal / (double)PageSize));
|
||||||
public IReadOnlyList<string> SourceNames { get; private set; } = new List<string>();
|
public IReadOnlyList<string> SourceNames { get; private set; } = new List<string>();
|
||||||
public int PublishedShifts { get; private set; }
|
public int PublishedShifts { get; private set; }
|
||||||
public int PublishedJobs { get; private set; }
|
public int PublishedJobs { get; private set; }
|
||||||
@@ -32,7 +43,7 @@ public class IndexModel : PageModel
|
|||||||
|
|
||||||
[TempData] public string? IngestMessage { get; set; }
|
[TempData] public string? IngestMessage { get; set; }
|
||||||
|
|
||||||
public async Task OnGetAsync() => await LoadAsync();
|
public async Task OnGetAsync(int q = 1, int f = 1) => await LoadAsync(q, f);
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAddAsync()
|
public async Task<IActionResult> OnPostAddAsync()
|
||||||
{
|
{
|
||||||
@@ -65,14 +76,136 @@ public class IndexModel : PageModel
|
|||||||
return RedirectToPage();
|
return RedirectToPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadAsync()
|
/// <summary>
|
||||||
|
/// DESTRUCTIVE rebuild, in two distinct deletes:
|
||||||
|
/// 1. The DEDUPE CACHE — ALL RawListings, including any added via «افزودن دستی». These are not
|
||||||
|
/// published content; they're the crawl/staging rows whose ContentHash blocks re-ingesting
|
||||||
|
/// the same ad. Wiping them lets everything be re-fetched and re-judged by the AI.
|
||||||
|
/// 2. AGGREGATED listings only — Shifts/JobOpenings/TalentListings with Source==Aggregated, i.e.
|
||||||
|
/// produced by ingestion. Employer/admin-posted listings (Source==Direct) are left untouched.
|
||||||
|
/// Then re-fetch everything and re-run it through the (now AI-enabled) pipeline.
|
||||||
|
/// RawListings are deleted first so their LinkedShift/LinkedTalent FKs (SetNull) don't dangle;
|
||||||
|
/// DB cascade clears ContactMethods / Applications / InterestEvents when the posts are deleted.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IActionResult> OnPostPurgeAndReingestAsync()
|
||||||
{
|
{
|
||||||
|
int rawCount, shifts, jobs, talent;
|
||||||
|
await using (var tx = await _db.Database.BeginTransactionAsync())
|
||||||
|
{
|
||||||
|
rawCount = await _db.RawListings.ExecuteDeleteAsync(); // clear dedupe cache
|
||||||
|
shifts = await _db.Shifts.Where(s => s.Source == ShiftSource.Aggregated).ExecuteDeleteAsync();
|
||||||
|
jobs = await _db.JobOpenings.Where(j => j.Source == ShiftSource.Aggregated).ExecuteDeleteAsync();
|
||||||
|
talent = await _db.TalentListings.Where(t => t.Source == ShiftSource.Aggregated).ExecuteDeleteAsync();
|
||||||
|
await tx.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var s = await _ingest.RunAsync(); // fresh fetch → AI audit → publish/queue
|
||||||
|
IngestMessage = $"پاکسازی شد (حذف: {rawCount} آیتم کش، {shifts} شیفت، {jobs} استخدام، {talent} آمادهبهکارِ جمعآوریشده). " +
|
||||||
|
$"جمعآوری مجدد: {s.TotalPublished} منتشر، {s.TotalQueued} در صف، {s.TotalFlagged} پرچم، {s.TotalSpam} اسپم، {s.TotalDuplicates} تکراری.";
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clean up EXISTING aggregated content by re-running the current pipeline over the stored raw
|
||||||
|
/// text — no re-fetch, so nothing is lost to sources only exposing recent posts. Long-running
|
||||||
|
/// (one AI call per item), so it runs on a background scope and returns immediately; the result
|
||||||
|
/// shows up as a new row in the «تاریخچهٔ اجرا» log when it finishes.
|
||||||
|
/// </summary>
|
||||||
|
public IActionResult OnPostReprocessStored()
|
||||||
|
{
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var scope = _scopes.CreateScope();
|
||||||
|
var svc = scope.ServiceProvider.GetRequiredService<IngestionService>();
|
||||||
|
var log = scope.ServiceProvider.GetRequiredService<ILogger<IndexModel>>();
|
||||||
|
// talentOnly: «آماده به کار» is NoIndex/Disallow → rebuilding it doesn't churn any indexed
|
||||||
|
// URL. Shift/Job detail pages ARE indexed, so they're left to self-clean via turnover.
|
||||||
|
try { await svc.ReprocessAsync(talentOnly: true); }
|
||||||
|
catch (Exception ex) { log.LogError(ex, "Background reprocess failed"); }
|
||||||
|
});
|
||||||
|
IngestMessage = "پردازش مجدد آیتمهای ذخیرهشده در پسزمینه آغاز شد. نتیجه پس از اتمام در «تاریخچهٔ اجرا» نمایش داده میشود (بسته به تعداد آیتمها و سرعت هوش مصنوعی، چند دقیقه طول میکشد).";
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fill missing map coordinates on existing aggregated Tehran listings from their stored ad text
|
||||||
|
/// (TehranGeo). In place — no AI calls, no re-fetch, and crucially no delete/recreate, so indexed
|
||||||
|
/// shift/job URLs keep their IDs. Fast (pure DB + string matching), so it runs inline.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IActionResult> OnPostBackfillCoordsAsync()
|
||||||
|
{
|
||||||
|
var n = await _ingest.BackfillCoordsAsync();
|
||||||
|
IngestMessage = $"مختصات تقریبی برای {n} آگهی جمعآوریشده از روی متن آگهی تکمیل شد (بدون تغییر شناسه یا آدرس صفحه).";
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fill missing salary on existing aggregated listings from the stored text (now reading
|
||||||
|
/// Iranian «X تومان» = millions shorthand). In place — no AI, no ID/URL change.</summary>
|
||||||
|
public async Task<IActionResult> OnPostBackfillPayAsync()
|
||||||
|
{
|
||||||
|
var n = await _ingest.BackfillPayAsync();
|
||||||
|
IngestMessage = $"حقوق برای {n} آگهیِ «توافقی» که در متن مبلغ داشت (مثل «۴۰ تا ۵۰ تومان») استخراج و ثبت شد. بدون تغییر شناسه/آدرس.";
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-place cleanup of existing aggregated jobs/shifts: ARCHIVE (hide, keep the row) only the
|
||||||
|
/// out-of-scope ones (domestic-helper / promotional / spam) per the current validator, plus
|
||||||
|
/// near-duplicate job reposts. Archived pages drop from lists + sitemap and return 410 Gone.
|
||||||
|
/// Valid listings keep their IDs/URLs. Reversible, no re-fetch, no AI — runs inline.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IActionResult> OnPostPurgeInvalidAsync()
|
||||||
|
{
|
||||||
|
var (archived, deduped) = await _ingest.PurgeInvalidAggregatedAsync();
|
||||||
|
IngestMessage = $"بایگانیِ درجا: {archived} آگهیِ خارج از حوزه (خدمات منزل/تبلیغاتی/اسپم) و {deduped} استخدامِ تکراری از سایت پنهان شد (وضعیت «بایگانی»؛ ردیف نگه داشته شد و قابل بازگشت است؛ صفحهشان ۴۱۰ Gone میدهد). آگهیهای معتبر و شناسه/آدرسشان دستنخورده ماند.";
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clean up the crawl-generated facility table: merge Persian-fuzzy duplicate facilities and fold
|
||||||
|
/// junk-named ones («بیمارستان هستم»، «... از مدجابز»، bare «کلینیک») into the shared placeholder,
|
||||||
|
/// repointing their listings first. Employer-owned / verified facilities are never touched.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IActionResult> OnPostCleanFacilitiesAsync()
|
||||||
|
{
|
||||||
|
var (merged, cleaned) = await _ingest.MergeAndCleanFacilitiesAsync();
|
||||||
|
IngestMessage = $"پاکسازی مراکز: {merged} مرکزِ تکراری ادغام و {cleaned} مرکزِ بینام/نامعتبر حذف شد (آگهیهایشان به مرکزِ معتبر یا «نامشخص» منتقل شد). مراکز ثبتشده توسط کارفرما/تأییدشده دستنخورده ماند.";
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fix existing aggregated listings the AI mislabeled «پزشک عمومی» (dentist/specialist/…)
|
||||||
|
/// in place from their stored text — no AI, no ID/URL change.</summary>
|
||||||
|
public async Task<IActionResult> OnPostRecorrectRolesAsync()
|
||||||
|
{
|
||||||
|
var n = await _ingest.RecorrectDoctorRolesAsync();
|
||||||
|
IngestMessage = $"اصلاح نقش: {n} آگهیِ «پزشک عمومی» که در واقع نقش دیگری بود (دندانپزشک، متخصص و …) از روی متن آگهی اصلاح شد. بدون تغییر شناسه یا آدرس صفحه.";
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Auto-merge duplicate/compound/typo roles minted by the dynamic taxonomy
|
||||||
|
/// («پرستار کودک» ×3، «پرستار و بهیار»، «بیهیار»→بهیار), repointing all listings first.</summary>
|
||||||
|
public async Task<IActionResult> OnPostMergeRolesAsync()
|
||||||
|
{
|
||||||
|
var n = await _ingest.MergeDuplicateRolesAsync();
|
||||||
|
IngestMessage = $"پاکسازی نقشها: {n} نقشِ تکراری/ترکیبی/غلطاملایی در نقشهای اصلی ادغام شد (آگهیهایشان منتقل شد). فهرست نقشها اکنون تمیزتر است.";
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync(int q = 1, int f = 1)
|
||||||
|
{
|
||||||
|
QueueTotal = await _db.RawListings.CountAsync(r => r.Status == RawListingStatus.New);
|
||||||
|
QueuePage = Math.Clamp(q, 1, QueuePages);
|
||||||
Queue = await _db.RawListings
|
Queue = await _db.RawListings
|
||||||
.Where(r => r.Status == RawListingStatus.New)
|
.Where(r => r.Status == RawListingStatus.New)
|
||||||
.OrderByDescending(r => r.Confidence).ThenByDescending(r => r.FetchedAt).ToListAsync();
|
.OrderByDescending(r => r.Confidence).ThenByDescending(r => r.FetchedAt)
|
||||||
|
.Skip((QueuePage - 1) * PageSize).Take(PageSize).ToListAsync();
|
||||||
|
|
||||||
|
FlaggedTotal = await _db.RawListings.CountAsync(r => r.Status == RawListingStatus.Flagged);
|
||||||
|
FlaggedPage = Math.Clamp(f, 1, FlaggedPages);
|
||||||
Flagged = await _db.RawListings
|
Flagged = await _db.RawListings
|
||||||
.Where(r => r.Status == RawListingStatus.Flagged)
|
.Where(r => r.Status == RawListingStatus.Flagged)
|
||||||
.OrderByDescending(r => r.FetchedAt).ToListAsync();
|
.OrderByDescending(r => r.FetchedAt)
|
||||||
|
.Skip((FlaggedPage - 1) * PageSize).Take(PageSize).ToListAsync();
|
||||||
SourceNames = _ingest.SourceNames;
|
SourceNames = _ingest.SourceNames;
|
||||||
PublishedShifts = await _db.Shifts.CountAsync(s => s.Source != ShiftSource.Direct);
|
PublishedShifts = await _db.Shifts.CountAsync(s => s.Source != ShiftSource.Direct);
|
||||||
PublishedJobs = await _db.JobOpenings.CountAsync();
|
PublishedJobs = await _db.JobOpenings.CountAsync();
|
||||||
|
|||||||
@@ -34,6 +34,32 @@
|
|||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (Model.SourceBreakdown.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="card card-pad" style="margin-bottom:14px;">
|
||||||
|
<strong style="display:block; margin-bottom:8px;">📊 به تفکیک منبع</strong>
|
||||||
|
<table style="width:100%; border-collapse:collapse; font-size:13.5px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="color:var(--muted);">
|
||||||
|
<th style="text-align:start; padding:4px 0;">منبع</th>
|
||||||
|
<th style="text-align:start;">منتشرشده</th>
|
||||||
|
<th style="text-align:start;">کل دریافت</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var s in Model.SourceBreakdown)
|
||||||
|
{
|
||||||
|
<tr style="border-top:1px solid var(--line);">
|
||||||
|
<td style="padding:6px 0;"><strong>@s.Source</strong></td>
|
||||||
|
<td><span class="badge badge-verified">@P(s.Published)</span></td>
|
||||||
|
<td class="muted">@P(s.Total)</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="ing-filters">
|
<div class="ing-filters">
|
||||||
@Html.Raw(Pill("all", "همه", Model.Counts.Values.Sum()))
|
@Html.Raw(Pill("all", "همه", Model.Counts.Values.Sum()))
|
||||||
@Html.Raw(Pill("new", "در صف", C(JobsMedical.Web.Models.RawListingStatus.New)))
|
@Html.Raw(Pill("new", "در صف", C(JobsMedical.Web.Models.RawListingStatus.New)))
|
||||||
@@ -65,7 +91,7 @@
|
|||||||
<span style="display:flex; gap:6px; align-items:center;">
|
<span style="display:flex; gap:6px; align-items:center;">
|
||||||
<span class="badge @cls">@label</span>
|
<span class="badge @cls">@label</span>
|
||||||
<span class="badge badge-type">اطمینان @P(r.Confidence)٪</span>
|
<span class="badge badge-type">اطمینان @P(r.Confidence)٪</span>
|
||||||
<span class="muted" style="font-size:12px;">@JalaliDate.ToLongDate(DateOnly.FromDateTime(r.FetchedAt)) — @JalaliDate.ToPersianDigits(r.FetchedAt.ToString("HH:mm"))</span>
|
<span class="muted" style="font-size:12px;">@JalaliDate.DateTimeLabel(r.FetchedAt)</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p style="margin:8px 0; white-space:pre-wrap; font-size:13.5px;">@(r.RawText.Length > 320 ? r.RawText.Substring(0,320) + "…" : r.RawText)</p>
|
<p style="margin:8px 0; white-space:pre-wrap; font-size:13.5px;">@(r.RawText.Length > 320 ? r.RawText.Substring(0,320) + "…" : r.RawText)</p>
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ public class IngestedModel : PageModel
|
|||||||
public List<RawListing> Items { get; private set; } = new();
|
public List<RawListing> Items { get; private set; } = new();
|
||||||
public int Total { get; private set; }
|
public int Total { get; private set; }
|
||||||
public Dictionary<RawListingStatus, int> Counts { get; private set; } = new();
|
public Dictionary<RawListingStatus, int> Counts { get; private set; } = new();
|
||||||
|
public List<SourceStat> SourceBreakdown { get; private set; } = new();
|
||||||
[TempData] public string? Message { get; set; }
|
[TempData] public string? Message { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Per-source tally: how many crawled vs how many actually published.</summary>
|
||||||
|
public record SourceStat(string Source, int Total, int Published);
|
||||||
|
|
||||||
[BindProperty(SupportsGet = true)] public string? Status { get; set; } // new|flagged|published|discarded|all
|
[BindProperty(SupportsGet = true)] public string? Status { get; set; } // new|flagged|published|discarded|all
|
||||||
[BindProperty(SupportsGet = true)] public string? Source { get; set; }
|
[BindProperty(SupportsGet = true)] public string? Source { get; set; }
|
||||||
|
|
||||||
@@ -29,6 +33,22 @@ public class IngestedModel : PageModel
|
|||||||
Counts = await _db.RawListings.GroupBy(r => r.Status)
|
Counts = await _db.RawListings.GroupBy(r => r.Status)
|
||||||
.Select(g => new { g.Key, C = g.Count() }).ToDictionaryAsync(x => x.Key, x => x.C);
|
.Select(g => new { g.Key, C = g.Count() }).ToDictionaryAsync(x => x.Key, x => x.C);
|
||||||
|
|
||||||
|
// Per-source breakdown — group exact SourceChannel rows then fold into source "families"
|
||||||
|
// (تلگرام/ch → تلگرام, وبسایت (host) → وبسایت) so the table reads one row per source.
|
||||||
|
var bySource = await _db.RawListings.GroupBy(r => r.SourceChannel)
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
Source = g.Key,
|
||||||
|
Total = g.Count(),
|
||||||
|
Published = g.Count(x => x.Status == RawListingStatus.Normalized),
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
SourceBreakdown = bySource
|
||||||
|
.GroupBy(x => SourceFamily(x.Source))
|
||||||
|
.Select(g => new SourceStat(g.Key, g.Sum(x => x.Total), g.Sum(x => x.Published)))
|
||||||
|
.OrderByDescending(s => s.Published).ThenByDescending(s => s.Total)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
var q = _db.RawListings.AsNoTracking().AsQueryable();
|
var q = _db.RawListings.AsNoTracking().AsQueryable();
|
||||||
|
|
||||||
var st = Status?.ToLowerInvariant() switch
|
var st = Status?.ToLowerInvariant() switch
|
||||||
@@ -46,6 +66,15 @@ public class IngestedModel : PageModel
|
|||||||
Items = await q.OrderByDescending(r => r.FetchedAt).Take(200).ToListAsync();
|
Items = await q.OrderByDescending(r => r.FetchedAt).Take(200).ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Collapse a channel label to its source family: "تلگرام/nurses" → "تلگرام",
|
||||||
|
/// "وبسایت (medjobs.ir)" → "وبسایت". Divar/Bale/Medjobs already have no suffix.</summary>
|
||||||
|
private static string SourceFamily(string? channel)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(channel)) return "نامشخص";
|
||||||
|
var cut = channel.IndexOfAny(new[] { '/', '(' });
|
||||||
|
return (cut > 0 ? channel[..cut] : channel).Trim();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ARCHIVE (never delete) everything published from ingestion: the aggregated Shift/Job/Talent
|
/// ARCHIVE (never delete) everything published from ingestion: the aggregated Shift/Job/Talent
|
||||||
/// posts are flipped to Archived (hidden from the site but kept for analytics); the raw crawl
|
/// posts are flipped to Archived (hidden from the site but kept for analytics); the raw crawl
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
<span class="badge @(r.Status == ReportStatus.Open ? "badge-day" : "badge-type")">@StatusLabel(r.Status)</span>
|
<span class="badge @(r.Status == ReportStatus.Open ? "badge-day" : "badge-type")">@StatusLabel(r.Status)</span>
|
||||||
</div>
|
</div>
|
||||||
<p style="margin:8px 0;">«@r.Reason»</p>
|
<p style="margin:8px 0;">«@r.Reason»</p>
|
||||||
<div class="muted" style="font-size:12px;">@JalaliDate.ToLongDate(DateOnly.FromDateTime(r.CreatedAt)) · گزارشدهنده: @(r.ReporterUserId is not null ? "کاربر #" + r.ReporterUserId : "مهمان")</div>
|
<div class="muted" style="font-size:12px;">@JalaliDate.ToLongDate(DateOnly.FromDateTime(JalaliDate.ToTehran(r.CreatedAt))) · گزارشدهنده: @(r.ReporterUserId is not null ? "کاربر #" + r.ReporterUserId : "مهمان")</div>
|
||||||
<div style="display:flex; gap:8px; margin-top:10px;">
|
<div style="display:flex; gap:8px; margin-top:10px;">
|
||||||
<a class="btn btn-outline" style="padding:6px 12px;" href="@JobsMedical.Web.Pages.Admin.ReportsModel.TargetUrl(r)" target="_blank">مشاهده مورد</a>
|
<a class="btn btn-outline" style="padding:6px 12px;" href="@JobsMedical.Web.Pages.Admin.ReportsModel.TargetUrl(r)" target="_blank">مشاهده مورد</a>
|
||||||
@if (r.Status == ReportStatus.Open)
|
@if (r.Status == ReportStatus.Open)
|
||||||
|
|||||||
@@ -19,6 +19,16 @@
|
|||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<h3 style="margin-top:0;">متن خام</h3>
|
<h3 style="margin-top:0;">متن خام</h3>
|
||||||
<p style="white-space:pre-wrap; margin:0;">@r.RawText</p>
|
<p style="white-space:pre-wrap; margin:0;">@r.RawText</p>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(r.SourceUrl))
|
||||||
|
{
|
||||||
|
<p style="margin:12px 0 0;">
|
||||||
|
<a class="btn btn-outline" href="@r.SourceUrl" target="_blank" rel="noopener noreferrer">🔗 مشاهده آگهی در منبع (@r.SourceChannel)</a>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="muted" style="font-size:12px; margin:12px 0 0;">لینک منبع برای این آگهی ثبت نشده است.</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (Model.Parsed is not null)
|
@if (Model.Parsed is not null)
|
||||||
@@ -62,13 +72,17 @@
|
|||||||
<p class="muted" style="font-size:11px; margin:4px 0 0;">اگر مرکز در فهرست نیست، نامش را اینجا بنویس تا بهصورت «تأییدنشده» ساخته شود.</p>
|
<p class="muted" style="font-size:11px; margin:4px 0 0;">اگر مرکز در فهرست نیست، نامش را اینجا بنویس تا بهصورت «تأییدنشده» ساخته شود.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label>نقش</label>
|
<label>نقشها (میتوانی چند مورد انتخاب کنی)</label>
|
||||||
<select name="RoleId">
|
<div class="role-checks">
|
||||||
@foreach (var role in Model.Roles)
|
@foreach (var role in Model.Roles)
|
||||||
{
|
{
|
||||||
<option value="@role.Id" selected="@(Model.RoleId == role.Id)">@role.Name</option>
|
<label class="role-check">
|
||||||
|
<input type="checkbox" name="RoleIds" value="@role.Id" checked="@(Model.RoleIds.Contains(role.Id))" />
|
||||||
|
<span>@role.Name</span>
|
||||||
|
</label>
|
||||||
}
|
}
|
||||||
</select>
|
</div>
|
||||||
|
<p class="muted" style="font-size:11px; margin:4px 0 0;">برای آگهی چندتخصصی (مثل «پرستار سالمند و کودک») همهی نقشها را تیک بزن — برای هر نقش یک آگهی جدا ساخته میشود.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ public class ReviewModel : PageModel
|
|||||||
[BindProperty] public ListingKind Kind { get; set; }
|
[BindProperty] public ListingKind Kind { get; set; }
|
||||||
[BindProperty] public int FacilityId { get; set; }
|
[BindProperty] public int FacilityId { get; set; }
|
||||||
[BindProperty] public string? NewFacilityName { get; set; } // create a facility on the fly if none picked
|
[BindProperty] public string? NewFacilityName { get; set; } // create a facility on the fly if none picked
|
||||||
[BindProperty] public int RoleId { get; set; }
|
/// <summary>One or more roles — an ad like «پرستار سالمند و کودک» publishes one listing per role.</summary>
|
||||||
|
[BindProperty] public int[] RoleIds { get; set; } = Array.Empty<int>();
|
||||||
[BindProperty] public string? Description { get; set; }
|
[BindProperty] public string? Description { get; set; }
|
||||||
// Shift fields
|
// Shift fields
|
||||||
[BindProperty] public DateOnly ShiftDate { get; set; }
|
[BindProperty] public DateOnly ShiftDate { get; set; }
|
||||||
@@ -70,7 +71,8 @@ public class ReviewModel : PageModel
|
|||||||
|
|
||||||
// Prefill the form from the parser's best guess.
|
// Prefill the form from the parser's best guess.
|
||||||
Kind = Parsed.Kind;
|
Kind = Parsed.Kind;
|
||||||
RoleId = Roles.FirstOrDefault(r => r.Name == Parsed.RoleName)?.Id ?? Roles.FirstOrDefault()?.Id ?? 0;
|
var matchedRole = Roles.FirstOrDefault(r => r.Name == Parsed.RoleName)?.Id ?? Roles.FirstOrDefault()?.Id ?? 0;
|
||||||
|
RoleIds = matchedRole > 0 ? new[] { matchedRole } : Array.Empty<int>();
|
||||||
ShiftType = Parsed.ShiftType ?? ShiftType.Day;
|
ShiftType = Parsed.ShiftType ?? ShiftType.Day;
|
||||||
EmploymentType = Parsed.EmploymentType ?? EmploymentType.FullTime;
|
EmploymentType = Parsed.EmploymentType ?? EmploymentType.FullTime;
|
||||||
(StartTime, EndTime) = DefaultTimes(ShiftType);
|
(StartTime, EndTime) = DefaultTimes(ShiftType);
|
||||||
@@ -117,13 +119,20 @@ public class ReviewModel : PageModel
|
|||||||
Raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id);
|
Raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id);
|
||||||
if (Raw is null) return NotFound();
|
if (Raw is null) return NotFound();
|
||||||
|
|
||||||
if (!await _db.Roles.AnyAsync(r => r.Id == RoleId))
|
// One or more roles — publish a separate listing per selected role.
|
||||||
|
var validRoles = await _db.Roles.Where(r => RoleIds.Contains(r.Id)).ToListAsync();
|
||||||
|
if (validRoles.Count == 0)
|
||||||
{
|
{
|
||||||
Error = "یک نقش معتبر انتخاب کن.";
|
Error = "حداقل یک نقش معتبر انتخاب کن.";
|
||||||
return RedirectToPage(new { id });
|
return RedirectToPage(new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
// «آماده به کار» — a worker offering themselves. No facility; publish a TalentListing.
|
var payType = Negotiable ? PayType.Negotiable
|
||||||
|
: (PayAmount is null && SharePercent is not null ? PayType.Percentage : PayType.PerShift);
|
||||||
|
var payAmt = Negotiable ? (long?)null : PayAmount;
|
||||||
|
var sharePct = Negotiable ? (int?)null : SharePercent;
|
||||||
|
|
||||||
|
// ---- آماده به کار: no facility; one TalentListing per role ----
|
||||||
if (Kind == ListingKind.Talent)
|
if (Kind == ListingKind.Talent)
|
||||||
{
|
{
|
||||||
var cityId = TalentCityId > 0 && await _db.Cities.AnyAsync(c => c.Id == TalentCityId)
|
var cityId = TalentCityId > 0 && await _db.Cities.AnyAsync(c => c.Id == TalentCityId)
|
||||||
@@ -134,112 +143,106 @@ public class ReviewModel : PageModel
|
|||||||
Error = "شهری برای انتشار آگهی «آماده به کار» موجود نیست.";
|
Error = "شهری برای انتشار آگهی «آماده به کار» موجود نیست.";
|
||||||
return RedirectToPage(new { id });
|
return RedirectToPage(new { id });
|
||||||
}
|
}
|
||||||
// Re-parse the raw text to recover all contact channels (phones/email/socials) + tags.
|
|
||||||
var roleNames = await _db.Roles.Select(r => r.Name).ToListAsync();
|
var roleNames = await _db.Roles.Select(r => r.Name).ToListAsync();
|
||||||
var reparsed = _parser.Parse(Raw.RawText, roleNames, await CityNamesAsync(), await DistrictNamesAsync());
|
var reparsed = _parser.Parse(Raw.RawText, roleNames, await CityNamesAsync(), await DistrictNamesAsync());
|
||||||
var parsedContacts = reparsed.Contacts
|
var contactSpecs = reparsed.Contacts.Select((c, i) => (c.Type, c.Value, Order: i)).ToList();
|
||||||
.Select((c, i) => new ContactMethod { Type = c.Type, Value = c.Value, SortOrder = i })
|
var adminPhone = string.IsNullOrWhiteSpace(Phone) ? null : Phone.Trim();
|
||||||
.ToList();
|
var tags = string.Join(" ", reparsed.Tags.Distinct());
|
||||||
// Include the admin-typed phone if it isn't already captured.
|
|
||||||
if (!string.IsNullOrWhiteSpace(Phone))
|
// Fresh ContactMethod instances per listing (EF can't share children across parents).
|
||||||
|
List<ContactMethod> FreshContacts()
|
||||||
{
|
{
|
||||||
var digits = new string(Phone.Where(char.IsDigit).ToArray());
|
var list = contactSpecs.Select(s => new ContactMethod { Type = s.Type, Value = s.Value, SortOrder = s.Order }).ToList();
|
||||||
if (!parsedContacts.Any(c => new string(c.Value.Where(char.IsDigit).ToArray()) == digits))
|
if (adminPhone is not null)
|
||||||
parsedContacts.Insert(0, new ContactMethod { Type = ContactType.Mobile, Value = Phone.Trim(), SortOrder = -1 });
|
{
|
||||||
|
var d = new string(adminPhone.Where(char.IsDigit).ToArray());
|
||||||
|
if (!list.Any(c => new string(c.Value.Where(char.IsDigit).ToArray()) == d))
|
||||||
|
list.Insert(0, new ContactMethod { Type = ContactType.Mobile, Value = adminPhone, SortOrder = -1 });
|
||||||
}
|
}
|
||||||
var talent = new TalentListing
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
TalentListing? firstTalent = null;
|
||||||
|
foreach (var role in validRoles)
|
||||||
{
|
{
|
||||||
RoleId = RoleId,
|
var t = new TalentListing
|
||||||
CityId = cityId.Value,
|
{
|
||||||
|
RoleId = role.Id, CityId = cityId.Value,
|
||||||
PersonName = string.IsNullOrWhiteSpace(PersonName) ? null : PersonName.Trim(),
|
PersonName = string.IsNullOrWhiteSpace(PersonName) ? null : PersonName.Trim(),
|
||||||
YearsExperience = YearsExperience,
|
YearsExperience = YearsExperience, IsLicensed = IsLicensed,
|
||||||
IsLicensed = IsLicensed,
|
|
||||||
AreaNote = string.IsNullOrWhiteSpace(AreaNote) ? null : AreaNote.Trim(),
|
AreaNote = string.IsNullOrWhiteSpace(AreaNote) ? null : AreaNote.Trim(),
|
||||||
Availability = EmploymentType,
|
Availability = EmploymentType, Gender = GenderRequirement,
|
||||||
Gender = GenderRequirement,
|
PayType = payType, PayAmount = payAmt, SharePercent = sharePct,
|
||||||
PayType = Negotiable ? PayType.Negotiable
|
Phone = adminPhone, Description = Description,
|
||||||
: (PayAmount is null && SharePercent is not null ? PayType.Percentage : PayType.PerShift),
|
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl,
|
||||||
PayAmount = Negotiable ? null : PayAmount,
|
Contacts = FreshContacts(),
|
||||||
SharePercent = Negotiable ? null : SharePercent,
|
Tags = string.Join(" ", new[] { tags, role.Name }.Where(x => !string.IsNullOrWhiteSpace(x))),
|
||||||
Phone = string.IsNullOrWhiteSpace(Phone) ? null : Phone.Trim(),
|
|
||||||
Description = Description,
|
|
||||||
Status = ShiftStatus.Open,
|
|
||||||
Source = ShiftSource.Aggregated,
|
|
||||||
SourceUrl = Raw.SourceUrl,
|
|
||||||
Contacts = parsedContacts,
|
|
||||||
Tags = string.Join(" ", reparsed.Tags.Distinct()),
|
|
||||||
};
|
};
|
||||||
_db.TalentListings.Add(talent);
|
_db.TalentListings.Add(t);
|
||||||
|
firstTalent ??= t;
|
||||||
|
}
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
Raw.Status = RawListingStatus.Normalized;
|
Raw.Status = RawListingStatus.Normalized;
|
||||||
Raw.LinkedTalentId = talent.Id;
|
Raw.LinkedTalentId = firstTalent!.Id;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
return RedirectToPage("/Admin/Index");
|
return RedirectToPage("/Admin/Index");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shift/Job need a facility. Resolve the picked/typed one, falling back to a single
|
// ---- Shift / Job: need a facility (falls back to «نامشخص / ثبت نشده») ----
|
||||||
// shared «نامشخص / ثبت نشده» record when the ad doesn't name a facility — so publishing
|
|
||||||
// never fails on a missing facility.
|
|
||||||
var facilityId = await ResolveFacilityIdAsync();
|
var facilityId = await ResolveFacilityIdAsync();
|
||||||
if (facilityId is null)
|
if (facilityId is null)
|
||||||
{
|
{
|
||||||
Error = "شهری برای ساخت مرکز موجود نیست؛ ابتدا یک شهر اضافه کن.";
|
Error = "شهری برای ساخت مرکز موجود نیست؛ ابتدا یک شهر اضافه کن.";
|
||||||
return RedirectToPage(new { id });
|
return RedirectToPage(new { id });
|
||||||
}
|
}
|
||||||
|
var many = validRoles.Count > 1;
|
||||||
|
|
||||||
Shift? createdShift = null;
|
|
||||||
JobOpening? createdJob = null;
|
|
||||||
if (Kind == ListingKind.Shift)
|
if (Kind == ListingKind.Shift)
|
||||||
{
|
{
|
||||||
var role = await _db.Roles.FindAsync(RoleId);
|
var created = new List<Shift>();
|
||||||
|
foreach (var role in validRoles)
|
||||||
|
{
|
||||||
var shift = new Shift
|
var shift = new Shift
|
||||||
{
|
{
|
||||||
FacilityId = facilityId.Value,
|
FacilityId = facilityId.Value, RoleId = role.Id,
|
||||||
RoleId = RoleId,
|
Date = ShiftDate, StartTime = StartTime, EndTime = EndTime, ShiftType = ShiftType,
|
||||||
Date = ShiftDate,
|
SpecialtyRequired = role.Name, Description = Description,
|
||||||
StartTime = StartTime,
|
PayType = payType, PayAmount = payAmt, SharePercent = sharePct,
|
||||||
EndTime = EndTime,
|
GenderRequirement = GenderRequirement, Status = ShiftStatus.Open,
|
||||||
ShiftType = ShiftType,
|
Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl,
|
||||||
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,
|
|
||||||
GenderRequirement = GenderRequirement,
|
|
||||||
Status = ShiftStatus.Open,
|
|
||||||
Source = ShiftSource.Aggregated,
|
|
||||||
SourceUrl = Raw.SourceUrl,
|
|
||||||
};
|
};
|
||||||
_db.Shifts.Add(shift);
|
_db.Shifts.Add(shift);
|
||||||
|
created.Add(shift);
|
||||||
|
}
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
Raw.Status = RawListingStatus.Normalized;
|
Raw.Status = RawListingStatus.Normalized;
|
||||||
Raw.LinkedShiftId = shift.Id;
|
Raw.LinkedShiftId = created[0].Id;
|
||||||
createdShift = shift;
|
await _db.SaveChangesAsync();
|
||||||
|
foreach (var s in created) await _notify.NotifyNewShiftAsync(s.Id);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
{
|
||||||
|
var created = new List<JobOpening>();
|
||||||
|
foreach (var role in validRoles)
|
||||||
{
|
{
|
||||||
var job = new JobOpening
|
var job = new JobOpening
|
||||||
{
|
{
|
||||||
FacilityId = facilityId.Value,
|
FacilityId = facilityId.Value, RoleId = role.Id,
|
||||||
RoleId = RoleId,
|
// With several roles, give each a role-specific title; with one, honor the typed title.
|
||||||
Title = string.IsNullOrWhiteSpace(Title) ? "موقعیت استخدامی" : Title.Trim(),
|
Title = many || string.IsNullOrWhiteSpace(Title) ? $"استخدام {role.Name}" : Title.Trim(),
|
||||||
EmploymentType = EmploymentType,
|
EmploymentType = EmploymentType,
|
||||||
SalaryMin = Negotiable ? null : SalaryMin,
|
SalaryMin = Negotiable ? null : SalaryMin, SalaryMax = Negotiable ? null : SalaryMax,
|
||||||
SalaryMax = Negotiable ? null : SalaryMax,
|
GenderRequirement = GenderRequirement, Description = Description,
|
||||||
GenderRequirement = GenderRequirement,
|
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl,
|
||||||
Description = Description,
|
|
||||||
Status = ShiftStatus.Open,
|
|
||||||
Source = ShiftSource.Aggregated,
|
|
||||||
SourceUrl = Raw.SourceUrl,
|
|
||||||
};
|
};
|
||||||
_db.JobOpenings.Add(job);
|
_db.JobOpenings.Add(job);
|
||||||
Raw.Status = RawListingStatus.Normalized;
|
created.Add(job);
|
||||||
createdJob = job;
|
|
||||||
}
|
}
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
if (createdShift is not null) await _notify.NotifyNewShiftAsync(createdShift.Id);
|
Raw.Status = RawListingStatus.Normalized;
|
||||||
if (createdJob is not null) await _notify.NotifyNewJobAsync(createdJob.Id);
|
await _db.SaveChangesAsync();
|
||||||
|
foreach (var j in created) await _notify.NotifyNewJobAsync(j.Id);
|
||||||
|
}
|
||||||
return RedirectToPage("/Admin/Index");
|
return RedirectToPage("/Admin/Index");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,13 +282,26 @@ public class ReviewModel : PageModel
|
|||||||
if (cityId is null) return null; // no cities seeded — cannot create a facility
|
if (cityId is null) return null; // no cities seeded — cannot create a facility
|
||||||
|
|
||||||
// No facility named in the ad → use/create the shared placeholder.
|
// No facility named in the ad → use/create the shared placeholder.
|
||||||
var name = string.IsNullOrWhiteSpace(NewFacilityName) ? UnknownFacilityName : NewFacilityName.Trim();
|
var isPlaceholder = string.IsNullOrWhiteSpace(NewFacilityName);
|
||||||
|
var name = isPlaceholder ? UnknownFacilityName : NewFacilityName.Trim();
|
||||||
|
|
||||||
|
// Approximate coords carried from the crawl (e.g. Divar). NEVER apply them to the shared
|
||||||
|
// «نامشخص» placeholder — it's reused across many ads, so a single ad's point would mislead.
|
||||||
|
bool HasGeo() => !isPlaceholder && Raw?.Lat is not null;
|
||||||
|
|
||||||
// Reuse an existing facility that's exactly or closely the same (Persian-aware fuzzy
|
// Reuse an existing facility that's exactly or closely the same (Persian-aware fuzzy
|
||||||
// match), so we don't create duplicates like «بیمارستان میلاد» vs «میلاد».
|
// match), so we don't create duplicates like «بیمارستان میلاد» vs «میلاد».
|
||||||
var all = await _db.Facilities.ToListAsync();
|
var all = await _db.Facilities.ToListAsync();
|
||||||
var match = FacilityMatcher.FindBest(all, name, cityId);
|
var match = FacilityMatcher.FindBest(all, name, cityId);
|
||||||
if (match is not null) return match.Id;
|
if (match is not null)
|
||||||
|
{
|
||||||
|
if (HasGeo() && match.Lat is null && match.Lng is null) // backfill only, never overwrite
|
||||||
|
{
|
||||||
|
match.Lat = Raw!.Lat; match.Lng = Raw.Lng;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
return match.Id;
|
||||||
|
}
|
||||||
|
|
||||||
var facility = new Facility
|
var facility = new Facility
|
||||||
{
|
{
|
||||||
@@ -294,6 +310,8 @@ public class ReviewModel : PageModel
|
|||||||
Type = FacilityType.Hospital,
|
Type = FacilityType.Hospital,
|
||||||
Verification = VerificationStatus.Unverified,
|
Verification = VerificationStatus.Unverified,
|
||||||
IsVerified = false,
|
IsVerified = false,
|
||||||
|
Lat = HasGeo() ? Raw!.Lat : null,
|
||||||
|
Lng = HasGeo() ? Raw!.Lng : null,
|
||||||
};
|
};
|
||||||
_db.Facilities.Add(facility);
|
_db.Facilities.Add(facility);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
@page
|
||||||
|
@model JobsMedical.Web.Pages.Admin.RolesModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "نقشها و دستهبندی";
|
||||||
|
string P(int n) => JalaliDate.ToPersianDigits(n.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="container">
|
||||||
|
<h1>نقشها و دستهبندی</h1>
|
||||||
|
<p class="muted"><a asp-page="/Admin/Index">← صف بررسی</a> — ادغام نقشهای تکراری و مدیریت تاکسونومی.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container section">
|
||||||
|
@if (Model.Message is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success">✓ @Model.Message</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="card card-pad" style="margin-bottom:16px;">
|
||||||
|
<h3 style="margin-top:0;">ادغام نقش</h3>
|
||||||
|
<p class="muted" style="font-size:12.5px; margin-top:0;">همهٔ آگهیها و علاقهمندیهای «نقش مبدأ» به «نقش مقصد» منتقل و نقش مبدأ حذف میشود. این کار بازگشتناپذیر است.</p>
|
||||||
|
<form method="post" asp-page-handler="Merge"
|
||||||
|
onsubmit="return confirm('ادغام انجام شود؟ این کار بازگشتناپذیر است.');"
|
||||||
|
style="display:flex; gap:8px; flex-wrap:wrap; align-items:end;">
|
||||||
|
<div class="filter-group" style="margin:0; flex:1; min-width:200px;">
|
||||||
|
<label>نقش مبدأ (حذف میشود)</label>
|
||||||
|
<select name="sourceId" required>
|
||||||
|
<option value="">— انتخاب —</option>
|
||||||
|
@foreach (var x in Model.Stats)
|
||||||
|
{
|
||||||
|
<option value="@x.Role.Id">@x.Role.Name (@P(x.Total))</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group" style="margin:0; flex:1; min-width:200px;">
|
||||||
|
<label>نقش مقصد (میماند)</label>
|
||||||
|
<select name="targetId" required>
|
||||||
|
<option value="">— انتخاب —</option>
|
||||||
|
@foreach (var x in Model.Stats)
|
||||||
|
{
|
||||||
|
<option value="@x.Role.Id">@x.Role.Name (@P(x.Total))</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-accent">ادغام</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table style="width:100%; border-collapse:collapse; font-size:13.5px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="color:var(--muted); text-align:start;">
|
||||||
|
<th style="text-align:start; padding:6px 0;">نقش</th>
|
||||||
|
<th style="text-align:start;">گروه</th>
|
||||||
|
<th style="text-align:start;">شیفت</th>
|
||||||
|
<th style="text-align:start;">استخدام</th>
|
||||||
|
<th style="text-align:start;">آمادهبهکار</th>
|
||||||
|
<th style="text-align:start;">جمع</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var x in Model.Stats)
|
||||||
|
{
|
||||||
|
<tr style="border-top:1px solid var(--line); @(x.Role.IsActive ? "" : "opacity:.5;")">
|
||||||
|
<td style="padding:8px 0;"><strong>@x.Role.Name</strong> @(x.Role.IsActive ? "" : "(غیرفعال)")</td>
|
||||||
|
<td class="muted">@x.Role.Category</td>
|
||||||
|
<td>@P(x.Shifts)</td>
|
||||||
|
<td>@P(x.Jobs)</td>
|
||||||
|
<td>@P(x.Talent)</td>
|
||||||
|
<td><strong>@P(x.Total)</strong></td>
|
||||||
|
<td style="text-align:end;">
|
||||||
|
<form method="post" asp-page-handler="Toggle" asp-route-id="@x.Role.Id" style="display:inline;">
|
||||||
|
<button type="submit" class="btn btn-outline" style="padding:4px 12px; font-size:12px;">
|
||||||
|
@(x.Role.IsActive ? "غیرفعالسازی" : "فعالسازی")
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using JobsMedical.Web.Data;
|
||||||
|
using JobsMedical.Web.Models;
|
||||||
|
using JobsMedical.Web.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Pages.Admin;
|
||||||
|
|
||||||
|
/// <summary>Role taxonomy hygiene — dynamic ingestion can mint near-duplicate roles over time
|
||||||
|
/// («کمکیار» vs «کمک بهیار»). This screen lists every role with its usage and lets an admin merge
|
||||||
|
/// one role into another (reassigning all its listings/preferences) or toggle a role's visibility.</summary>
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public class RolesModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public RolesModel(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public record RoleStat(Role Role, int Shifts, int Jobs, int Talent)
|
||||||
|
{
|
||||||
|
public int Total => Shifts + Jobs + Talent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<RoleStat> Stats { get; private set; } = new();
|
||||||
|
[TempData] public string? Message { get; set; }
|
||||||
|
|
||||||
|
public async Task OnGetAsync() => await LoadAsync();
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
var roles = await _db.Roles.OrderBy(r => r.Category).ThenBy(r => r.Name).ToListAsync();
|
||||||
|
var sc = await _db.Shifts.GroupBy(s => s.RoleId).Select(g => new { g.Key, C = g.Count() }).ToDictionaryAsync(x => x.Key, x => x.C);
|
||||||
|
var jc = await _db.JobOpenings.GroupBy(j => j.RoleId).Select(g => new { g.Key, C = g.Count() }).ToDictionaryAsync(x => x.Key, x => x.C);
|
||||||
|
var tc = await _db.TalentListings.GroupBy(t => t.RoleId).Select(g => new { g.Key, C = g.Count() }).ToDictionaryAsync(x => x.Key, x => x.C);
|
||||||
|
Stats = roles.Select(r => new RoleStat(r, sc.GetValueOrDefault(r.Id), jc.GetValueOrDefault(r.Id), tc.GetValueOrDefault(r.Id)))
|
||||||
|
.OrderByDescending(x => x.Total).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Move every reference from <paramref name="sourceId"/> to <paramref name="targetId"/>
|
||||||
|
/// (listings — the Restrict FKs that would otherwise block — plus preferences/alerts/profiles),
|
||||||
|
/// then delete the now-empty source role.</summary>
|
||||||
|
public async Task<IActionResult> OnPostMergeAsync(int sourceId, int targetId)
|
||||||
|
{
|
||||||
|
if (sourceId == 0 || targetId == 0 || sourceId == targetId)
|
||||||
|
{ Message = "نقش مبدأ و مقصد را درست انتخاب کن."; return RedirectToPage(); }
|
||||||
|
|
||||||
|
var source = await _db.Roles.FindAsync(sourceId);
|
||||||
|
var target = await _db.Roles.FindAsync(targetId);
|
||||||
|
if (source is null || target is null) { Message = "نقش پیدا نشد."; return RedirectToPage(); }
|
||||||
|
|
||||||
|
var s = await _db.Shifts.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, targetId));
|
||||||
|
var j = await _db.JobOpenings.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, targetId));
|
||||||
|
var t = await _db.TalentListings.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, targetId));
|
||||||
|
// Nullable references too, so a saved preference/alert follows the merge instead of dangling.
|
||||||
|
await _db.UserPreferences.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, (int?)targetId));
|
||||||
|
await _db.JobAlerts.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, (int?)targetId));
|
||||||
|
await _db.DoctorProfiles.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, (int?)targetId));
|
||||||
|
|
||||||
|
_db.Roles.Remove(source);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
string P(int n) => JalaliDate.ToPersianDigits(n.ToString());
|
||||||
|
Message = $"«{source.Name}» در «{target.Name}» ادغام شد — منتقلشده: {P(s)} شیفت، {P(j)} استخدام، {P(t)} آمادهبهکار.";
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hide a role from filters/forms without deleting it (keeps its listings intact).</summary>
|
||||||
|
public async Task<IActionResult> OnPostToggleAsync(int id)
|
||||||
|
{
|
||||||
|
var role = await _db.Roles.FindAsync(id);
|
||||||
|
if (role is not null) { role.IsActive = !role.IsActive; await _db.SaveChangesAsync(); }
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,11 @@
|
|||||||
@if (Model.DemoMsg is not null) { <div class="alert alert-success">@Model.DemoMsg</div> }
|
@if (Model.DemoMsg is not null) { <div class="alert alert-success">@Model.DemoMsg</div> }
|
||||||
@if (Model.SmsTest is not null) { <div class="alert alert-success">@Model.SmsTest</div> }
|
@if (Model.SmsTest is not null) { <div class="alert alert-success">@Model.SmsTest</div> }
|
||||||
@if (Model.ProxyTest is not null) { <div class="alert alert-success">@Model.ProxyTest</div> }
|
@if (Model.ProxyTest is not null) { <div class="alert alert-success">@Model.ProxyTest</div> }
|
||||||
@if (Model.AiTest is not null) { <div class="alert alert-success">@Model.AiTest</div> }
|
@if (Model.AiTest is not null)
|
||||||
|
{
|
||||||
|
<div class="alert @(Model.AiTest.StartsWith("✅") ? "alert-success" : "alert-error")"
|
||||||
|
style="white-space:pre-wrap; word-break:break-word;">@Model.AiTest</div>
|
||||||
|
}
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div class="settings-layout">
|
<div class="settings-layout">
|
||||||
@@ -67,9 +71,10 @@
|
|||||||
<div style="flex:1;"><label>نام مدل</label><input type="text" name="AiModel" value="@Model.AiModel" dir="ltr" /></div>
|
<div style="flex:1;"><label>نام مدل</label><input type="text" name="AiModel" value="@Model.AiModel" dir="ltr" /></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label>دستور و چارچوب هوش مصنوعی (Prompt / Framework)</label>
|
<label>دستور و چارچوب هوش مصنوعی (Prompt / Framework) — ثابت 🔒</label>
|
||||||
<textarea name="AiSystemPrompt" rows="8" dir="rtl">@Model.AiSystemPrompt</textarea>
|
<textarea rows="14" dir="rtl" readonly
|
||||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">به مدل بگو چطور تأیید/رد کند و چه فیلدهایی را استخراج کند. خروجی باید JSON باشد.</p>
|
style="background:var(--bg); color:var(--muted); cursor:not-allowed;">@JobsMedical.Web.Models.AppSetting.DefaultPrompt</textarea>
|
||||||
|
<p class="muted" style="font-size:12px; margin:4px 0 0;">این دستور در کد ثابت شده و قابل ویرایش نیست تا دستهبندی و استخراج همیشه درست بماند. یک «اسکیمای خروجی JSON» هم بهصورت خودکار به انتهای آن افزوده میشود.</p>
|
||||||
</div>
|
</div>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<input type="checkbox" name="AiAutoApprove" value="true" checked="@Model.AiAutoApprove" />
|
<input type="checkbox" name="AiAutoApprove" value="true" checked="@Model.AiAutoApprove" />
|
||||||
@@ -142,6 +147,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="source-box">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<input type="checkbox" name="IranEstekhdamEnabled" value="true" checked="@Model.IranEstekhdamEnabled" />
|
||||||
|
<span class="t-body"><span>🏥 ایراناستخدام (iranestekhdam.ir)</span><span class="t-hint">آگهیهای استخدامِ مراکز درمانیِ نامدار از سایتمپِ ماهانه؛ فقط نقشهای بالینی.</span></span>
|
||||||
|
</label>
|
||||||
|
<div class="filter-group"><label>حداکثر آگهی در هر اجرا</label><input type="number" name="IranEstekhdamMaxAds" min="1" max="500" value="@Model.IranEstekhdamMaxAds" dir="ltr" />
|
||||||
|
<label class="proxy-toggle"><input type="checkbox" name="IranEstekhdamUseProxy" value="true" checked="@Model.IranEstekhdamUseProxy" /> از پروکسی استفاده شود</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="source-box">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<input type="checkbox" name="MedboomEnabled" value="true" checked="@Model.MedboomEnabled" />
|
||||||
|
<span class="t-body"><span>🩺 مدبوم (medboom.ir)</span><span class="t-hint">آگهیهای علوم پزشکی (بیشتر پزشک/دندانپزشک)، استخدام و آمادهبهکار؛ بدون نیاز به فیلترشکن.</span></span>
|
||||||
|
</label>
|
||||||
|
<div class="filter-group"><label>حداکثر آگهی در هر اجرا</label><input type="number" name="MedboomMaxAds" min="1" max="500" value="@Model.MedboomMaxAds" dir="ltr" />
|
||||||
|
<label class="proxy-toggle"><input type="checkbox" name="MedboomUseProxy" value="true" checked="@Model.MedboomUseProxy" /> از پروکسی استفاده شود</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="source-box">
|
<div class="source-box">
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<input type="checkbox" name="WebsitesEnabled" value="true" checked="@Model.WebsitesEnabled" />
|
<input type="checkbox" name="WebsitesEnabled" value="true" checked="@Model.WebsitesEnabled" />
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public class SettingsModel : PageModel
|
|||||||
[BindProperty] public string? AiEndpoint { get; set; }
|
[BindProperty] public string? AiEndpoint { get; set; }
|
||||||
[BindProperty] public string? AiApiKey { get; set; }
|
[BindProperty] public string? AiApiKey { get; set; }
|
||||||
[BindProperty] public string? AiModel { get; set; }
|
[BindProperty] public string? AiModel { get; set; }
|
||||||
[BindProperty] public string AiSystemPrompt { get; set; } = "";
|
// AiSystemPrompt is hardcoded (AppSetting.DefaultPrompt) and shown read-only — not bound/editable.
|
||||||
[BindProperty] public bool AiAutoApprove { get; set; }
|
[BindProperty] public bool AiAutoApprove { get; set; }
|
||||||
[BindProperty] public bool AiUseProxy { get; set; }
|
[BindProperty] public bool AiUseProxy { get; set; }
|
||||||
// Channel scraping sources
|
// Channel scraping sources
|
||||||
@@ -47,6 +47,12 @@ public class SettingsModel : PageModel
|
|||||||
[BindProperty] public string? DivarQueries { get; set; }
|
[BindProperty] public string? DivarQueries { get; set; }
|
||||||
[BindProperty] public bool MedjobsEnabled { get; set; }
|
[BindProperty] public bool MedjobsEnabled { get; set; }
|
||||||
[BindProperty] public int MedjobsMaxAds { get; set; } = 40;
|
[BindProperty] public int MedjobsMaxAds { get; set; } = 40;
|
||||||
|
[BindProperty] public bool IranEstekhdamEnabled { get; set; }
|
||||||
|
[BindProperty] public int IranEstekhdamMaxAds { get; set; } = 40;
|
||||||
|
[BindProperty] public bool IranEstekhdamUseProxy { get; set; }
|
||||||
|
[BindProperty] public bool MedboomEnabled { get; set; }
|
||||||
|
[BindProperty] public int MedboomMaxAds { get; set; } = 40;
|
||||||
|
[BindProperty] public bool MedboomUseProxy { get; set; }
|
||||||
[BindProperty] public bool SmsEnabled { get; set; }
|
[BindProperty] public bool SmsEnabled { get; set; }
|
||||||
[BindProperty] public string? SmsApiKey { get; set; }
|
[BindProperty] public string? SmsApiKey { get; set; }
|
||||||
[BindProperty] public string? SmsTemplate { get; set; }
|
[BindProperty] public string? SmsTemplate { get; set; }
|
||||||
@@ -82,7 +88,6 @@ public class SettingsModel : PageModel
|
|||||||
AiEndpoint = s.AiEndpoint;
|
AiEndpoint = s.AiEndpoint;
|
||||||
AiApiKey = s.AiApiKey;
|
AiApiKey = s.AiApiKey;
|
||||||
AiModel = s.AiModel;
|
AiModel = s.AiModel;
|
||||||
AiSystemPrompt = s.AiSystemPrompt;
|
|
||||||
AiAutoApprove = s.AiAutoApprove;
|
AiAutoApprove = s.AiAutoApprove;
|
||||||
AiUseProxy = s.AiUseProxy;
|
AiUseProxy = s.AiUseProxy;
|
||||||
AutoIngestEnabled = s.AutoIngestEnabled;
|
AutoIngestEnabled = s.AutoIngestEnabled;
|
||||||
@@ -96,6 +101,12 @@ public class SettingsModel : PageModel
|
|||||||
DivarQueries = s.DivarQueries;
|
DivarQueries = s.DivarQueries;
|
||||||
MedjobsEnabled = s.MedjobsEnabled;
|
MedjobsEnabled = s.MedjobsEnabled;
|
||||||
MedjobsMaxAds = s.MedjobsMaxAds;
|
MedjobsMaxAds = s.MedjobsMaxAds;
|
||||||
|
IranEstekhdamEnabled = s.IranEstekhdamEnabled;
|
||||||
|
IranEstekhdamMaxAds = s.IranEstekhdamMaxAds;
|
||||||
|
IranEstekhdamUseProxy = s.IranEstekhdamUseProxy;
|
||||||
|
MedboomEnabled = s.MedboomEnabled;
|
||||||
|
MedboomMaxAds = s.MedboomMaxAds;
|
||||||
|
MedboomUseProxy = s.MedboomUseProxy;
|
||||||
SmsEnabled = s.SmsEnabled;
|
SmsEnabled = s.SmsEnabled;
|
||||||
SmsApiKey = s.SmsApiKey;
|
SmsApiKey = s.SmsApiKey;
|
||||||
SmsTemplate = s.SmsTemplate;
|
SmsTemplate = s.SmsTemplate;
|
||||||
@@ -127,7 +138,7 @@ public class SettingsModel : PageModel
|
|||||||
AiEndpoint = AiEndpoint,
|
AiEndpoint = AiEndpoint,
|
||||||
AiApiKey = AiApiKey,
|
AiApiKey = AiApiKey,
|
||||||
AiModel = AiModel,
|
AiModel = AiModel,
|
||||||
AiSystemPrompt = AiSystemPrompt,
|
// AiSystemPrompt intentionally omitted — AppSetting defaults it to DefaultPrompt (hardcoded).
|
||||||
AiAutoApprove = AiAutoApprove,
|
AiAutoApprove = AiAutoApprove,
|
||||||
AiUseProxy = AiUseProxy,
|
AiUseProxy = AiUseProxy,
|
||||||
AutoIngestEnabled = AutoIngestEnabled,
|
AutoIngestEnabled = AutoIngestEnabled,
|
||||||
@@ -141,6 +152,12 @@ public class SettingsModel : PageModel
|
|||||||
DivarQueries = DivarQueries,
|
DivarQueries = DivarQueries,
|
||||||
MedjobsEnabled = MedjobsEnabled,
|
MedjobsEnabled = MedjobsEnabled,
|
||||||
MedjobsMaxAds = MedjobsMaxAds,
|
MedjobsMaxAds = MedjobsMaxAds,
|
||||||
|
IranEstekhdamEnabled = IranEstekhdamEnabled,
|
||||||
|
IranEstekhdamMaxAds = IranEstekhdamMaxAds,
|
||||||
|
IranEstekhdamUseProxy = IranEstekhdamUseProxy,
|
||||||
|
MedboomEnabled = MedboomEnabled,
|
||||||
|
MedboomMaxAds = MedboomMaxAds,
|
||||||
|
MedboomUseProxy = MedboomUseProxy,
|
||||||
SmsEnabled = SmsEnabled,
|
SmsEnabled = SmsEnabled,
|
||||||
SmsApiKey = SmsApiKey,
|
SmsApiKey = SmsApiKey,
|
||||||
SmsTemplate = SmsTemplate,
|
SmsTemplate = SmsTemplate,
|
||||||
@@ -212,14 +229,9 @@ public class SettingsModel : PageModel
|
|||||||
{ AiTest = "ابتدا «فعالسازی هوش مصنوعی» را بزن و آدرس/کلید را ذخیره کن."; return RedirectToPage(); }
|
{ AiTest = "ابتدا «فعالسازی هوش مصنوعی» را بزن و آدرس/کلید را ذخیره کن."; return RedirectToPage(); }
|
||||||
|
|
||||||
const string sample = "استخدام پرستار خانم برای بخش اورژانس بیمارستان میلاد تهران، شیفت شب، حقوق توافقی، تماس ۰۹۱۲۱۲۳۴۵۶۷";
|
const string sample = "استخدام پرستار خانم برای بخش اورژانس بیمارستان میلاد تهران، شیفت شب، حقوق توافقی، تماس ۰۹۱۲۱۲۳۴۵۶۷";
|
||||||
try
|
// TestAsync runs the real call and returns the exact reason on failure (HTTP status,
|
||||||
{
|
// response body, network/proxy error) — unlike AuditAsync, which swallows errors to null.
|
||||||
var r = await _ai.AuditAsync(sample, s);
|
AiTest = await _ai.TestAsync(sample, s);
|
||||||
AiTest = r is null
|
|
||||||
? "❌ پاسخی از هوش مصنوعی دریافت نشد. کلید/آدرس و (در صورت نیاز) تیک «از طریق پروکسی» را بررسی کن."
|
|
||||||
: $"✅ هوش مصنوعی پاسخ داد — تصمیم: {r.Decision} | اطمینان: {r.Confidence}٪ | نقش: {r.Data?.Role} | شهر: {r.Data?.City} | شیفت: {r.Data?.ShiftType}";
|
|
||||||
}
|
|
||||||
catch (Exception ex) { AiTest = "❌ خطا در تماس با هوش مصنوعی: " + ex.Message; }
|
|
||||||
return RedirectToPage();
|
return RedirectToPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,8 +88,8 @@
|
|||||||
@section Scripts {
|
@section Scripts {
|
||||||
@if (!string.IsNullOrEmpty(Model.MapKey))
|
@if (!string.IsNullOrEmpty(Model.MapKey))
|
||||||
{
|
{
|
||||||
<link rel="stylesheet" href="https://static.neshan.org/sdk/leaflet/1.4.0/neshan-sdk/v1.0.8/index.css" />
|
<link rel="stylesheet" href="https://static.neshan.org/sdk/leaflet/1.4.0/leaflet.css" />
|
||||||
<script src="https://static.neshan.org/sdk/leaflet/1.4.0/neshan-sdk/v1.0.8/index.js"></script>
|
<script src="https://static.neshan.org/sdk/leaflet/1.4.0/leaflet.js"></script>
|
||||||
}
|
}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
@@ -14,6 +14,15 @@
|
|||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (Model.AdminDetail is not null)
|
||||||
|
{
|
||||||
|
<div style="margin:16px 0; padding:14px; border:1px solid var(--danger); border-radius:10px; background:#fff5f5; direction:ltr; text-align:left;">
|
||||||
|
<strong>🔧 جزئیات خطا (فقط برای ادمین)</strong>
|
||||||
|
@if (Model.AdminPath is not null) { <div style="margin:6px 0;"><code>@Model.AdminPath</code></div> }
|
||||||
|
<pre style="white-space:pre-wrap; word-break:break-word; font-size:12px; margin:8px 0 0; max-height:50vh; overflow:auto;">@Model.AdminDetail</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<h3>Development Mode</h3>
|
<h3>Development Mode</h3>
|
||||||
<p>
|
<p>
|
||||||
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
|
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using Microsoft.AspNetCore.Diagnostics;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
@@ -12,9 +13,24 @@ public class ErrorModel : PageModel
|
|||||||
|
|
||||||
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||||
|
|
||||||
|
/// <summary>The real exception — shown ONLY to a logged-in Admin, so production 500s can be
|
||||||
|
/// diagnosed without server-log access. Hidden from everyone else.</summary>
|
||||||
|
public string? AdminDetail { get; private set; }
|
||||||
|
public string? AdminPath { get; private set; }
|
||||||
|
|
||||||
public void OnGet()
|
public void OnGet()
|
||||||
{
|
{
|
||||||
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
|
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
|
||||||
|
|
||||||
|
if (User.IsInRole("Admin"))
|
||||||
|
{
|
||||||
|
var feat = HttpContext.Features.Get<IExceptionHandlerPathFeature>();
|
||||||
|
AdminPath = feat?.Path;
|
||||||
|
if (feat?.Error is { } ex)
|
||||||
|
AdminDetail = ex.GetType().FullName + ": " + ex.Message
|
||||||
|
+ (ex.InnerException is { } ie ? $"\n ↳ {ie.GetType().Name}: {ie.Message}" : "")
|
||||||
|
+ "\n\n" + ex.StackTrace;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,9 @@
|
|||||||
<div class="container section">
|
<div class="container section">
|
||||||
@if (Model.Reported) { <div class="alert alert-success">✓ گزارش شما ثبت شد. متشکریم.</div> }
|
@if (Model.Reported) { <div class="alert alert-success">✓ گزارش شما ثبت شد. متشکریم.</div> }
|
||||||
|
|
||||||
<div class="layout-2">
|
@* detail-grid = content(1fr) + sidebar(340px); the content div is first, so it gets the wide
|
||||||
|
column. (layout-2 is sidebar-first/270px and was squeezing the job cards into a narrow strip.) *@
|
||||||
|
<div class="detail-grid">
|
||||||
<div>
|
<div>
|
||||||
@if (Model.Shifts.Count == 0 && Model.Jobs.Count == 0)
|
@if (Model.Shifts.Count == 0 && Model.Jobs.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -147,3 +149,12 @@
|
|||||||
{
|
{
|
||||||
<partial name="_NeshanMap" model="Model.MapKey" />
|
<partial name="_NeshanMap" model="Model.MapKey" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* Place/clinic structured data — only for a real named facility (not the «نامشخص» placeholder). *@
|
||||||
|
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f))
|
||||||
|
{
|
||||||
|
var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}";
|
||||||
|
@Html.Raw("<script type=\"application/ld+json\">"
|
||||||
|
+ JobsMedical.Web.Services.SeoJsonLd.MedicalOrganization(f, bu, Model.AvgRating, Model.RatingCount)
|
||||||
|
+ "</script>")
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="foot" style="display:flex; justify-content:space-between; align-items:center; border-top:1px solid var(--line); padding-top:12px;">
|
<div class="foot" style="display:flex; justify-content:space-between; align-items:center; border-top:1px solid var(--line); padding-top:12px;">
|
||||||
<span class="pay" style="color:var(--primary-dark); font-weight:800;">
|
<span class="pay" style="color:var(--primary-dark); font-weight:800;">
|
||||||
@JalaliDate.ToPersianDigits(row.OpenShifts.ToString()) شیفت باز
|
@JalaliDate.ToPersianDigits(row.OpenListings.ToString()) آگهی فعال
|
||||||
</span>
|
</span>
|
||||||
<span class="btn btn-outline" style="padding:6px 14px;">مشاهده مرکز</span>
|
<span class="btn btn-outline" style="padding:6px 14px;">مشاهده مرکز</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,21 +10,36 @@ public class IndexModel : PageModel
|
|||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
public IndexModel(AppDbContext db) => _db = db;
|
public IndexModel(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
public record FacilityRow(Facility Facility, int OpenShifts);
|
public record FacilityRow(Facility Facility, int OpenListings);
|
||||||
public List<FacilityRow> Rows { get; private set; } = new();
|
public List<FacilityRow> Rows { get; private set; } = new();
|
||||||
|
|
||||||
|
// The shared placeholder for unnamed aggregated ads is not a real, browseable facility.
|
||||||
|
private const string PlaceholderName = "نامشخص / ثبت نشده";
|
||||||
|
|
||||||
public async Task OnGetAsync()
|
public async Task OnGetAsync()
|
||||||
{
|
{
|
||||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
var facilities = await _db.Facilities.Include(f => f.City).OrderBy(f => f.Name).ToListAsync();
|
var jobCutoff = JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc;
|
||||||
var counts = await _db.Shifts
|
|
||||||
|
var facilities = await _db.Facilities.Include(f => f.City)
|
||||||
|
.Where(f => f.Name != PlaceholderName).ToListAsync();
|
||||||
|
|
||||||
|
// "Active listings" = open shifts + open (fresh) job openings — a facility that is hiring
|
||||||
|
// shouldn't read «۰ شیفت باز» just because it posted a job rather than a dated shift.
|
||||||
|
var shiftCounts = await _db.Shifts
|
||||||
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today)
|
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today)
|
||||||
.GroupBy(s => s.FacilityId)
|
.GroupBy(s => s.FacilityId).Select(g => new { g.Key, C = g.Count() })
|
||||||
.Select(g => new { g.Key, Count = g.Count() })
|
.ToDictionaryAsync(x => x.Key, x => x.C);
|
||||||
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
var jobCounts = await _db.JobOpenings
|
||||||
|
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCutoff)
|
||||||
|
.GroupBy(j => j.FacilityId).Select(g => new { g.Key, C = g.Count() })
|
||||||
|
.ToDictionaryAsync(x => x.Key, x => x.C);
|
||||||
|
|
||||||
Rows = facilities
|
Rows = facilities
|
||||||
.Select(f => new FacilityRow(f, counts.GetValueOrDefault(f.Id)))
|
.Select(f => new FacilityRow(f, shiftCounts.GetValueOrDefault(f.Id) + jobCounts.GetValueOrDefault(f.Id)))
|
||||||
|
.OrderByDescending(r => r.OpenListings) // active facilities first
|
||||||
|
.ThenByDescending(r => r.Facility.IsVerified)
|
||||||
|
.ThenBy(r => r.Facility.Name)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@model IndexModel
|
@model IndexModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = null; // use default site title for the home page (best for SEO)
|
ViewData["Title"] = null; // use default site title for the home page (best for SEO)
|
||||||
ViewData["Description"] = "همکادر؛ سریعترین راه برای کادر درمان (پزشک، پرستار، ماما، تکنسین) جهت یافتن شیفت و موقعیت استخدامی در بیمارستانها و کلینیکهای تهران. بهجای گشتن در کانالهای تلگرام و بله، همه فرصتها یکجا.";
|
ViewData["Description"] = "یافتن شیفت و موقعیت استخدامی کادر درمان (پزشک، پرستار، ماما، تکنسین) در بیمارستانها و کلینیکهای تهران — همهٔ فرصتها یکجا در همکادر.";
|
||||||
}
|
}
|
||||||
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
@@ -14,106 +14,64 @@
|
|||||||
مرکز درمانی، محل و تقویم هفتگی — یکجا.
|
مرکز درمانی، محل و تقویم هفتگی — یکجا.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form class="search-card" method="get" asp-page="/Shifts/Index">
|
<form class="hero-search" method="get" action="/Search" role="search" data-suggest>
|
||||||
<div class="field">
|
<div class="hero-search-pill">
|
||||||
<label>شهر</label>
|
<span class="hs-ico">🔎</span>
|
||||||
<select name="cityId">
|
<input type="search" name="Q" autocomplete="off"
|
||||||
<option value="">همه شهرها</option>
|
placeholder="جستجو: پرستار، mmt، دندانپزشک…" />
|
||||||
@foreach (var c in Model.Cities)
|
<button type="submit" class="btn btn-accent btn-lg hs-submit" aria-label="جستجو">
|
||||||
{
|
<span class="hs-submit-txt">جستجو</span>
|
||||||
<option value="@c.Id">@c.Name</option>
|
<span class="hs-submit-ico" aria-hidden="true">🔎</span>
|
||||||
}
|
</button>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="hero-chips">
|
||||||
<label>نقش</label>
|
<span class="hc-label">جستجوهای پرطرفدار:</span>
|
||||||
<select name="roleId">
|
<a href="/Search?Q=%D9%BE%D8%B1%D8%B3%D8%AA%D8%A7%D8%B1">پرستار</a>
|
||||||
<option value="">همه نقشها</option>
|
<a href="/Search?Q=%D9%BE%D8%B2%D8%B4%DA%A9">پزشک</a>
|
||||||
@foreach (var r in Model.Roles)
|
<a href="/Search?Q=%D8%B4%DB%8C%D9%81%D8%AA%20%D8%B4%D8%A8">شیفت شب</a>
|
||||||
{
|
<a href="/Search?Q=%D8%A2%D9%85%D8%A7%D8%AF%D9%87%20%D8%A8%D9%87%20%DA%A9%D8%A7%D8%B1">آماده به کار</a>
|
||||||
<option value="@r.Id">@r.Name</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>نوع شیفت</label>
|
|
||||||
<select name="shiftType">
|
|
||||||
<option value="">همه</option>
|
|
||||||
<option value="0">صبح</option>
|
|
||||||
<option value="1">عصر</option>
|
|
||||||
<option value="2">شب</option>
|
|
||||||
<option value="3">آنکال</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label> </label>
|
|
||||||
<button type="submit" class="btn btn-accent btn-block btn-lg">جستجوی فرصتها</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="stat-pills">
|
<div class="stat-pills">
|
||||||
<div class="stat-pill"><span class="n">@JalaliDate.ToPersianDigits(Model.OpenShiftCount.ToString())</span><span class="l">شیفت باز</span></div>
|
<div class="stat-pill"><span class="n">@JalaliDate.ToPersianDigits(Model.OpenJobCount.ToString())</span><span class="l">موقعیت استخدام</span></div>
|
||||||
<div class="stat-pill"><span class="n">@JalaliDate.ToPersianDigits(Model.FacilityCount.ToString())</span><span class="l">مرکز درمانی</span></div>
|
<div class="stat-pill"><span class="n">@JalaliDate.ToPersianDigits(Model.FacilityCount.ToString())</span><span class="l">مرکز درمانی</span></div>
|
||||||
<div class="stat-pill"><span class="n">@JalaliDate.ToPersianDigits(Model.CityCount.ToString())</span><span class="l">شهر فعال</span></div>
|
<div class="stat-pill"><span class="n">@JalaliDate.ToPersianDigits(Model.CityCount.ToString())</span><span class="l">شهر فعال</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (Model.Recommendations.Count > 0)
|
<section class="section" style="padding-bottom:0;">
|
||||||
{
|
|
||||||
<section class="section" style="padding-bottom:0;">
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@if (Model.HasPersonalization)
|
<a asp-page="/Recommendations/Index" class="rec-banner" style="text-decoration:none; color:#fff;">
|
||||||
{
|
|
||||||
<div class="rec-banner">
|
|
||||||
<div>
|
<div>
|
||||||
<h2 style="margin:0 0 4px;">✨ پیشنهادهای ویژه شما</h2>
|
<h2 style="margin:0 0 4px;">✨ پیشنهادهای ویژه شما</h2>
|
||||||
<span style="opacity:.9; font-size:14px;">بر اساس علاقهمندیها و فعالیت شما انتخاب شدهاند</span>
|
<span style="opacity:.9; font-size:14px;">فرصتهای متناسب با نقش، شهر و فعالیت شما — همه یکجا</span>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-outline" asp-page="/Preferences/Index">ویرایش علاقهمندیها</a>
|
<span class="btn btn-outline">مشاهده پیشنهادها ←</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
</section>
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="rec-banner">
|
|
||||||
<div>
|
|
||||||
<h2 style="margin:0 0 4px;">پیشنهادها را شخصیسازی کن</h2>
|
|
||||||
<span style="opacity:.9; font-size:14px;">نقش، شهر و نوع شیفت دلخواهت را بگو تا بهترین فرصتها را برایت پیدا کنیم</span>
|
|
||||||
</div>
|
|
||||||
<a class="btn btn-outline" asp-page="/Preferences/Index">تنظیم علاقهمندیها</a>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="grid grid-3">
|
|
||||||
@foreach (var rec in Model.Recommendations)
|
|
||||||
{
|
|
||||||
<partial name="_RecommendationCard" model="rec" />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
<section class="section">
|
@* Shifts are rare for aggregated content (most ads are ongoing hiring, not dated shifts) — only
|
||||||
|
show the section when there are real open shifts, so we never display a fabricated/empty date. *@
|
||||||
|
@if (Model.LatestShifts.Count > 0)
|
||||||
|
{
|
||||||
|
<section class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>جدیدترین شیفتها</h2>
|
<h2>جدیدترین شیفتها</h2>
|
||||||
<a asp-page="/Shifts/Index">مشاهده همه ←</a>
|
<a href="/Shifts">مشاهده همه ←</a>
|
||||||
</div>
|
</div>
|
||||||
@if (Model.LatestShifts.Count == 0)
|
|
||||||
{
|
|
||||||
<div class="empty-state">فعلاً شیفت بازی ثبت نشده است.</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="grid grid-3">
|
<div class="grid grid-3">
|
||||||
@foreach (var s in Model.LatestShifts)
|
@foreach (var s in Model.LatestShifts)
|
||||||
{
|
{
|
||||||
<partial name="_ShiftCard" model="s" />
|
<partial name="_ShiftCard" model="s" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
@if (Model.LatestJobs.Count > 0)
|
@if (Model.LatestJobs.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -121,7 +79,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>فرصتهای استخدامی</h2>
|
<h2>فرصتهای استخدامی</h2>
|
||||||
<a asp-page="/Jobs/Index">مشاهده همه ←</a>
|
<a href="/Jobs">مشاهده همه ←</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-3">
|
<div class="grid grid-3">
|
||||||
@foreach (var j in Model.LatestJobs)
|
@foreach (var j in Model.LatestJobs)
|
||||||
|
|||||||
@@ -9,24 +9,19 @@ namespace JobsMedical.Web.Pages;
|
|||||||
public class IndexModel : PageModel
|
public class IndexModel : PageModel
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly RecommendationService _recs;
|
|
||||||
private readonly InterestService _interest;
|
|
||||||
|
|
||||||
public IndexModel(AppDbContext db, RecommendationService recs, InterestService interest)
|
public IndexModel(AppDbContext db)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_recs = recs;
|
|
||||||
_interest = interest;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Recommendation> Recommendations { get; private set; } = new();
|
|
||||||
public bool HasPersonalization { get; private set; }
|
|
||||||
public List<Shift> LatestShifts { get; private set; } = new();
|
public List<Shift> LatestShifts { get; private set; } = new();
|
||||||
public List<JobOpening> LatestJobs { get; private set; } = new();
|
public List<JobOpening> LatestJobs { get; private set; } = new();
|
||||||
public List<TalentListing> LatestTalent { get; private set; } = new();
|
public List<TalentListing> LatestTalent { get; private set; } = new();
|
||||||
public List<City> Cities { get; private set; } = new();
|
public List<City> Cities { get; private set; } = new();
|
||||||
public List<Role> Roles { get; private set; } = new();
|
public List<Role> Roles { get; private set; } = new();
|
||||||
public int OpenShiftCount { get; private set; }
|
public int OpenShiftCount { get; private set; }
|
||||||
|
public int OpenJobCount { get; private set; }
|
||||||
public int FacilityCount { get; private set; }
|
public int FacilityCount { get; private set; }
|
||||||
public int CityCount { get; private set; }
|
public int CityCount { get; private set; }
|
||||||
|
|
||||||
@@ -34,11 +29,6 @@ public class IndexModel : PageModel
|
|||||||
{
|
{
|
||||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
|
||||||
Recommendations = await _recs.GetForVisitorAsync(6);
|
|
||||||
// "Personalized" = we actually used a signal (prefs or behavior), not just cold-start freshness.
|
|
||||||
HasPersonalization = (await _interest.GetPreferencesAsync())?.HasAny == true
|
|
||||||
|| (await _interest.RecentEventsAsync(1)).Count > 0;
|
|
||||||
|
|
||||||
LatestShifts = await _db.Shifts
|
LatestShifts = await _db.Shifts
|
||||||
.Include(s => s.Facility).ThenInclude(f => f.City)
|
.Include(s => s.Facility).ThenInclude(f => f.City)
|
||||||
.Include(s => s.Role)
|
.Include(s => s.Role)
|
||||||
@@ -62,12 +52,14 @@ public class IndexModel : PageModel
|
|||||||
.Where(t => t.Status == ShiftStatus.Open
|
.Where(t => t.Status == ShiftStatus.Open
|
||||||
&& t.CreatedAt >= JobsMedical.Web.Services.Scraping.ListingPolicy.TalentCutoffUtc)
|
&& t.CreatedAt >= JobsMedical.Web.Services.Scraping.ListingPolicy.TalentCutoffUtc)
|
||||||
.OrderByDescending(t => t.CreatedAt)
|
.OrderByDescending(t => t.CreatedAt)
|
||||||
.Take(3)
|
.Take(6) // two rows of the grid-3 «آماده به کار» section
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
|
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
|
||||||
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
|
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
|
||||||
OpenShiftCount = await _db.Shifts.CountAsync(s => s.Status == ShiftStatus.Open && s.Date >= today);
|
OpenShiftCount = await _db.Shifts.CountAsync(s => s.Status == ShiftStatus.Open && s.Date >= today);
|
||||||
|
OpenJobCount = await _db.JobOpenings.CountAsync(j => j.Status == ShiftStatus.Open
|
||||||
|
&& j.CreatedAt >= JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc);
|
||||||
FacilityCount = await _db.Facilities.CountAsync();
|
FacilityCount = await _db.Facilities.CountAsync();
|
||||||
CityCount = await _db.Cities.CountAsync(c => c.IsActive);
|
CityCount = await _db.Cities.CountAsync(c => c.IsActive);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,16 @@
|
|||||||
@{
|
@{
|
||||||
var j = Model.Job!;
|
var j = Model.Job!;
|
||||||
var f = j.Facility!;
|
var f = j.Facility!;
|
||||||
|
var hasFac = JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f); // false for the «نامشخص» placeholder
|
||||||
|
var jobContacts = (j.Contacts ?? new List<JobsMedical.Web.Models.ContactMethod>()).ToList();
|
||||||
|
// Map: listing's own approx coords (aggregated) then facility's; aggregated = approximate area.
|
||||||
|
var mapLat = j.Lat ?? f.Lat;
|
||||||
|
var mapLng = j.Lng ?? f.Lng;
|
||||||
|
var mapApprox = j.Source == JobsMedical.Web.Models.ShiftSource.Aggregated;
|
||||||
ViewData["Title"] = j.Title;
|
ViewData["Title"] = j.Title;
|
||||||
ViewData["Description"] = $"{j.Title} در {f.Name}، {f.City?.Name}. موقعیت استخدامی برای {j.Role?.Name}.";
|
ViewData["Description"] = hasFac
|
||||||
|
? $"{j.Title} در {f.Name}، {f.City?.Name}. موقعیت استخدامی برای {j.Role?.Name}."
|
||||||
|
: $"{j.Title} در {f.City?.Name}. موقعیت استخدامی برای {j.Role?.Name}.";
|
||||||
// Don't let Google index filled/expired openings (avoids dead "Job for jobs" results).
|
// Don't let Google index filled/expired openings (avoids dead "Job for jobs" results).
|
||||||
if (j.Status != JobsMedical.Web.Models.ShiftStatus.Open) ViewData["NoIndex"] = true;
|
if (j.Status != JobsMedical.Web.Models.ShiftStatus.Open) ViewData["NoIndex"] = true;
|
||||||
string empLabel = j.EmploymentType switch
|
string empLabel = j.EmploymentType switch
|
||||||
@@ -17,18 +25,23 @@
|
|||||||
string salary;
|
string salary;
|
||||||
if (j.SalaryMin is null && j.SalaryMax is null) salary = "توافقی";
|
if (j.SalaryMin is null && j.SalaryMax is null) salary = "توافقی";
|
||||||
else if (j.SalaryMin == j.SalaryMax) salary = JalaliDate.Toman(j.SalaryMin) + " ماهانه";
|
else if (j.SalaryMin == j.SalaryMax) salary = JalaliDate.Toman(j.SalaryMin) + " ماهانه";
|
||||||
|
else if (j.SalaryMax is null) salary = "از " + JalaliDate.Toman(j.SalaryMin) + " ماهانه"; // min only — avoid «تا توافقی»
|
||||||
else salary = $"از {JalaliDate.ToPersianDigits((j.SalaryMin ?? 0).ToString("#,0"))} تا {JalaliDate.Toman(j.SalaryMax)} ماهانه";
|
else salary = $"از {JalaliDate.ToPersianDigits((j.SalaryMin ?? 0).ToString("#,0"))} تا {JalaliDate.Toman(j.SalaryMax)} ماهانه";
|
||||||
|
var crumbs = new List<JobsMedical.Web.Services.Crumb> { new("خانه", "/"), new("استخدام", "/Jobs") };
|
||||||
|
if (j.Role is not null) crumbs.Add(new(j.Role.Name, "/استخدام/" + JobsMedical.Web.Services.SeoSlug.Of(j.Role.Name)));
|
||||||
|
crumbs.Add(new(j.Title, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<partial name="_Breadcrumbs" model="crumbs" />
|
||||||
<div class="row" style="display:flex; gap:10px; align-items:center;">
|
<div class="row" style="display:flex; gap:10px; align-items:center;">
|
||||||
<span class="badge badge-job">@empLabel</span>
|
<span class="badge badge-job">@empLabel</span>
|
||||||
@if (j.Role is not null) { <span class="badge badge-type">@j.Role.Name</span> }
|
@if (j.Role is not null) { <span class="badge badge-type">@j.Role.Name</span> }
|
||||||
@if (f.IsVerified) { <span class="badge badge-verified">✓ مرکز تأیید شده</span> }
|
@if (f.IsVerified) { <span class="badge badge-verified">✓ مرکز تأیید شده</span> }
|
||||||
</div>
|
</div>
|
||||||
<h1 style="margin-top:8px;">@j.Title</h1>
|
<h1 style="margin-top:8px;">@j.Title</h1>
|
||||||
<p class="muted">🏥 @f.Name — 📍 @f.City?.Name@(f.District is not null ? "، " + f.District.Name : "")</p>
|
<p class="muted">@(hasFac ? "🏥 " + f.Name + " — " : "")📍 @f.City?.Name@(f.District is not null ? "، " + f.District.Name : "")</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -38,11 +51,18 @@
|
|||||||
@if (Model.ShowContact)
|
@if (Model.ShowContact)
|
||||||
{
|
{
|
||||||
<div class="contact-reveal" style="margin-bottom:16px;">
|
<div class="contact-reveal" style="margin-bottom:16px;">
|
||||||
<h4>✓ راههای ارتباطی مرکز</h4>
|
<h4>✓ راههای ارتباطی</h4>
|
||||||
|
@if (jobContacts.Count > 0)
|
||||||
|
{
|
||||||
|
@* Numbers from THIS ad (aggregated) — the correct, per-listing contacts. *@
|
||||||
|
<partial name="_ContactList" model="jobContacts" />
|
||||||
|
}
|
||||||
|
else if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f) && (!string.IsNullOrEmpty(f.Phone) || !string.IsNullOrEmpty(f.BaleId)))
|
||||||
|
{
|
||||||
@if (!string.IsNullOrEmpty(f.Phone))
|
@if (!string.IsNullOrEmpty(f.Phone))
|
||||||
{
|
{
|
||||||
<div class="contact-row">
|
<div class="contact-row">
|
||||||
<span class="c-meta"><span class="c-type">📞 تلفن</span><span class="c-val" dir="ltr">@f.Phone</span></span>
|
<span class="c-meta"><span class="c-type">📞 تلفن مرکز</span><span class="c-val" dir="ltr">@f.Phone</span></span>
|
||||||
<a class="btn btn-accent" href="tel:@f.Phone">تماس</a>
|
<a class="btn btn-accent" href="tel:@f.Phone">تماس</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -53,9 +73,10 @@
|
|||||||
<a class="btn btn-outline" href="https://ble.ir/@f.BaleId" target="_blank" rel="noopener">باز کردن</a>
|
<a class="btn btn-outline" href="https://ble.ir/@f.BaleId" target="_blank" rel="noopener">باز کردن</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (string.IsNullOrEmpty(f.Phone) && string.IsNullOrEmpty(f.BaleId))
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
<p class="muted" style="margin:0;">شمارهای برای این مرکز ثبت نشده است.</p>
|
<p class="muted" style="margin:0;">شمارهای ثبت نشده است.</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -96,19 +117,14 @@
|
|||||||
<div class="pay" style="font-size:19px; margin-bottom:6px; color:var(--primary-dark); font-weight:800;">@salary</div>
|
<div class="pay" style="font-size:19px; margin-bottom:6px; color:var(--primary-dark); font-weight:800;">@salary</div>
|
||||||
<p class="muted" style="font-size:13px; margin-top:0;">@empLabel</p>
|
<p class="muted" style="font-size:13px; margin-top:0;">@empLabel</p>
|
||||||
<div class="aside-apply">
|
<div class="aside-apply">
|
||||||
<form method="post">
|
<button type="button" class="btn btn-accent btn-block btn-lg contact-trigger"
|
||||||
<button type="submit" asp-page-handler="Interest" asp-route-id="@j.Id"
|
data-contact-type="job" data-contact-id="@j.Id">📞 اعلام تمایل و مشاهده راه ارتباطی</button>
|
||||||
class="btn btn-accent btn-block btn-lg">اعلام تمایل و مشاهده راه ارتباطی</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex; gap:8px; margin-top:8px;">
|
|
||||||
<form method="post" style="flex:1;">
|
|
||||||
<button type="submit" asp-page-handler="Save" asp-route-id="@j.Id" class="btn btn-outline btn-block">♡ ذخیره</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" style="flex:1;">
|
|
||||||
<button type="submit" asp-page-handler="Dismiss" asp-route-id="@j.Id" class="btn btn-outline btn-block">✕ علاقهمند نیستم</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-block like-trigger" style="margin-top:8px;"
|
||||||
|
data-like-type="job" data-like-id="@j.Id" data-liked="@(Model.IsLiked ? "true" : "false")">
|
||||||
|
<span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> پسندیدم
|
||||||
|
<span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
|
||||||
|
</button>
|
||||||
@if (Model.Reported)
|
@if (Model.Reported)
|
||||||
{
|
{
|
||||||
<p class="muted" style="font-size:12px; margin:8px 0 0;">✓ گزارش شما ثبت شد. متشکریم.</p>
|
<p class="muted" style="font-size:12px; margin:8px 0 0;">✓ گزارش شما ثبت شد. متشکریم.</p>
|
||||||
@@ -129,7 +145,7 @@
|
|||||||
@if (j.Facility is not null)
|
@if (j.Facility is not null)
|
||||||
{
|
{
|
||||||
<details style="margin-top:6px;">
|
<details style="margin-top:6px;">
|
||||||
<summary class="muted" style="font-size:12px; cursor:pointer;">شکایت از این مرکز (@j.Facility.Name)</summary>
|
<summary class="muted" style="font-size:12px; cursor:pointer;">شکایت از این @(hasFac ? "مرکز (" + j.Facility.Name + ")" : "آگهی")</summary>
|
||||||
<form method="post" action="/report" style="margin-top:8px;">
|
<form method="post" action="/report" style="margin-top:8px;">
|
||||||
<input type="hidden" name="targetType" value="Facility" />
|
<input type="hidden" name="targetType" value="Facility" />
|
||||||
<input type="hidden" name="targetId" value="@j.Facility.Id" />
|
<input type="hidden" name="targetId" value="@j.Facility.Id" />
|
||||||
@@ -143,15 +159,15 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (j.Facility?.Lat is not null && j.Facility?.Lng is not null)
|
|
||||||
{
|
|
||||||
var latS = j.Facility.Lat.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
|
||||||
var lngS = j.Facility.Lng.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
|
||||||
<div class="card card-pad" style="margin-top:16px;">
|
<div class="card card-pad" style="margin-top:16px;">
|
||||||
<h3 style="margin-top:0;">موقعیت مکانی</h3>
|
<h3 style="margin-top:0;">موقعیت مکانی</h3>
|
||||||
|
@if (mapLat is not null && mapLng is not null)
|
||||||
|
{
|
||||||
|
var latS = mapLat.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
var lngS = mapLng.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
@if (!string.IsNullOrEmpty(Model.MapKey))
|
@if (!string.IsNullOrEmpty(Model.MapKey))
|
||||||
{
|
{
|
||||||
<div id="facmap" data-lat="@latS" data-lng="@lngS" style="height:200px; border-radius:10px; overflow:hidden; border:1px solid var(--line);"></div>
|
<div id="facmap" data-lat="@latS" data-lng="@lngS" data-approx="@(mapApprox ? "true" : "false")" style="height:200px; border-radius:10px; overflow:hidden; border:1px solid var(--line);"></div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -159,44 +175,44 @@
|
|||||||
🗺️<br /><small class="muted" dir="ltr">@latS، @lngS</small>
|
🗺️<br /><small class="muted" dir="ltr">@latS، @lngS</small>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (mapApprox)
|
||||||
|
{
|
||||||
|
<p class="muted" style="font-size:12px; margin:8px 0 0;">📍 محدودهٔ تقریبی (برگرفته از آگهی منبع)؛ موقعیت دقیق نیست.</p>
|
||||||
|
}
|
||||||
<a class="btn btn-outline btn-block" style="margin-top:8px;" target="_blank" rel="noopener"
|
<a class="btn btn-outline btn-block" style="margin-top:8px;" target="_blank" rel="noopener"
|
||||||
href="https://neshan.org/maps/@(latS),@(lngS),16z">مسیریابی در نشان</a>
|
href="https://neshan.org/maps/@(latS),@(lngS),16z">مسیریابی در نشان</a>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="muted" style="margin:0;">مختصات این آگهی ثبت نشده است.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* Sticky bottom action bar — mobile only. *@
|
@* Sticky bottom action bar — mobile only. *@
|
||||||
<div class="mobile-action-bar">
|
<div class="mobile-action-bar">
|
||||||
@if (Model.ShowContact)
|
<button type="button" class="btn btn-accent btn-lg cta-main contact-trigger"
|
||||||
{
|
data-contact-type="job" data-contact-id="@j.Id">📞 اعلام تمایل و مشاهده تماس</button>
|
||||||
@if (!string.IsNullOrEmpty(f.Phone))
|
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-lg like-trigger" aria-label="پسندیدن"
|
||||||
{
|
data-like-type="job" data-like-id="@j.Id" data-liked="@(Model.IsLiked ? "true" : "false")">
|
||||||
<a class="btn btn-accent btn-lg cta-main" href="tel:@f.Phone">📞 تماس با مرکز</a>
|
<span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> <span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
|
||||||
}
|
</button>
|
||||||
else
|
|
||||||
{
|
|
||||||
<span class="cta-main center muted" style="align-self:center;">اطلاعات تماس در بالای صفحه</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<form method="post" class="cta-main">
|
|
||||||
<button type="submit" asp-page-handler="Interest" asp-route-id="@j.Id" class="btn btn-accent btn-block btn-lg">اعلام تمایل و مشاهده تماس</button>
|
|
||||||
</form>
|
|
||||||
<form method="post">
|
|
||||||
<button type="submit" asp-page-handler="Save" asp-route-id="@j.Id" class="btn btn-outline btn-lg" aria-label="ذخیره" title="ذخیره">♡</button>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(Model.MapKey) && Model.Job?.Facility?.Lat is not null)
|
@if (!string.IsNullOrEmpty(Model.MapKey) && mapLat is not null)
|
||||||
{
|
{
|
||||||
<partial name="_NeshanMap" model="Model.MapKey" />
|
<partial name="_NeshanMap" model="Model.MapKey" />
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Head {
|
@section Head {
|
||||||
|
@* Only emit JobPosting structured data for a real named employer — Google for Jobs rejects a
|
||||||
|
placeholder/empty hiringOrganization (most aggregated ads have no named center). *@
|
||||||
@{ var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
|
@{ var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
|
||||||
|
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.Breadcrumb(crumbs, bu) + "</script>")
|
||||||
|
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f))
|
||||||
|
{
|
||||||
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.JobPosting(j, bu) + "</script>")
|
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.JobPosting(j, bu) + "</script>")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public class DetailsModel : PageModel
|
|||||||
|
|
||||||
public JobOpening? Job { get; private set; }
|
public JobOpening? Job { get; private set; }
|
||||||
public string? MapKey { get; private set; }
|
public string? MapKey { get; private set; }
|
||||||
|
public int LikeCount { get; private set; }
|
||||||
|
public bool IsLiked { get; private set; }
|
||||||
public bool ShowContact { get; private set; }
|
public bool ShowContact { get; private set; }
|
||||||
public bool Saved { get; private set; }
|
public bool Saved { get; private set; }
|
||||||
public bool Reported { get; private set; }
|
public bool Reported { get; private set; }
|
||||||
@@ -31,7 +33,13 @@ public class DetailsModel : PageModel
|
|||||||
{
|
{
|
||||||
await LoadAsync(id);
|
await LoadAsync(id);
|
||||||
if (Job is null) return NotFound();
|
if (Job is null) return NotFound();
|
||||||
|
// Intentionally removed (admin-archived out-of-scope/duplicate ad): 410 Gone is the standard
|
||||||
|
// signal for permanent removal, so search engines deindex it cleanly (we keep the row for audit).
|
||||||
|
if (Job.Status == ShiftStatus.Archived) return StatusCode(StatusCodes.Status410Gone);
|
||||||
MapKey = (await _settings.GetAsync()).NeshanMapKey;
|
MapKey = (await _settings.GetAsync()).NeshanMapKey;
|
||||||
|
LikeCount = await _db.Likes.CountAsync(l => l.TargetType == LikeTargetType.Job && l.TargetId == id);
|
||||||
|
var meId = int.TryParse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var n) ? n : (int?)null;
|
||||||
|
IsLiked = meId is int u && await _db.Likes.AnyAsync(l => l.UserId == u && l.TargetType == LikeTargetType.Job && l.TargetId == id);
|
||||||
Reported = Request.Query["reported"] == "1";
|
Reported = Request.Query["reported"] == "1";
|
||||||
await _interest.LogJobAsync(InterestEventType.View, id);
|
await _interest.LogJobAsync(InterestEventType.View, id);
|
||||||
return Page();
|
return Page();
|
||||||
@@ -67,6 +75,7 @@ public class DetailsModel : PageModel
|
|||||||
.Include(j => j.Facility).ThenInclude(f => f.City)
|
.Include(j => j.Facility).ThenInclude(f => f.City)
|
||||||
.Include(j => j.Facility).ThenInclude(f => f.District)
|
.Include(j => j.Facility).ThenInclude(f => f.District)
|
||||||
.Include(j => j.Role)
|
.Include(j => j.Role)
|
||||||
|
.Include(j => j.Contacts)
|
||||||
.FirstOrDefaultAsync(j => j.Id == id);
|
.FirstOrDefaultAsync(j => j.Id == id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,40 @@
|
|||||||
@page
|
@page
|
||||||
@model JobsMedical.Web.Pages.Jobs.IndexModel
|
@model JobsMedical.Web.Pages.Jobs.IndexModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "موقعیتهای استخدامی";
|
// Title/description are set in the page model (SetSeo) from the active role/city.
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>موقعیتهای استخدامی</h1>
|
<partial name="_Breadcrumbs" model="Model.Breadcrumbs" />
|
||||||
|
<h1>@Model.PageHeading</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) موقعیت شغلی پیدا شد
|
@JalaliDate.ToPersianDigits(Model.TotalCount.ToString()) موقعیت شغلی پیدا شد
|
||||||
@if (Model.NearMeActive)
|
@if (Model.NearMeActive)
|
||||||
{
|
{
|
||||||
<span> — مرتبشده بر اساس نزدیکترین به شما 📍</span>
|
<span> — مرتبشده بر اساس نزدیکترین به شما 📍</span>
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
|
@if (!string.IsNullOrEmpty(Model.PageIntro))
|
||||||
|
{
|
||||||
|
<p class="muted" style="max-width:720px; font-size:13.5px; line-height:1.9;">@Model.PageIntro</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container section">
|
<div class="container section">
|
||||||
|
@if (Model.Roles.Count > 0)
|
||||||
|
{
|
||||||
|
@* Internal links to the SEO landing pages (/استخدام/{نقش}) — and since this page IS the
|
||||||
|
landing page, every landing page cross-links to all the others. *@
|
||||||
|
<div class="role-links">
|
||||||
|
<span class="rl-label">استخدام بر اساس نقش:</span>
|
||||||
|
@foreach (var r in Model.Roles.Take(14))
|
||||||
|
{
|
||||||
|
<a class="rl-chip" href="/استخدام/@JobsMedical.Web.Services.SeoSlug.Of(r.Name)">@r.Name</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="layout-2">
|
<div class="layout-2">
|
||||||
<aside class="card card-pad filter-card">
|
<aside class="card card-pad filter-card">
|
||||||
<h3>فیلترها</h3>
|
<h3>فیلترها</h3>
|
||||||
@@ -100,6 +117,7 @@
|
|||||||
<partial name="_JobCard" model="j" />
|
<partial name="_JobCard" model="j" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<partial name="_Pager" model="(Model.CurrentPage, Model.TotalPages)" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,3 +142,12 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@section Head {
|
||||||
|
@{ var bcUrl = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
|
||||||
|
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.Breadcrumb(Model.Breadcrumbs, bcUrl) + "</script>")
|
||||||
|
@if (Model.Results.Count > 0)
|
||||||
|
{
|
||||||
|
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.ItemList(Model.Results.Select(j => "/Jobs/Details/" + j.Id), bcUrl) + "</script>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ public class IndexModel : PageModel
|
|||||||
[BindProperty(SupportsGet = true)] public double? Lat { get; set; }
|
[BindProperty(SupportsGet = true)] public double? Lat { get; set; }
|
||||||
[BindProperty(SupportsGet = true)] public double? Lng { get; set; }
|
[BindProperty(SupportsGet = true)] public double? Lng { get; set; }
|
||||||
|
|
||||||
|
// Pretty-URL segments (/استخدام/{roleSlug}/{citySlug?}); resolved to RoleId/CityId below.
|
||||||
|
[BindProperty(SupportsGet = true)] public string? RoleSlug { get; set; }
|
||||||
|
[BindProperty(SupportsGet = true)] public string? CitySlug { get; set; }
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true)] public int Page { get; set; } = 1;
|
||||||
|
private const int PageSize = 24;
|
||||||
|
public int TotalCount { get; private set; }
|
||||||
|
public int TotalPages { get; private set; }
|
||||||
|
public int CurrentPage { get; private set; }
|
||||||
|
|
||||||
public bool NearMeActive => Lat is not null && Lng is not null;
|
public bool NearMeActive => Lat is not null && Lng is not null;
|
||||||
|
|
||||||
public List<JobOpening> Results { get; private set; } = new();
|
public List<JobOpening> Results { get; private set; } = new();
|
||||||
@@ -27,10 +37,35 @@ public class IndexModel : PageModel
|
|||||||
public List<District> Districts { get; private set; } = new();
|
public List<District> Districts { get; private set; } = new();
|
||||||
public List<Role> Roles { get; private set; } = new();
|
public List<Role> Roles { get; private set; } = new();
|
||||||
|
|
||||||
public async Task OnGetAsync()
|
/// <summary>Dynamic page heading/H1 + title, set from the active role/city for SEO.</summary>
|
||||||
|
public string PageHeading { get; private set; } = "موقعیتهای استخدامی";
|
||||||
|
|
||||||
|
/// <summary>A short unique intro shown on role/city landing pages (avoids thin-content).</summary>
|
||||||
|
public string? PageIntro { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Breadcrumb trail (also emitted as BreadcrumbList JSON-LD).</summary>
|
||||||
|
public IReadOnlyList<Crumb> Breadcrumbs { get; private set; } = Array.Empty<Crumb>();
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync()
|
||||||
{
|
{
|
||||||
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
|
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
|
||||||
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
|
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
|
||||||
|
|
||||||
|
// Pretty-URL landing: resolve slugs → filters. A slug matching nothing is a 404 (don't
|
||||||
|
// render a thin page under a junk URL).
|
||||||
|
if (!string.IsNullOrWhiteSpace(RoleSlug))
|
||||||
|
{
|
||||||
|
var role = Roles.FirstOrDefault(r => SeoSlug.Matches(r.Name, RoleSlug));
|
||||||
|
if (role is null) return NotFound();
|
||||||
|
RoleId = role.Id;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(CitySlug))
|
||||||
|
{
|
||||||
|
var city = Cities.FirstOrDefault(c => SeoSlug.Matches(c.Name, CitySlug));
|
||||||
|
if (city is null) return NotFound();
|
||||||
|
CityId = city.Id;
|
||||||
|
}
|
||||||
|
|
||||||
Districts = await _db.Districts
|
Districts = await _db.Districts
|
||||||
.Where(d => d.IsActive && (CityId == null || d.CityId == CityId))
|
.Where(d => d.IsActive && (CityId == null || d.CityId == CityId))
|
||||||
.OrderBy(d => d.Name).ToListAsync();
|
.OrderBy(d => d.Name).ToListAsync();
|
||||||
@@ -49,19 +84,50 @@ public class IndexModel : PageModel
|
|||||||
if (GenderFilter is Gender g && g != Gender.Any)
|
if (GenderFilter is Gender g && g != Gender.Any)
|
||||||
q = q.Where(j => j.GenderRequirement == Gender.Any || j.GenderRequirement == g);
|
q = q.Where(j => j.GenderRequirement == Gender.Any || j.GenderRequirement == g);
|
||||||
|
|
||||||
var results = await q.ToListAsync();
|
TotalCount = await q.CountAsync();
|
||||||
|
TotalPages = Math.Max(1, (int)Math.Ceiling(TotalCount / (double)PageSize));
|
||||||
|
CurrentPage = Math.Clamp(Page, 1, TotalPages);
|
||||||
|
var skip = (CurrentPage - 1) * PageSize;
|
||||||
|
|
||||||
if (NearMeActive)
|
if (NearMeActive)
|
||||||
{
|
{
|
||||||
foreach (var j in results)
|
// Distance sort needs all rows in memory; paginate after sorting.
|
||||||
|
var all = await q.ToListAsync();
|
||||||
|
foreach (var j in all)
|
||||||
if (j.Facility.Lat is double flat && j.Facility.Lng is double flng)
|
if (j.Facility.Lat is double flat && j.Facility.Lng is double flng)
|
||||||
j.DistanceKm = Geo.DistanceKm(Lat!.Value, Lng!.Value, flat, flng);
|
j.DistanceKm = Geo.DistanceKm(Lat!.Value, Lng!.Value, flat, flng);
|
||||||
Results = results.OrderBy(j => j.DistanceKm ?? double.MaxValue)
|
Results = all.OrderBy(j => j.DistanceKm ?? double.MaxValue)
|
||||||
.ThenByDescending(j => j.CreatedAt).ToList();
|
.ThenByDescending(j => j.CreatedAt).Skip(skip).Take(PageSize).ToList();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Results = results.OrderByDescending(j => j.CreatedAt).ToList();
|
Results = await q.OrderByDescending(j => j.CreatedAt).Skip(skip).Take(PageSize).ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SetSeo(Roles.FirstOrDefault(r => r.Id == RoleId)?.Name, Cities.FirstOrDefault(c => c.Id == CityId)?.Name);
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Title/H1/meta from the active role+city so the page targets «استخدام [نقش] [شهر]».</summary>
|
||||||
|
private void SetSeo(string? role, string? city)
|
||||||
|
{
|
||||||
|
PageHeading =
|
||||||
|
role is not null && city is not null ? $"استخدام {role} در {city}"
|
||||||
|
: role is not null ? $"استخدام {role}"
|
||||||
|
: city is not null ? $"استخدام کادر درمان در {city}"
|
||||||
|
: "موقعیتهای استخدامی";
|
||||||
|
ViewData["Title"] = PageHeading;
|
||||||
|
ViewData["Description"] = role is not null || city is not null
|
||||||
|
? $"جدیدترین آگهیهای {PageHeading} در همکادر؛ مشاهده فرصتها و تماس مستقیم با مراکز درمانی."
|
||||||
|
: "موقعیتهای استخدامی کادر درمان (پزشک، پرستار، ماما، تکنسین) در بیمارستانها و کلینیکهای تهران — همکادر.";
|
||||||
|
if (role is not null || city is not null)
|
||||||
|
PageIntro = $"در این صفحه جدیدترین فرصتهای {PageHeading}، گردآوریشده از منابع معتبر، را میبینید. "
|
||||||
|
+ "روی هر آگهی بزنید تا جزئیات، شرایط و راهِ تماسِ مستقیم با مرکز درمانی نمایش داده شود. "
|
||||||
|
+ "برای فرصتهای مرتبط، نقش یا شهر دیگری را از لینکهای بالا انتخاب کنید.";
|
||||||
|
|
||||||
|
var crumbs = new List<Crumb> { new("خانه", "/"), new("استخدام", "/Jobs") };
|
||||||
|
if (role is not null) crumbs.Add(new(role, "/استخدام/" + SeoSlug.Of(role)));
|
||||||
|
if (city is not null) crumbs.Add(new(city, null));
|
||||||
|
Breadcrumbs = crumbs;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
@page
|
||||||
|
@model JobsMedical.Web.Pages.Me.LikedModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "پسندیدهها";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="container">
|
||||||
|
<h1>❤️ پسندیدهها</h1>
|
||||||
|
<p class="muted">فرصتهایی که پسندیدهای — برای حذف، دوباره روی دکمهٔ ♥ بزن.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container section">
|
||||||
|
@if (Model.Total == 0)
|
||||||
|
{
|
||||||
|
<div class="card empty-state">
|
||||||
|
هنوز چیزی نپسندیدهای. در <a asp-page="/Jobs/Index">استخدام</a>،
|
||||||
|
<a asp-page="/Shifts/Index">شیفتها</a> و <a asp-page="/Talent/Index">آمادهبهکار</a>
|
||||||
|
فرصتها را ببین و آنهایی که دوست داری را با دکمهٔ ♥ پسند کن.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Model.Jobs.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="section-head"><h2>موقعیتهای استخدامی (@JalaliDate.ToPersianDigits(Model.Jobs.Count.ToString()))</h2></div>
|
||||||
|
<div class="grid grid-3">
|
||||||
|
@foreach (var j in Model.Jobs) { <partial name="_JobCard" model="j" /> }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Model.Shifts.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="section-head" style="margin-top:18px;"><h2>شیفتها (@JalaliDate.ToPersianDigits(Model.Shifts.Count.ToString()))</h2></div>
|
||||||
|
<div class="grid grid-3">
|
||||||
|
@foreach (var s in Model.Shifts) { <partial name="_ShiftCard" model="s" /> }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Model.Talent.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="section-head" style="margin-top:18px;"><h2>آماده به کار (@JalaliDate.ToPersianDigits(Model.Talent.Count.ToString()))</h2></div>
|
||||||
|
<div class="grid grid-3">
|
||||||
|
@foreach (var t in Model.Talent) { <partial name="_TalentCard" model="t" /> }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using JobsMedical.Web.Data;
|
||||||
|
using JobsMedical.Web.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Pages.Me;
|
||||||
|
|
||||||
|
/// <summary>«پسندیدهها» — the listings the logged-in user has liked (jobs, shifts, talent), newest
|
||||||
|
/// first. Only still-open listings are shown; un-liking is done with the same heart button.</summary>
|
||||||
|
[Authorize]
|
||||||
|
public class LikedModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public LikedModel(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public List<JobOpening> Jobs { get; private set; } = new();
|
||||||
|
public List<Shift> Shifts { get; private set; } = new();
|
||||||
|
public List<TalentListing> Talent { get; private set; } = new();
|
||||||
|
public int Total => Jobs.Count + Shifts.Count + Talent.Count;
|
||||||
|
|
||||||
|
public async Task OnGetAsync()
|
||||||
|
{
|
||||||
|
var uid = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)!.Value);
|
||||||
|
var likes = await _db.Likes.Where(l => l.UserId == uid)
|
||||||
|
.OrderByDescending(l => l.CreatedAt).ToListAsync();
|
||||||
|
|
||||||
|
var jobIds = likes.Where(l => l.TargetType == LikeTargetType.Job).Select(l => l.TargetId).ToList();
|
||||||
|
var shiftIds = likes.Where(l => l.TargetType == LikeTargetType.Shift).Select(l => l.TargetId).ToList();
|
||||||
|
var talentIds = likes.Where(l => l.TargetType == LikeTargetType.Talent).Select(l => l.TargetId).ToList();
|
||||||
|
|
||||||
|
Jobs = await _db.JobOpenings
|
||||||
|
.Include(j => j.Facility).ThenInclude(f => f.City)
|
||||||
|
.Include(j => j.Facility).ThenInclude(f => f.District)
|
||||||
|
.Include(j => j.Role)
|
||||||
|
.Where(j => jobIds.Contains(j.Id) && j.Status == ShiftStatus.Open).ToListAsync();
|
||||||
|
Shifts = await _db.Shifts
|
||||||
|
.Include(s => s.Facility).ThenInclude(f => f.City)
|
||||||
|
.Include(s => s.Role)
|
||||||
|
.Where(s => shiftIds.Contains(s.Id) && s.Status == ShiftStatus.Open).ToListAsync();
|
||||||
|
Talent = await _db.TalentListings
|
||||||
|
.Include(t => t.City).Include(t => t.District).Include(t => t.Role)
|
||||||
|
.Where(t => talentIds.Contains(t.Id) && t.Status == ShiftStatus.Open).ToListAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,31 +29,13 @@ public class IndexModel : PageModel
|
|||||||
|
|
||||||
public bool Saved { get; private set; }
|
public bool Saved { get; private set; }
|
||||||
|
|
||||||
public async Task OnGetAsync()
|
// Preferences have moved onto the «پیشنهادهای ویژه شما» page (settings next to their result).
|
||||||
{
|
// Keep this route working by redirecting any old link/bookmark there.
|
||||||
await LoadListsAsync();
|
public IActionResult OnGet() => RedirectToPage("/Recommendations/Index");
|
||||||
var prefs = await _interest.GetPreferencesAsync();
|
|
||||||
if (prefs is not null)
|
|
||||||
{
|
|
||||||
RoleId = prefs.RoleId;
|
|
||||||
CityId = prefs.CityId;
|
|
||||||
PreferredShiftType = prefs.PreferredShiftType;
|
|
||||||
MinPay = prefs.MinPay;
|
|
||||||
Gender = prefs.Gender;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync()
|
public async Task<IActionResult> OnPostAsync()
|
||||||
{
|
{
|
||||||
await _interest.SavePreferencesAsync(RoleId, CityId, PreferredShiftType, MinPay, Gender);
|
await _interest.SavePreferencesAsync(RoleId, CityId, PreferredShiftType, MinPay, Gender);
|
||||||
// Back to home so the personalized feed is the immediate payoff.
|
return RedirectToPage("/Recommendations/Index");
|
||||||
TempData["prefsSaved"] = true;
|
|
||||||
return RedirectToPage("/Index");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadListsAsync()
|
|
||||||
{
|
|
||||||
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
|
|
||||||
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
@page "/Recommendations"
|
||||||
|
@model JobsMedical.Web.Pages.Recommendations.IndexModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "پیشنهادهای ویژه شما";
|
||||||
|
ViewData["Description"] = "پیشنهادهای شخصیسازیشدهٔ شیفت و استخدام برای شما در همکادر — بر اساس نقش، شهر و فعالیت شما.";
|
||||||
|
ViewData["NoIndex"] = true; // personalized to the visitor — not an indexable page
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="container">
|
||||||
|
<h1>✨ پیشنهادهای ویژه شما</h1>
|
||||||
|
<p class="muted">
|
||||||
|
@(Model.HasPersonalization
|
||||||
|
? "بر اساس علاقهمندیها و فعالیت شما انتخاب شدهاند. علاقهمندیها را پایینتر تنظیم کن."
|
||||||
|
: "نقش، شهر و نوع شیفت دلخواهت را تنظیم کن تا بهترین فرصتها را برایت پیدا کنیم.")
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container section">
|
||||||
|
@if (Model.Saved)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success">✓ علاقهمندیها ذخیره شد — پیشنهادها بهروزرسانی شدند.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Preferences — the settings that drive the feed, collapsed by default once personalized. *@
|
||||||
|
<details class="card card-pad" style="margin-bottom:18px;" @(Model.HasPersonalization ? "" : "open")>
|
||||||
|
<summary style="font-weight:800; cursor:pointer; font-size:16px;">⚙️ تنظیم علاقهمندیها</summary>
|
||||||
|
<form method="post" style="margin-top:14px;">
|
||||||
|
<div class="grid grid-3">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>نقش / رشته</label>
|
||||||
|
<select name="RoleId">
|
||||||
|
<option value="">مهم نیست</option>
|
||||||
|
@foreach (var r in Model.Roles)
|
||||||
|
{
|
||||||
|
<option value="@r.Id" selected="@(Model.RoleId == r.Id)">@r.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>شهر</label>
|
||||||
|
<select name="CityId">
|
||||||
|
<option value="">مهم نیست</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="PreferredShiftType">
|
||||||
|
<option value="">مهم نیست</option>
|
||||||
|
<option value="0" selected="@(Model.PreferredShiftType == ShiftType.Day)">صبح</option>
|
||||||
|
<option value="1" selected="@(Model.PreferredShiftType == ShiftType.Evening)">عصر</option>
|
||||||
|
<option value="2" selected="@(Model.PreferredShiftType == ShiftType.Night)">شب</option>
|
||||||
|
<option value="3" selected="@(Model.PreferredShiftType == ShiftType.OnCall)">آنکال</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>جنسیت شما</label>
|
||||||
|
<select name="Gender">
|
||||||
|
<option value="0" selected="@(Model.Gender == JobsMedical.Web.Models.Gender.Any)">نمیخواهم بگویم</option>
|
||||||
|
<option value="1" selected="@(Model.Gender == JobsMedical.Web.Models.Gender.Male)">آقا</option>
|
||||||
|
<option value="2" selected="@(Model.Gender == JobsMedical.Web.Models.Gender.Female)">خانم</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>حداقل حقوق مورد انتظار (تومان)</label>
|
||||||
|
<input type="number" name="MinPay" value="@Model.MinPay" placeholder="مثلاً ۲۰۰۰۰۰۰۰" dir="ltr" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block btn-lg" style="margin-top:6px;">ذخیره و دیدن پیشنهادها</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
@if (Model.Recommendations.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="grid grid-3">
|
||||||
|
@foreach (var rec in Model.Recommendations)
|
||||||
|
{
|
||||||
|
<partial name="_RecommendationCard" model="rec" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="card empty-state">
|
||||||
|
هنوز پیشنهادی برای شما نیست. علاقهمندیهایت را تنظیم کن یا چند فرصت را در
|
||||||
|
<a asp-page="/Jobs/Index">استخدام</a> و <a asp-page="/Shifts/Index">شیفتها</a> ببین تا پیشنهادها شخصی شوند.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using JobsMedical.Web.Data;
|
||||||
|
using JobsMedical.Web.Models;
|
||||||
|
using JobsMedical.Web.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Pages.Recommendations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dedicated «پیشنهادهای ویژه شما» page: the personalized recommendation feed plus the preference
|
||||||
|
/// controls that drive it (role/city/shift-type/pay/gender), in one place — moved off the homepage
|
||||||
|
/// and consolidating the old /Preferences screen so the settings live next to their result.
|
||||||
|
/// </summary>
|
||||||
|
public class IndexModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly RecommendationService _recs;
|
||||||
|
private readonly InterestService _interest;
|
||||||
|
|
||||||
|
public IndexModel(AppDbContext db, RecommendationService recs, InterestService interest)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_recs = recs;
|
||||||
|
_interest = interest;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Recommendation> Recommendations { get; private set; } = new();
|
||||||
|
public bool HasPersonalization { get; private set; }
|
||||||
|
public List<Role> Roles { get; private set; } = new();
|
||||||
|
public List<City> Cities { get; private set; } = new();
|
||||||
|
|
||||||
|
[BindProperty] public int? RoleId { get; set; }
|
||||||
|
[BindProperty] public int? CityId { get; set; }
|
||||||
|
[BindProperty] public ShiftType? PreferredShiftType { get; set; }
|
||||||
|
[BindProperty] public long? MinPay { get; set; }
|
||||||
|
[BindProperty] public Gender Gender { get; set; }
|
||||||
|
[TempData] public bool Saved { get; set; }
|
||||||
|
|
||||||
|
public async Task OnGetAsync() => await LoadAsync();
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostAsync()
|
||||||
|
{
|
||||||
|
await _interest.SavePreferencesAsync(RoleId, CityId, PreferredShiftType, MinPay, Gender);
|
||||||
|
Saved = true;
|
||||||
|
return RedirectToPage(); // reload so the feed reflects the new preferences immediately
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
|
||||||
|
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
|
||||||
|
Recommendations = await _recs.GetForVisitorAsync(12);
|
||||||
|
|
||||||
|
var prefs = await _interest.GetPreferencesAsync();
|
||||||
|
HasPersonalization = prefs?.HasAny == true || (await _interest.RecentEventsAsync(1)).Count > 0;
|
||||||
|
if (prefs is not null)
|
||||||
|
{
|
||||||
|
RoleId = prefs.RoleId;
|
||||||
|
CityId = prefs.CityId;
|
||||||
|
PreferredShiftType = prefs.PreferredShiftType;
|
||||||
|
MinPay = prefs.MinPay;
|
||||||
|
Gender = prefs.Gender;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@model IReadOnlyList<JobsMedical.Web.Services.Crumb>
|
||||||
|
@* Visible breadcrumb trail. The last crumb is the current page (no link). Pair with the
|
||||||
|
BreadcrumbList JSON-LD (SeoJsonLd.Breadcrumb) emitted in @@section Head. *@
|
||||||
|
@if (Model is { Count: > 1 })
|
||||||
|
{
|
||||||
|
<nav class="breadcrumbs" aria-label="مسیر">
|
||||||
|
@for (var i = 0; i < Model.Count; i++)
|
||||||
|
{
|
||||||
|
if (i > 0) { <span class="bc-sep" aria-hidden="true">›</span> }
|
||||||
|
@if (!string.IsNullOrEmpty(Model[i].Url) && i < Model.Count - 1)
|
||||||
|
{
|
||||||
|
<a href="@Model[i].Url">@Model[i].Name</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="bc-current">@Model[i].Name</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@model IReadOnlyList<JobsMedical.Web.Models.ContactMethod>
|
||||||
|
@* Renders one row per contact channel (phone/Bale/Telegram/email/…) with a clickable action.
|
||||||
|
Shared by the shift, job, and applicant detail pages. *@
|
||||||
|
@foreach (var c in Model.OrderBy(c => c.SortOrder))
|
||||||
|
{
|
||||||
|
var href = JobsMedical.Web.Services.ContactInfo.Href(c.Type, c.Value);
|
||||||
|
var label = JobsMedical.Web.Services.ContactInfo.Label(c.Type);
|
||||||
|
var icon = JobsMedical.Web.Services.ContactInfo.Icon(c.Type);
|
||||||
|
var cls = c.Type is JobsMedical.Web.Models.ContactType.Mobile or JobsMedical.Web.Models.ContactType.Phone ? "btn-accent" : "btn-outline";
|
||||||
|
<div class="contact-row">
|
||||||
|
<span class="c-meta"><span class="c-type">@icon @label</span><span class="c-val" dir="ltr">@c.Value</span></span>
|
||||||
|
@if (href is not null)
|
||||||
|
{
|
||||||
|
<a class="btn @cls" href="@href" target="_blank" rel="noopener">@(c.Type is JobsMedical.Web.Models.ContactType.Mobile or JobsMedical.Web.Models.ContactType.Phone ? "تماس" : "باز کردن")</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
string salary;
|
string salary;
|
||||||
if (Model.SalaryMin is null && Model.SalaryMax is null) salary = "توافقی";
|
if (Model.SalaryMin is null && Model.SalaryMax is null) salary = "توافقی";
|
||||||
else if (Model.SalaryMin == Model.SalaryMax) salary = JalaliDate.Toman(Model.SalaryMin) + " ماهانه";
|
else if (Model.SalaryMin == Model.SalaryMax) salary = JalaliDate.Toman(Model.SalaryMin) + " ماهانه";
|
||||||
|
else if (Model.SalaryMax is null) salary = "از " + JalaliDate.Toman(Model.SalaryMin) + " ماهانه"; // min only — avoid «تا توافقی»
|
||||||
else salary = $"از {JalaliDate.ToPersianDigits((Model.SalaryMin ?? 0).ToString("#,0"))} تا {JalaliDate.Toman(Model.SalaryMax)} ماهانه";
|
else salary = $"از {JalaliDate.ToPersianDigits((Model.SalaryMin ?? 0).ToString("#,0"))} تا {JalaliDate.Toman(Model.SalaryMax)} ماهانه";
|
||||||
var q = ViewData["q"] as string;
|
var q = ViewData["q"] as string;
|
||||||
}
|
}
|
||||||
@@ -27,7 +28,10 @@
|
|||||||
{
|
{
|
||||||
<span class="badge badge-gender">@JalaliDate.GenderLabel(Model.GenderRequirement)</span>
|
<span class="badge badge-gender">@JalaliDate.GenderLabel(Model.GenderRequirement)</span>
|
||||||
}
|
}
|
||||||
|
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(Model.Facility))
|
||||||
|
{
|
||||||
<span>🏥 @JobsMedical.Web.Services.SearchHighlight.Mark(Model.Facility?.Name, q)</span>
|
<span>🏥 @JobsMedical.Web.Services.SearchHighlight.Mark(Model.Facility?.Name, q)</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="row">📍 @Model.Facility?.City?.Name@(Model.Facility?.District is not null ? "، " + Model.Facility.District.Name : "")</div>
|
<div class="row">📍 @Model.Facility?.City?.Name@(Model.Facility?.District is not null ? "، " + Model.Facility.District.Name : "")</div>
|
||||||
@if (Model.DistanceKm is double km)
|
@if (Model.DistanceKm is double km)
|
||||||
@@ -39,6 +43,7 @@
|
|||||||
{
|
{
|
||||||
<div class="search-snippet">@snip</div>
|
<div class="search-snippet">@snip</div>
|
||||||
}
|
}
|
||||||
|
<div class="row muted" style="font-size:12px; margin-top:6px;">🕒 @JalaliDate.TimeAgo(Model.CreatedAt)</div>
|
||||||
<div class="foot">
|
<div class="foot">
|
||||||
<span class="pay">@salary</span>
|
<span class="pay">@salary</span>
|
||||||
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
|
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
|
||||||
|
|||||||
@@ -109,20 +109,25 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="nav-collapse">
|
<div class="nav-collapse">
|
||||||
|
@* Browse items only — personal ones (پیشنهادها/پسندیدهها) live in the profile menu. *@
|
||||||
<nav class="main-nav">
|
<nav class="main-nav">
|
||||||
<a asp-page="/Index" class="@(path == "/" ? "active" : null)">خانه</a>
|
<a asp-page="/Index" class="@(path == "/" ? "active" : null)">خانه</a>
|
||||||
<a asp-page="/Shifts/Index" data-tour="shifts" class="@(path.StartsWith("/Shifts") ? "active" : null)">شیفتها</a>
|
<a href="/Jobs" data-tour="jobs" class="@(path.StartsWith("/Jobs") ? "active" : null)">استخدام</a>
|
||||||
<a asp-page="/Jobs/Index" data-tour="jobs" class="@(path.StartsWith("/Jobs") ? "active" : null)">استخدام</a>
|
<a href="/Shifts" data-tour="shifts" class="@(path.StartsWith("/Shifts") ? "active" : null)">شیفتها</a>
|
||||||
<a asp-page="/Talent/Index" class="@(path.StartsWith("/Talent") ? "active" : null)">آماده به کار</a>
|
<a asp-page="/Talent/Index" class="@(path.StartsWith("/Talent") ? "active" : null)">آماده به کار</a>
|
||||||
<a asp-page="/Facilities/Index" class="@(path.StartsWith("/Facilities") ? "active" : null)">مراکز درمانی</a>
|
@if (User.Identity?.IsAuthenticated != true)
|
||||||
<a asp-page="/Calendar/Index" class="@(path.StartsWith("/Calendar") ? "active" : null)">تقویم هفتگی</a>
|
{
|
||||||
</nav>
|
<a asp-page="/Recommendations/Index" class="@(path.StartsWith("/Recommendations") ? "active" : null)">✨ پیشنهادها</a>
|
||||||
<form class="nav-search" method="get" action="/Search" role="search">
|
}
|
||||||
<div class="nav-search-pill">
|
<details class="nav-more">
|
||||||
<input type="search" name="Q" placeholder="جستجو…" aria-label="جستجو" autocomplete="off" />
|
<summary class="@(path.StartsWith("/Facilities") || path.StartsWith("/Calendar") ? "active" : null)">بیشتر ▾</summary>
|
||||||
<button type="submit" aria-label="جستجو">🔎</button>
|
<div class="nav-more-menu">
|
||||||
|
<a asp-page="/Facilities/Index" class="@(path.StartsWith("/Facilities") ? "active" : null)">🏥 مراکز درمانی</a>
|
||||||
|
<a asp-page="/Calendar/Index" class="@(path.StartsWith("/Calendar") ? "active" : null)">🗓️ تقویم هفتگی</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</details>
|
||||||
|
<a asp-page="/Search" class="nav-search-link @(path.StartsWith("/Search") ? "active" : null)">🔎 جستجو</a>
|
||||||
|
</nav>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<a class="btn btn-accent btn-sm cta-post" asp-page="/Employer/Index" data-tour="post">+ ثبت آگهی</a>
|
<a class="btn btn-accent btn-sm cta-post" asp-page="/Employer/Index" data-tour="post">+ ثبت آگهی</a>
|
||||||
@if (User.Identity?.IsAuthenticated == true)
|
@if (User.Identity?.IsAuthenticated == true)
|
||||||
@@ -162,6 +167,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pd-sep"></div>
|
<div class="pd-sep"></div>
|
||||||
|
<a asp-page="/Recommendations/Index">✨ پیشنهادهای ویژه شما</a>
|
||||||
|
<a asp-page="/Me/Liked">❤️ پسندیدهها</a>
|
||||||
|
<div class="pd-sep"></div>
|
||||||
<a href="@dashUrl" data-tour="panel">@dashIcon @dashLabel</a>
|
<a href="@dashUrl" data-tour="panel">@dashIcon @dashLabel</a>
|
||||||
<a asp-page="/Me/Profile">👤 ویرایش پروفایل</a>
|
<a asp-page="/Me/Profile">👤 ویرایش پروفایل</a>
|
||||||
<div class="pd-sep"></div>
|
<div class="pd-sep"></div>
|
||||||
@@ -225,20 +233,17 @@
|
|||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
var t = document.getElementById('profile-toggle');
|
var t = document.getElementById('profile-toggle');
|
||||||
if (t && t.checked && !e.target.closest('.profile-menu')) t.checked = false;
|
if (t && t.checked && !e.target.closest('.profile-menu')) t.checked = false;
|
||||||
|
// Close the «بیشتر» nav dropdown when clicking outside it.
|
||||||
|
document.querySelectorAll('details.nav-more[open]').forEach(function (d) {
|
||||||
|
if (!d.contains(e.target)) d.removeAttribute('open');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@* Instant search suggestions (typeahead) for the header search box. *@
|
@* Instant search suggestions (typeahead) — attaches to every form[data-suggest]
|
||||||
|
(header pill + homepage hero). *@
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var form = document.querySelector('.nav-search');
|
|
||||||
if (!form) return;
|
|
||||||
var input = form.querySelector('input');
|
|
||||||
var box = document.createElement('div');
|
|
||||||
box.className = 'nav-search-results';
|
|
||||||
box.style.display = 'none';
|
|
||||||
form.appendChild(box);
|
|
||||||
var timer;
|
|
||||||
function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
|
function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
|
||||||
function hi(text, q) {
|
function hi(text, q) {
|
||||||
var safe = esc(text);
|
var safe = esc(text);
|
||||||
@@ -248,6 +253,16 @@
|
|||||||
try { return safe.replace(new RegExp('(' + terms.join('|') + ')', 'gi'), '<mark>$1</mark>'); }
|
try { return safe.replace(new RegExp('(' + terms.join('|') + ')', 'gi'), '<mark>$1</mark>'); }
|
||||||
catch (e) { return safe; }
|
catch (e) { return safe; }
|
||||||
}
|
}
|
||||||
|
function attach(form) {
|
||||||
|
var input = form.querySelector('input[type=search], input[name=Q]');
|
||||||
|
if (!input) return;
|
||||||
|
var box = document.createElement('div');
|
||||||
|
box.className = 'nav-search-results';
|
||||||
|
box.style.display = 'none';
|
||||||
|
// Anchor the dropdown to the input's box (the hero pill) so it sits
|
||||||
|
// directly under the input rather than below the popular-search chips.
|
||||||
|
(input.closest('.hero-search-pill') || form).appendChild(box);
|
||||||
|
var timer;
|
||||||
function hide() { box.style.display = 'none'; box.innerHTML = ''; }
|
function hide() { box.style.display = 'none'; box.innerHTML = ''; }
|
||||||
input.addEventListener('input', function () {
|
input.addEventListener('input', function () {
|
||||||
var q = input.value.trim();
|
var q = input.value.trim();
|
||||||
@@ -256,15 +271,18 @@
|
|||||||
timer = setTimeout(function () {
|
timer = setTimeout(function () {
|
||||||
fetch('/search/suggest?q=' + encodeURIComponent(q))
|
fetch('/search/suggest?q=' + encodeURIComponent(q))
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (items) {
|
.then(function (data) {
|
||||||
if (!items || !items.length) { hide(); return; }
|
var items = (data && data.items) || [];
|
||||||
|
var total = (data && data.total) || items.length;
|
||||||
|
if (!items.length) { hide(); return; }
|
||||||
|
function fa(n) { return String(n).replace(/[0-9]/g, function (d) { return '۰۱۲۳۴۵۶۷۸۹'[+d]; }); }
|
||||||
var html = items.map(function (it) {
|
var html = items.map(function (it) {
|
||||||
var sub = it.sub ? '<span class="ns-sub">' + hi(it.sub, q) + '</span>' : '';
|
var sub = it.sub ? '<span class="ns-sub">' + hi(it.sub, q) + '</span>' : '';
|
||||||
return '<a href="' + it.url + '"><span class="ns-type">' + esc(it.type) +
|
return '<a href="' + it.url + '"><span class="ns-type">' + esc(it.type) +
|
||||||
'</span><span class="ns-text"><span class="ns-label">' + hi(it.label, q) +
|
'</span><span class="ns-text"><span class="ns-label">' + hi(it.label, q) +
|
||||||
'</span>' + sub + '</span></a>';
|
'</span>' + sub + '</span></a>';
|
||||||
}).join('');
|
}).join('');
|
||||||
html += '<a class="ns-all" href="/Search?Q=' + encodeURIComponent(q) + '">همه نتایج برای «' + esc(q) + '» ←</a>';
|
html += '<a class="ns-all" href="/Search?Q=' + encodeURIComponent(q) + '">مشاهده همه ' + fa(total) + ' نتیجه برای «' + esc(q) + '» ←</a>';
|
||||||
box.innerHTML = html;
|
box.innerHTML = html;
|
||||||
box.style.display = 'block';
|
box.style.display = 'block';
|
||||||
}).catch(function () { hide(); });
|
}).catch(function () { hide(); });
|
||||||
@@ -272,6 +290,89 @@
|
|||||||
});
|
});
|
||||||
document.addEventListener('click', function (e) { if (!form.contains(e.target)) hide(); });
|
document.addEventListener('click', function (e) { if (!form.contains(e.target)) hide(); });
|
||||||
input.addEventListener('keydown', function (e) { if (e.key === 'Escape') hide(); });
|
input.addEventListener('keydown', function (e) { if (e.key === 'Escape') hide(); });
|
||||||
|
}
|
||||||
|
document.querySelectorAll('[data-suggest]').forEach(attach);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
@* Contact modal — any element with data-contact-type + data-contact-id opens it; numbers are
|
||||||
|
fetched from /contact on click (so they never sit in list HTML and bots can't scrape them). *@
|
||||||
|
<div id="contactModal" class="contact-modal" aria-hidden="true">
|
||||||
|
<div class="contact-modal-box" role="dialog" aria-modal="true" aria-labelledby="contactModalTitle">
|
||||||
|
<div class="contact-modal-head">
|
||||||
|
<h3 id="contactModalTitle">راههای ارتباطی</h3>
|
||||||
|
<button type="button" class="contact-modal-x" data-contact-close aria-label="بستن">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="contactModalBody" class="contact-modal-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@* Like («پسندیدن») toggle — login-gated, updates the button state + count in place. *@
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
function fa(n) { return String(n).replace(/[0-9]/g, function (d) { return '۰۱۲۳۴۵۶۷۸۹'[+d]; }); }
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var b = e.target.closest('.like-trigger');
|
||||||
|
if (!b) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (document.body.dataset.authed !== '1') {
|
||||||
|
location.href = '/Account/Login?returnUrl=' + encodeURIComponent(location.pathname);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('type', b.dataset.likeType);
|
||||||
|
fd.append('id', b.dataset.likeId);
|
||||||
|
b.disabled = true;
|
||||||
|
fetch('/like', { method: 'POST', body: fd })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
|
||||||
|
.then(function (d) {
|
||||||
|
b.dataset.liked = d.liked ? 'true' : 'false';
|
||||||
|
b.classList.toggle('btn-accent', d.liked);
|
||||||
|
b.classList.toggle('btn-outline', !d.liked);
|
||||||
|
var ico = b.querySelector('.like-ico'); if (ico) ico.textContent = d.liked ? '♥' : '♡';
|
||||||
|
var c = b.querySelector('.like-count'); if (c) c.textContent = fa(d.count);
|
||||||
|
})
|
||||||
|
.catch(function () {})
|
||||||
|
.finally(function () { b.disabled = false; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var modal = document.getElementById('contactModal');
|
||||||
|
var box = document.getElementById('contactModalBody');
|
||||||
|
var titleEl = document.getElementById('contactModalTitle');
|
||||||
|
function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
|
||||||
|
function open() { modal.classList.add('show'); modal.setAttribute('aria-hidden', 'false'); }
|
||||||
|
function close() { modal.classList.remove('show'); modal.setAttribute('aria-hidden', 'true'); box.innerHTML = ''; }
|
||||||
|
function render(d) {
|
||||||
|
titleEl.textContent = d.title || 'راههای ارتباطی';
|
||||||
|
var html = '';
|
||||||
|
(d.contacts || []).forEach(function (c) {
|
||||||
|
html += '<div class="contact-row"><span class="c-meta"><span class="c-type">' + esc(c.icon + ' ' + c.label) +
|
||||||
|
'</span><span class="c-val" dir="ltr">' + esc(c.value) + '</span></span>' +
|
||||||
|
(c.href ? '<a class="btn btn-accent" href="' + esc(c.href) + '" target="_blank" rel="nofollow noopener">تماس</a>' : '') + '</div>';
|
||||||
|
});
|
||||||
|
if (d.fallbackUrl) html += '<a class="btn btn-accent btn-block" href="' + esc(d.fallbackUrl) +
|
||||||
|
'" target="_blank" rel="nofollow noopener">' + esc(d.fallbackLabel || 'مشاهده') + '</a>';
|
||||||
|
box.innerHTML = html || '<p class="muted" style="margin:0;">شمارهای ثبت نشده است.</p>';
|
||||||
|
}
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var trigger = e.target.closest('[data-contact-type]');
|
||||||
|
if (trigger) {
|
||||||
|
e.preventDefault(); e.stopPropagation(); // don't follow the card link
|
||||||
|
titleEl.textContent = 'راههای ارتباطی';
|
||||||
|
box.innerHTML = '<p class="muted" style="margin:0;">در حال دریافت…</p>';
|
||||||
|
open();
|
||||||
|
fetch('/contact?type=' + encodeURIComponent(trigger.getAttribute('data-contact-type')) +
|
||||||
|
'&id=' + encodeURIComponent(trigger.getAttribute('data-contact-id')))
|
||||||
|
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
|
||||||
|
.then(render)
|
||||||
|
.catch(function () { box.innerHTML = '<p class="muted" style="margin:0;">خطا در دریافت اطلاعات تماس.</p>'; });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.target.closest('[data-contact-close]') || e.target === modal) close();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,25 @@
|
|||||||
data-lat="…" data-lng="…"> exists. Pass the Neshan web key as the model.
|
data-lat="…" data-lng="…"> exists. Pass the Neshan web key as the model.
|
||||||
The SDK is a synchronous script, so the init below runs once L is defined.
|
The SDK is a synchronous script, so the init below runs once L is defined.
|
||||||
*@
|
*@
|
||||||
<link rel="stylesheet" href="https://static.neshan.org/sdk/leaflet/1.4.0/neshan-sdk/v1.0.8/index.css" />
|
<link rel="stylesheet" href="https://static.neshan.org/sdk/leaflet/1.4.0/leaflet.css" />
|
||||||
<script src="https://static.neshan.org/sdk/leaflet/1.4.0/neshan-sdk/v1.0.8/index.js"></script>
|
<script src="https://static.neshan.org/sdk/leaflet/1.4.0/leaflet.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var el = document.getElementById('facmap');
|
var el = document.getElementById('facmap');
|
||||||
if (!el || !window.L) return;
|
if (!el || !window.L) return;
|
||||||
var lat = parseFloat(el.dataset.lat), lng = parseFloat(el.dataset.lng);
|
var lat = parseFloat(el.dataset.lat), lng = parseFloat(el.dataset.lng);
|
||||||
if (isNaN(lat) || isNaN(lng)) return;
|
if (isNaN(lat) || isNaN(lng)) return;
|
||||||
|
// Approximate (aggregated) listings show a shaded AREA circle, not a precise pin.
|
||||||
|
var approx = el.dataset.approx === 'true';
|
||||||
var map = new L.Map('facmap', {
|
var map = new L.Map('facmap', {
|
||||||
key: '@Model', maptype: 'neshan', poi: true, traffic: false,
|
key: '@Model', maptype: 'neshan', poi: !approx, traffic: false,
|
||||||
center: [lat, lng], zoom: 15
|
center: [lat, lng], zoom: approx ? 14 : 15
|
||||||
});
|
});
|
||||||
|
if (approx) {
|
||||||
|
var radius = parseInt(el.dataset.radius || '700', 10);
|
||||||
|
L.circle([lat, lng], { radius: radius, color: '#e07b39', weight: 1, fillColor: '#e07b39', fillOpacity: 0.18 }).addTo(map);
|
||||||
|
} else {
|
||||||
L.marker([lat, lng]).addTo(map);
|
L.marker([lat, lng]).addTo(map);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
@model (int Current, int Total)
|
||||||
|
@{
|
||||||
|
var (cur, total) = Model;
|
||||||
|
}
|
||||||
|
@if (total > 1)
|
||||||
|
{
|
||||||
|
@* Build a page URL that preserves every current filter in the query string. *@
|
||||||
|
Func<int, string> pageUrl = p =>
|
||||||
|
{
|
||||||
|
var parts = Context.Request.Query
|
||||||
|
.Where(kv => !string.Equals(kv.Key, "Page", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(kv => Uri.EscapeDataString(kv.Key) + "=" + Uri.EscapeDataString(kv.Value.ToString()))
|
||||||
|
.ToList();
|
||||||
|
parts.Add("Page=" + p);
|
||||||
|
return Context.Request.Path + "?" + string.Join("&", parts);
|
||||||
|
};
|
||||||
|
var from = Math.Max(1, cur - 2);
|
||||||
|
var to = Math.Min(total, cur + 2);
|
||||||
|
|
||||||
|
<nav class="pager" aria-label="صفحهبندی">
|
||||||
|
@if (cur > 1)
|
||||||
|
{
|
||||||
|
<a class="pager-btn" href="@pageUrl(cur - 1)" rel="prev">→ قبلی</a>
|
||||||
|
}
|
||||||
|
@if (from > 1)
|
||||||
|
{
|
||||||
|
<a class="pager-num" href="@pageUrl(1)">@JalaliDate.ToPersianDigits("1")</a>
|
||||||
|
@if (from > 2) { <span class="pager-gap">…</span> }
|
||||||
|
}
|
||||||
|
@for (var p = from; p <= to; p++)
|
||||||
|
{
|
||||||
|
if (p == cur)
|
||||||
|
{
|
||||||
|
<span class="pager-num active" aria-current="page">@JalaliDate.ToPersianDigits(p.ToString())</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="pager-num" href="@pageUrl(p)">@JalaliDate.ToPersianDigits(p.ToString())</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (to < total)
|
||||||
|
{
|
||||||
|
@if (to < total - 1) { <span class="pager-gap">…</span> }
|
||||||
|
<a class="pager-num" href="@pageUrl(total)">@JalaliDate.ToPersianDigits(total.ToString())</a>
|
||||||
|
}
|
||||||
|
@if (cur < total)
|
||||||
|
{
|
||||||
|
<a class="pager-btn" href="@pageUrl(cur + 1)" rel="next">بعدی ←</a>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
<a class="@(On("/Admin/Index") ? "active" : null)" asp-page="/Admin/Index">📥 صف آگهیها</a>
|
<a class="@(On("/Admin/Index") ? "active" : null)" asp-page="/Admin/Index">📥 صف آگهیها</a>
|
||||||
<a class="@(On("/Admin/Ingested") ? "active" : null)" asp-page="/Admin/Ingested">📜 نتایج جمعآوری</a>
|
<a class="@(On("/Admin/Ingested") ? "active" : null)" asp-page="/Admin/Ingested">📜 نتایج جمعآوری</a>
|
||||||
<a class="@(On("/Admin/Facilities") ? "active" : null)" asp-page="/Admin/Facilities">🏥 مراکز</a>
|
<a class="@(On("/Admin/Facilities") ? "active" : null)" asp-page="/Admin/Facilities">🏥 مراکز</a>
|
||||||
|
<a class="@(On("/Admin/Roles") ? "active" : null)" asp-page="/Admin/Roles">🏷️ نقشها</a>
|
||||||
<a class="@(On("/Admin/Users") ? "active" : null)" asp-page="/Admin/Users">👥 کاربران</a>
|
<a class="@(On("/Admin/Users") ? "active" : null)" asp-page="/Admin/Users">👥 کاربران</a>
|
||||||
<a class="@(On("/Admin/Reports") ? "active" : null)" asp-page="/Admin/Reports">🛡️ گزارشها</a>
|
<a class="@(On("/Admin/Reports") ? "active" : null)" asp-page="/Admin/Reports">🛡️ گزارشها</a>
|
||||||
<a class="@(On("/Admin/Broadcast") ? "active" : null)" asp-page="/Admin/Broadcast">📣 اعلان همگانی</a>
|
<a class="@(On("/Admin/Broadcast") ? "active" : null)" asp-page="/Admin/Broadcast">📣 اعلان همگانی</a>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<strong>@Model.SourceChannel</strong>
|
<strong>@Model.SourceChannel</strong>
|
||||||
<span style="display:flex; gap:8px; align-items:center;">
|
<span style="display:flex; gap:8px; align-items:center;">
|
||||||
<span class="badge @confClass">اطمینان @JalaliDate.ToPersianDigits(c.ToString())٪</span>
|
<span class="badge @confClass">اطمینان @JalaliDate.ToPersianDigits(c.ToString())٪</span>
|
||||||
<span class="muted" style="font-size:12px;">@JalaliDate.ToLongDate(DateOnly.FromDateTime(Model.FetchedAt)) — @JalaliDate.ToPersianDigits(Model.FetchedAt.ToString("HH:mm"))</span>
|
<span class="muted" style="font-size:12px;">@JalaliDate.DateTimeLabel(Model.FetchedAt)</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p style="margin:10px 0; white-space:pre-wrap;">@Model.RawText</p>
|
<p style="margin:10px 0; white-space:pre-wrap;">@Model.RawText</p>
|
||||||
|
|||||||
@@ -1,6 +1,28 @@
|
|||||||
@model JobsMedical.Web.Services.Recommendation
|
@model JobsMedical.Web.Services.Recommendation
|
||||||
@{
|
@{
|
||||||
var s = Model.Shift;
|
var isJob = Model.IsJob;
|
||||||
|
var role = isJob ? Model.Job!.Role?.Name : Model.Shift!.Role?.Name;
|
||||||
|
var fac = isJob ? Model.Job!.Facility : Model.Shift!.Facility;
|
||||||
|
var gender = isJob ? Model.Job!.GenderRequirement : Model.Shift!.GenderRequirement;
|
||||||
|
var url = isJob ? $"/Jobs/Details/{Model.Job!.Id}" : $"/Shifts/Details/{Model.Shift!.Id}";
|
||||||
|
string empLabel(JobsMedical.Web.Models.EmploymentType t) => t switch
|
||||||
|
{
|
||||||
|
JobsMedical.Web.Models.EmploymentType.PartTime => "پارهوقت",
|
||||||
|
JobsMedical.Web.Models.EmploymentType.Contract => "قراردادی",
|
||||||
|
JobsMedical.Web.Models.EmploymentType.Plan => "طرح",
|
||||||
|
_ => "تماموقت",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
<a class="card card-pad shift-card" href="@url">
|
||||||
|
<div class="row" style="justify-content: space-between;">
|
||||||
|
<span class="facility">@(role ?? (isJob ? "استخدام" : "شیفت"))</span>
|
||||||
|
@if (isJob)
|
||||||
|
{
|
||||||
|
<span class="badge badge-job">استخدام</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var s = Model.Shift!;
|
||||||
var (badgeClass, typeLabel) = s.ShiftType switch
|
var (badgeClass, typeLabel) = s.ShiftType switch
|
||||||
{
|
{
|
||||||
ShiftType.Day => ("badge-day", "صبح"),
|
ShiftType.Day => ("badge-day", "صبح"),
|
||||||
@@ -8,25 +30,31 @@
|
|||||||
ShiftType.Night => ("badge-night", "شب"),
|
ShiftType.Night => ("badge-night", "شب"),
|
||||||
_ => ("badge-oncall", "آنکال"),
|
_ => ("badge-oncall", "آنکال"),
|
||||||
};
|
};
|
||||||
}
|
|
||||||
<a class="card card-pad shift-card" asp-page="/Shifts/Details" asp-route-id="@s.Id">
|
|
||||||
<div class="row" style="justify-content: space-between;">
|
|
||||||
<span class="facility">@s.Facility?.Name</span>
|
|
||||||
<span class="badge @badgeClass">@typeLabel</span>
|
<span class="badge @badgeClass">@typeLabel</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@if (s.Role is not null)
|
@if (gender != Gender.Any)
|
||||||
{
|
{
|
||||||
<span class="badge badge-type">@s.Role.Name</span>
|
<span class="badge badge-gender">@JalaliDate.GenderLabel(gender)</span>
|
||||||
}
|
}
|
||||||
@if (s.GenderRequirement != Gender.Any)
|
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(fac))
|
||||||
{
|
{
|
||||||
<span class="badge badge-gender">@JalaliDate.GenderLabel(s.GenderRequirement)</span>
|
<span>🏥 @fac?.Name</span>
|
||||||
}
|
}
|
||||||
<span>📍 @s.Facility?.City?.Name</span>
|
<span>📍 @fac?.City?.Name</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (isJob)
|
||||||
|
{
|
||||||
|
<div class="row">💼 @empLabel(Model.Job!.EmploymentType)</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var s = Model.Shift!;
|
||||||
<div class="row">📅 @JalaliDate.WeekDayName(s.Date)، @JalaliDate.ToLongDate(s.Date) — 🕐 @JalaliDate.Time(s.StartTime)</div>
|
<div class="row">📅 @JalaliDate.WeekDayName(s.Date)، @JalaliDate.ToLongDate(s.Date) — 🕐 @JalaliDate.Time(s.StartTime)</div>
|
||||||
<partial name="_HourBar" model="s" />
|
<partial name="_HourBar" model="s" />
|
||||||
|
}
|
||||||
|
|
||||||
@* The "why" — what makes a pattern engine trustworthy: every pick is explained. *@
|
@* The "why" — what makes a pattern engine trustworthy: every pick is explained. *@
|
||||||
<div class="rec-reasons">
|
<div class="rec-reasons">
|
||||||
@@ -37,7 +65,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="foot">
|
<div class="foot">
|
||||||
<span class="pay">@JalaliDate.PayLabel(s.PayType, s.PayAmount, s.SharePercent)</span>
|
<span class="pay">
|
||||||
|
@if (isJob)
|
||||||
|
{
|
||||||
|
@(Model.Job!.SalaryMin is long m ? JalaliDate.ToPersianDigits(m.ToString("#,0")) + " تومان" : "توافقی")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var s = Model.Shift!;
|
||||||
|
@JalaliDate.PayLabel(s.PayType, s.PayAmount, s.SharePercent)
|
||||||
|
}
|
||||||
|
</span>
|
||||||
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
|
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -11,14 +11,10 @@
|
|||||||
}
|
}
|
||||||
<a class="card card-pad shift-card" asp-page="/Shifts/Details" asp-route-id="@Model.Id">
|
<a class="card card-pad shift-card" asp-page="/Shifts/Details" asp-route-id="@Model.Id">
|
||||||
<div class="row" style="justify-content: space-between;">
|
<div class="row" style="justify-content: space-between;">
|
||||||
<span class="facility">@JobsMedical.Web.Services.SearchHighlight.Mark(Model.Facility?.Name, q)</span>
|
<span class="facility">@JobsMedical.Web.Services.SearchHighlight.Mark(Model.Role?.Name ?? "شیفت", q)</span>
|
||||||
<span class="badge @badgeClass">@typeLabel</span>
|
<span class="badge @badgeClass">@typeLabel</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@if (Model.Role is not null)
|
|
||||||
{
|
|
||||||
<span class="badge badge-type">@JobsMedical.Web.Services.SearchHighlight.Mark(Model.Role.Name, q)</span>
|
|
||||||
}
|
|
||||||
@if (Model.GenderRequirement != Gender.Any)
|
@if (Model.GenderRequirement != Gender.Any)
|
||||||
{
|
{
|
||||||
<span class="badge badge-gender">@JalaliDate.GenderLabel(Model.GenderRequirement)</span>
|
<span class="badge badge-gender">@JalaliDate.GenderLabel(Model.GenderRequirement)</span>
|
||||||
@@ -27,6 +23,10 @@
|
|||||||
{
|
{
|
||||||
<span class="badge badge-verified">✓ تأیید شده</span>
|
<span class="badge badge-verified">✓ تأیید شده</span>
|
||||||
}
|
}
|
||||||
|
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(Model.Facility))
|
||||||
|
{
|
||||||
|
<span>🏥 @JobsMedical.Web.Services.SearchHighlight.Mark(Model.Facility?.Name, q)</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="row loc-row">📍 @Model.Facility?.City?.Name@(Model.Facility?.District is not null ? "، " + Model.Facility.District.Name : "")</div>
|
<div class="row loc-row">📍 @Model.Facility?.City?.Name@(Model.Facility?.District is not null ? "، " + Model.Facility.District.Name : "")</div>
|
||||||
@if (Model.DistanceKm is double km)
|
@if (Model.DistanceKm is double km)
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
<div class="search-snippet">@snip</div>
|
<div class="search-snippet">@snip</div>
|
||||||
}
|
}
|
||||||
<partial name="_HourBar" model="Model" />
|
<partial name="_HourBar" model="Model" />
|
||||||
|
<div class="row muted" style="font-size:12px; margin-top:6px;">🕒 @JalaliDate.TimeAgo(Model.CreatedAt)</div>
|
||||||
<div class="foot">
|
<div class="foot">
|
||||||
<span class="pay">@JalaliDate.PayLabel(Model.PayType, Model.PayAmount, Model.SharePercent)</span>
|
<span class="pay">@JalaliDate.PayLabel(Model.PayType, Model.PayAmount, Model.SharePercent)</span>
|
||||||
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
|
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<div class="row muted" style="font-size:12px; margin-top:6px;">🕒 @JalaliDate.TimeAgo(Model.CreatedAt)</div>
|
||||||
<div class="foot">
|
<div class="foot">
|
||||||
<span class="pay">@comp</span>
|
<span class="pay">@comp</span>
|
||||||
<span class="btn btn-outline" style="padding: 6px 14px;">مشاهده و تماس</span>
|
<span class="btn btn-outline" style="padding: 6px 14px;">مشاهده و تماس</span>
|
||||||
|
|||||||
@@ -3,8 +3,17 @@
|
|||||||
@{
|
@{
|
||||||
var s = Model.Shift!;
|
var s = Model.Shift!;
|
||||||
var f = s.Facility!;
|
var f = s.Facility!;
|
||||||
ViewData["Title"] = $"شیفت {s.SpecialtyRequired} - {f.Name}";
|
var hasFac = JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f); // false for the «نامشخص» placeholder
|
||||||
ViewData["Description"] = $"شیفت {s.SpecialtyRequired} در {f.Name}، {f.City?.Name}، تاریخ {JalaliDate.ToLongDate(s.Date)} از ساعت {JalaliDate.Time(s.StartTime)}.";
|
var shiftContacts = (s.Contacts ?? new List<JobsMedical.Web.Models.ContactMethod>()).ToList();
|
||||||
|
// Map: prefer the listing's own approx coords (aggregated ads) then the facility's. Aggregated =
|
||||||
|
// approximate → shown as an area circle with a disclaimer, never a precise pin.
|
||||||
|
var mapLat = s.Lat ?? f.Lat;
|
||||||
|
var mapLng = s.Lng ?? f.Lng;
|
||||||
|
var mapApprox = s.Source == JobsMedical.Web.Models.ShiftSource.Aggregated;
|
||||||
|
ViewData["Title"] = hasFac ? $"شیفت {s.SpecialtyRequired} - {f.Name}" : $"شیفت {s.SpecialtyRequired} — {f.City?.Name}";
|
||||||
|
ViewData["Description"] = hasFac
|
||||||
|
? $"شیفت {s.SpecialtyRequired} در {f.Name}، {f.City?.Name}، تاریخ {JalaliDate.ToLongDate(s.Date)} از ساعت {JalaliDate.Time(s.StartTime)}."
|
||||||
|
: $"شیفت {s.SpecialtyRequired} در {f.City?.Name}، تاریخ {JalaliDate.ToLongDate(s.Date)} از ساعت {JalaliDate.Time(s.StartTime)}.";
|
||||||
// Past/filled shifts shouldn't stay in the index as dead pages.
|
// Past/filled shifts shouldn't stay in the index as dead pages.
|
||||||
if (s.Status != JobsMedical.Web.Models.ShiftStatus.Open || s.Date < DateOnly.FromDateTime(DateTime.UtcNow))
|
if (s.Status != JobsMedical.Web.Models.ShiftStatus.Open || s.Date < DateOnly.FromDateTime(DateTime.UtcNow))
|
||||||
ViewData["NoIndex"] = true;
|
ViewData["NoIndex"] = true;
|
||||||
@@ -15,10 +24,14 @@
|
|||||||
ShiftType.Night => ("badge-night", "شیفت شب"),
|
ShiftType.Night => ("badge-night", "شیفت شب"),
|
||||||
_ => ("badge-oncall", "آنکال"),
|
_ => ("badge-oncall", "آنکال"),
|
||||||
};
|
};
|
||||||
|
var crumbs = new List<JobsMedical.Web.Services.Crumb> { new("خانه", "/"), new("شیفتها", "/Shifts") };
|
||||||
|
if (s.Role is not null) crumbs.Add(new(s.Role.Name, "/شیفت/" + JobsMedical.Web.Services.SeoSlug.Of(s.Role.Name)));
|
||||||
|
crumbs.Add(new(JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f) ? f.Name : "جزئیات شیفت", null));
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<partial name="_Breadcrumbs" model="crumbs" />
|
||||||
<div class="row" style="display:flex; gap:10px; align-items:center;">
|
<div class="row" style="display:flex; gap:10px; align-items:center;">
|
||||||
<span class="badge @badgeClass">@typeLabel</span>
|
<span class="badge @badgeClass">@typeLabel</span>
|
||||||
@if (f.IsVerified)
|
@if (f.IsVerified)
|
||||||
@@ -26,7 +39,7 @@
|
|||||||
<span class="badge badge-verified">✓ مرکز تأیید شده</span>
|
<span class="badge badge-verified">✓ مرکز تأیید شده</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<h1 style="margin-top:8px;">@s.SpecialtyRequired — @f.Name</h1>
|
<h1 style="margin-top:8px;">@s.SpecialtyRequired@(hasFac ? " — " + f.Name : "")</h1>
|
||||||
<p class="muted">📍 @f.City?.Name @(string.IsNullOrEmpty(f.Address) ? "" : "، " + f.Address)</p>
|
<p class="muted">📍 @f.City?.Name @(string.IsNullOrEmpty(f.Address) ? "" : "، " + f.Address)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,11 +50,18 @@
|
|||||||
@if (Model.ShowContact)
|
@if (Model.ShowContact)
|
||||||
{
|
{
|
||||||
<div class="contact-reveal" style="margin-bottom:16px;">
|
<div class="contact-reveal" style="margin-bottom:16px;">
|
||||||
<h4>✓ راههای ارتباطی مرکز</h4>
|
<h4>✓ راههای ارتباطی</h4>
|
||||||
|
@if (shiftContacts.Count > 0)
|
||||||
|
{
|
||||||
|
@* Numbers from THIS ad (aggregated) — the correct, per-listing contacts. *@
|
||||||
|
<partial name="_ContactList" model="shiftContacts" />
|
||||||
|
}
|
||||||
|
else if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f) && (!string.IsNullOrEmpty(f.Phone) || !string.IsNullOrEmpty(f.BaleId)))
|
||||||
|
{
|
||||||
@if (!string.IsNullOrEmpty(f.Phone))
|
@if (!string.IsNullOrEmpty(f.Phone))
|
||||||
{
|
{
|
||||||
<div class="contact-row">
|
<div class="contact-row">
|
||||||
<span class="c-meta"><span class="c-type">📞 تلفن</span><span class="c-val" dir="ltr">@f.Phone</span></span>
|
<span class="c-meta"><span class="c-type">📞 تلفن مرکز</span><span class="c-val" dir="ltr">@f.Phone</span></span>
|
||||||
<a class="btn btn-accent" href="tel:@f.Phone">تماس</a>
|
<a class="btn btn-accent" href="tel:@f.Phone">تماس</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -52,9 +72,10 @@
|
|||||||
<a class="btn btn-outline" href="https://ble.ir/@f.BaleId" target="_blank" rel="noopener">باز کردن</a>
|
<a class="btn btn-outline" href="https://ble.ir/@f.BaleId" target="_blank" rel="noopener">باز کردن</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (string.IsNullOrEmpty(f.Phone) && string.IsNullOrEmpty(f.BaleId))
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
<p class="muted" style="margin:0;">شمارهای برای این مرکز ثبت نشده است.</p>
|
<p class="muted" style="margin:0;">شمارهای ثبت نشده است.</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -84,7 +105,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (Model.MoreAtFacility.Count > 0)
|
@if (hasFac && Model.MoreAtFacility.Count > 0)
|
||||||
{
|
{
|
||||||
<h3 style="margin:26px 0 14px;">شیفتهای دیگر این مرکز</h3>
|
<h3 style="margin:26px 0 14px;">شیفتهای دیگر این مرکز</h3>
|
||||||
<div class="grid grid-3">
|
<div class="grid grid-3">
|
||||||
@@ -110,22 +131,15 @@
|
|||||||
<div class="alert alert-success" style="margin-bottom:12px;">✓ این فرصت ذخیره شد و در پیشنهادهای شما لحاظ میشود.</div>
|
<div class="alert alert-success" style="margin-bottom:12px;">✓ این فرصت ذخیره شد و در پیشنهادهای شما لحاظ میشود.</div>
|
||||||
}
|
}
|
||||||
<div class="aside-apply">
|
<div class="aside-apply">
|
||||||
<form method="post">
|
<button type="button" class="btn btn-accent btn-block btn-lg contact-trigger"
|
||||||
<button type="submit" asp-page-handler="Interest" asp-route-id="@s.Id"
|
data-contact-type="shift" data-contact-id="@s.Id">📞 اعلام تمایل و مشاهده راه ارتباطی</button>
|
||||||
class="btn btn-accent btn-block btn-lg">اعلام تمایل و مشاهده راه ارتباطی</button>
|
|
||||||
</form>
|
|
||||||
<p class="muted center" style="font-size:12px; margin:8px 0;">با اعلام تمایل، اطلاعات تماس مرکز نمایش داده میشود.</p>
|
<p class="muted center" style="font-size:12px; margin:8px 0;">با اعلام تمایل، اطلاعات تماس مرکز نمایش داده میشود.</p>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap:8px;">
|
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-block like-trigger"
|
||||||
<form method="post" style="flex:1;">
|
data-like-type="shift" data-like-id="@s.Id" data-liked="@(Model.IsLiked ? "true" : "false")">
|
||||||
<button type="submit" asp-page-handler="Save" asp-route-id="@s.Id"
|
<span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> پسندیدم
|
||||||
class="btn btn-outline btn-block">♡ ذخیره</button>
|
<span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
|
||||||
</form>
|
</button>
|
||||||
<form method="post" style="flex:1;">
|
|
||||||
<button type="submit" asp-page-handler="Dismiss" asp-route-id="@s.Id"
|
|
||||||
class="btn btn-outline btn-block">✕ علاقهمند نیستم</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@if (Model.Reported)
|
@if (Model.Reported)
|
||||||
{
|
{
|
||||||
<p class="muted" style="font-size:12px; margin:8px 0 0;">✓ گزارش شما ثبت شد. متشکریم.</p>
|
<p class="muted" style="font-size:12px; margin:8px 0 0;">✓ گزارش شما ثبت شد. متشکریم.</p>
|
||||||
@@ -144,7 +158,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
<details style="margin-top:6px;">
|
<details style="margin-top:6px;">
|
||||||
<summary class="muted" style="font-size:12px; cursor:pointer;">شکایت از این مرکز (@f.Name)</summary>
|
<summary class="muted" style="font-size:12px; cursor:pointer;">شکایت از این @(hasFac ? "مرکز (" + f.Name + ")" : "آگهی")</summary>
|
||||||
<form method="post" action="/report" style="margin-top:8px;">
|
<form method="post" action="/report" style="margin-top:8px;">
|
||||||
<input type="hidden" name="targetType" value="Facility" />
|
<input type="hidden" name="targetType" value="Facility" />
|
||||||
<input type="hidden" name="targetId" value="@f.Id" />
|
<input type="hidden" name="targetId" value="@f.Id" />
|
||||||
@@ -159,13 +173,13 @@
|
|||||||
|
|
||||||
<div class="card card-pad" style="margin-top:16px;">
|
<div class="card card-pad" style="margin-top:16px;">
|
||||||
<h3 style="margin-top:0;">موقعیت مکانی</h3>
|
<h3 style="margin-top:0;">موقعیت مکانی</h3>
|
||||||
@if (f.Lat is not null && f.Lng is not null)
|
@if (mapLat is not null && mapLng is not null)
|
||||||
{
|
{
|
||||||
var latS = f.Lat.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
var latS = mapLat.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
var lngS = f.Lng.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
var lngS = mapLng.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
@if (!string.IsNullOrEmpty(Model.MapKey))
|
@if (!string.IsNullOrEmpty(Model.MapKey))
|
||||||
{
|
{
|
||||||
<div id="facmap" data-lat="@latS" data-lng="@lngS" style="height:200px; border-radius:10px; overflow:hidden; border:1px solid var(--line);"></div>
|
<div id="facmap" data-lat="@latS" data-lng="@lngS" data-approx="@(mapApprox ? "true" : "false")" style="height:200px; border-radius:10px; overflow:hidden; border:1px solid var(--line);"></div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -173,12 +187,16 @@
|
|||||||
🗺️<br /><small class="muted" dir="ltr">@latS، @lngS</small>
|
🗺️<br /><small class="muted" dir="ltr">@latS، @lngS</small>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (mapApprox)
|
||||||
|
{
|
||||||
|
<p class="muted" style="font-size:12px; margin:8px 0 0;">📍 محدودهٔ تقریبی (برگرفته از آگهی منبع)؛ موقعیت دقیق نیست.</p>
|
||||||
|
}
|
||||||
<a class="btn btn-outline btn-block" style="margin-top:8px;" target="_blank" rel="noopener"
|
<a class="btn btn-outline btn-block" style="margin-top:8px;" target="_blank" rel="noopener"
|
||||||
href="https://neshan.org/maps/@(latS),@(lngS),16z">مسیریابی در نشان</a>
|
href="https://neshan.org/maps/@(latS),@(lngS),16z">مسیریابی در نشان</a>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<p class="muted" style="margin:0;">مختصات این مرکز هنوز ثبت نشده است.</p>
|
<p class="muted" style="margin:0;">مختصات این آگهی ثبت نشده است.</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -187,34 +205,25 @@
|
|||||||
|
|
||||||
@* Sticky bottom action bar — mobile only. Always-reachable primary action (native-app feel). *@
|
@* Sticky bottom action bar — mobile only. Always-reachable primary action (native-app feel). *@
|
||||||
<div class="mobile-action-bar">
|
<div class="mobile-action-bar">
|
||||||
@if (Model.ShowContact)
|
<button type="button" class="btn btn-accent btn-lg cta-main contact-trigger"
|
||||||
{
|
data-contact-type="shift" data-contact-id="@s.Id">📞 اعلام تمایل و مشاهده تماس</button>
|
||||||
@if (!string.IsNullOrEmpty(f.Phone))
|
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-lg like-trigger" aria-label="پسندیدن"
|
||||||
{
|
data-like-type="shift" data-like-id="@s.Id" data-liked="@(Model.IsLiked ? "true" : "false")">
|
||||||
<a class="btn btn-accent btn-lg cta-main" href="tel:@f.Phone">📞 تماس با مرکز</a>
|
<span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> <span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
|
||||||
}
|
</button>
|
||||||
else
|
|
||||||
{
|
|
||||||
<span class="cta-main center muted" style="align-self:center;">اطلاعات تماس در بالای صفحه</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<form method="post" class="cta-main">
|
|
||||||
<button type="submit" asp-page-handler="Interest" asp-route-id="@s.Id" class="btn btn-accent btn-block btn-lg">اعلام تمایل و مشاهده تماس</button>
|
|
||||||
</form>
|
|
||||||
<form method="post">
|
|
||||||
<button type="submit" asp-page-handler="Save" asp-route-id="@s.Id" class="btn btn-outline btn-lg" aria-label="ذخیره" title="ذخیره">♡</button>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(Model.MapKey) && Model.Shift?.Facility?.Lat is not null)
|
@if (!string.IsNullOrEmpty(Model.MapKey) && mapLat is not null)
|
||||||
{
|
{
|
||||||
<partial name="_NeshanMap" model="Model.MapKey" />
|
<partial name="_NeshanMap" model="Model.MapKey" />
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Head {
|
@section Head {
|
||||||
@{ var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
|
@{ var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
|
||||||
|
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.Breadcrumb(crumbs, bu) + "</script>")
|
||||||
|
@* Only for a real named employer — Google for Jobs rejects a placeholder hiringOrganization. *@
|
||||||
|
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f))
|
||||||
|
{
|
||||||
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.ShiftPosting(s, bu) + "</script>")
|
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.ShiftPosting(s, bu) + "</script>")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public class DetailsModel : PageModel
|
|||||||
public Shift? Shift { get; private set; }
|
public Shift? Shift { get; private set; }
|
||||||
public List<Shift> MoreAtFacility { get; private set; } = new();
|
public List<Shift> MoreAtFacility { get; private set; } = new();
|
||||||
public string? MapKey { get; private set; }
|
public string? MapKey { get; private set; }
|
||||||
|
public int LikeCount { get; private set; }
|
||||||
|
public bool IsLiked { get; private set; }
|
||||||
|
|
||||||
// Set after the visitor taps "interested" — reveals the facility contact (handoff model).
|
// Set after the visitor taps "interested" — reveals the facility contact (handoff model).
|
||||||
public bool ShowContact { get; private set; }
|
public bool ShowContact { get; private set; }
|
||||||
@@ -34,7 +36,13 @@ public class DetailsModel : PageModel
|
|||||||
{
|
{
|
||||||
await LoadAsync(id);
|
await LoadAsync(id);
|
||||||
if (Shift is null) return NotFound();
|
if (Shift is null) return NotFound();
|
||||||
|
// Intentionally removed (admin-archived out-of-scope/duplicate ad): 410 Gone is the standard
|
||||||
|
// signal for permanent removal, so search engines deindex it cleanly (we keep the row for audit).
|
||||||
|
if (Shift.Status == ShiftStatus.Archived) return StatusCode(StatusCodes.Status410Gone);
|
||||||
MapKey = (await _settings.GetAsync()).NeshanMapKey;
|
MapKey = (await _settings.GetAsync()).NeshanMapKey;
|
||||||
|
LikeCount = await _db.Likes.CountAsync(l => l.TargetType == LikeTargetType.Shift && l.TargetId == id);
|
||||||
|
var meId = int.TryParse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var n) ? n : (int?)null;
|
||||||
|
IsLiked = meId is int u && await _db.Likes.AnyAsync(l => l.UserId == u && l.TargetType == LikeTargetType.Shift && l.TargetId == id);
|
||||||
Reported = Request.Query["reported"] == "1";
|
Reported = Request.Query["reported"] == "1";
|
||||||
await _interest.LogAsync(InterestEventType.View, id); // behavioral signal for recommendations
|
await _interest.LogAsync(InterestEventType.View, id); // behavioral signal for recommendations
|
||||||
return Page();
|
return Page();
|
||||||
@@ -69,6 +77,7 @@ public class DetailsModel : PageModel
|
|||||||
Shift = await _db.Shifts
|
Shift = await _db.Shifts
|
||||||
.Include(s => s.Facility).ThenInclude(f => f.City)
|
.Include(s => s.Facility).ThenInclude(f => f.City)
|
||||||
.Include(s => s.Role)
|
.Include(s => s.Role)
|
||||||
|
.Include(s => s.Contacts)
|
||||||
.FirstOrDefaultAsync(s => s.Id == id);
|
.FirstOrDefaultAsync(s => s.Id == id);
|
||||||
|
|
||||||
if (Shift is not null)
|
if (Shift is not null)
|
||||||
|
|||||||
@@ -1,23 +1,39 @@
|
|||||||
@page
|
@page
|
||||||
@model JobsMedical.Web.Pages.Shifts.IndexModel
|
@model JobsMedical.Web.Pages.Shifts.IndexModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "شیفتهای موجود";
|
// Title/description are set in the page model (SetSeo) from the active role/city.
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>شیفتهای موجود</h1>
|
<partial name="_Breadcrumbs" model="Model.Breadcrumbs" />
|
||||||
|
<h1>@Model.PageHeading</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) شیفت باز پیدا شد
|
@JalaliDate.ToPersianDigits(Model.TotalCount.ToString()) شیفت باز پیدا شد
|
||||||
@if (Model.NearMeActive)
|
@if (Model.NearMeActive)
|
||||||
{
|
{
|
||||||
<span> — مرتبشده بر اساس نزدیکترین به شما 📍</span>
|
<span> — مرتبشده بر اساس نزدیکترین به شما 📍</span>
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
|
@if (!string.IsNullOrEmpty(Model.PageIntro))
|
||||||
|
{
|
||||||
|
<p class="muted" style="max-width:720px; font-size:13.5px; line-height:1.9;">@Model.PageIntro</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container section">
|
<div class="container section">
|
||||||
|
@if (Model.Roles.Count > 0)
|
||||||
|
{
|
||||||
|
@* Internal links to the SEO landing pages (/شیفت/{نقش}); this page is itself such a page. *@
|
||||||
|
<div class="role-links">
|
||||||
|
<span class="rl-label">شیفت بر اساس نقش:</span>
|
||||||
|
@foreach (var r in Model.Roles.Take(14))
|
||||||
|
{
|
||||||
|
<a class="rl-chip" href="/شیفت/@JobsMedical.Web.Services.SeoSlug.Of(r.Name)">@r.Name</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="layout-2">
|
<div class="layout-2">
|
||||||
<aside class="card card-pad filter-card">
|
<aside class="card card-pad filter-card">
|
||||||
<h3>فیلترها</h3>
|
<h3>فیلترها</h3>
|
||||||
@@ -129,6 +145,7 @@
|
|||||||
<partial name="_ShiftCard" model="s" />
|
<partial name="_ShiftCard" model="s" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<partial name="_Pager" model="(Model.CurrentPage, Model.TotalPages)" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,3 +175,12 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@section Head {
|
||||||
|
@{ var bcUrl = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
|
||||||
|
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.Breadcrumb(Model.Breadcrumbs, bcUrl) + "</script>")
|
||||||
|
@if (Model.Results.Count > 0)
|
||||||
|
{
|
||||||
|
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.ItemList(Model.Results.Select(s => "/Shifts/Details/" + s.Id), bcUrl) + "</script>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ public class IndexModel : PageModel
|
|||||||
[BindProperty(SupportsGet = true)] public double? Lat { get; set; }
|
[BindProperty(SupportsGet = true)] public double? Lat { get; set; }
|
||||||
[BindProperty(SupportsGet = true)] public double? Lng { get; set; }
|
[BindProperty(SupportsGet = true)] public double? Lng { get; set; }
|
||||||
|
|
||||||
|
// Pretty-URL segments (/شیفت/{roleSlug}/{citySlug?}); resolved to RoleId/CityId below.
|
||||||
|
[BindProperty(SupportsGet = true)] public string? RoleSlug { get; set; }
|
||||||
|
[BindProperty(SupportsGet = true)] public string? CitySlug { get; set; }
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true)] public int Page { get; set; } = 1;
|
||||||
|
private const int PageSize = 24;
|
||||||
|
public int TotalCount { get; private set; }
|
||||||
|
public int TotalPages { get; private set; }
|
||||||
|
public int CurrentPage { get; private set; }
|
||||||
|
|
||||||
public bool NearMeActive => Lat is not null && Lng is not null;
|
public bool NearMeActive => Lat is not null && Lng is not null;
|
||||||
|
|
||||||
public List<Shift> Results { get; private set; } = new();
|
public List<Shift> Results { get; private set; } = new();
|
||||||
@@ -33,12 +43,36 @@ public class IndexModel : PageModel
|
|||||||
public List<Role> Roles { get; private set; } = new();
|
public List<Role> Roles { get; private set; } = new();
|
||||||
public List<Facility> Facilities { get; private set; } = new();
|
public List<Facility> Facilities { get; private set; } = new();
|
||||||
|
|
||||||
public async Task OnGetAsync()
|
/// <summary>Dynamic page heading/H1 + title, set from the active role/city for SEO.</summary>
|
||||||
|
public string PageHeading { get; private set; } = "شیفتهای خالی";
|
||||||
|
|
||||||
|
/// <summary>A short unique intro shown on role/city landing pages (avoids thin-content).</summary>
|
||||||
|
public string? PageIntro { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Breadcrumb trail (also emitted as BreadcrumbList JSON-LD).</summary>
|
||||||
|
public IReadOnlyList<Crumb> Breadcrumbs { get; private set; } = Array.Empty<Crumb>();
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync()
|
||||||
{
|
{
|
||||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
|
||||||
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
|
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
|
||||||
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
|
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
|
||||||
|
|
||||||
|
// Pretty-URL landing: resolve slugs → filters (404 on a slug that matches nothing).
|
||||||
|
if (!string.IsNullOrWhiteSpace(RoleSlug))
|
||||||
|
{
|
||||||
|
var role = Roles.FirstOrDefault(r => SeoSlug.Matches(r.Name, RoleSlug));
|
||||||
|
if (role is null) return NotFound();
|
||||||
|
RoleId = role.Id;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(CitySlug))
|
||||||
|
{
|
||||||
|
var city = Cities.FirstOrDefault(c => SeoSlug.Matches(c.Name, CitySlug));
|
||||||
|
if (city is null) return NotFound();
|
||||||
|
CityId = city.Id;
|
||||||
|
}
|
||||||
|
|
||||||
Districts = await _db.Districts
|
Districts = await _db.Districts
|
||||||
.Where(d => d.IsActive && (CityId == null || d.CityId == CityId))
|
.Where(d => d.IsActive && (CityId == null || d.CityId == CityId))
|
||||||
.OrderBy(d => d.Name).ToListAsync();
|
.OrderBy(d => d.Name).ToListAsync();
|
||||||
@@ -63,24 +97,53 @@ public class IndexModel : PageModel
|
|||||||
if (GenderFilter is Gender g && g != Gender.Any)
|
if (GenderFilter is Gender g && g != Gender.Any)
|
||||||
q = q.Where(s => s.GenderRequirement == Gender.Any || s.GenderRequirement == g);
|
q = q.Where(s => s.GenderRequirement == Gender.Any || s.GenderRequirement == g);
|
||||||
|
|
||||||
var results = await q.ToListAsync();
|
TotalCount = await q.CountAsync();
|
||||||
|
TotalPages = Math.Max(1, (int)Math.Ceiling(TotalCount / (double)PageSize));
|
||||||
|
CurrentPage = Math.Clamp(Page, 1, TotalPages);
|
||||||
|
var skip = (CurrentPage - 1) * PageSize;
|
||||||
|
|
||||||
if (NearMeActive)
|
if (NearMeActive)
|
||||||
{
|
{
|
||||||
// Compute distance to each facility, then nearest-first (shifts without coords last).
|
// Distance sort needs all rows in memory; paginate after sorting (shifts without coords last).
|
||||||
foreach (var s in results)
|
var all = await q.ToListAsync();
|
||||||
{
|
foreach (var s in all)
|
||||||
if (s.Facility.Lat is double flat && s.Facility.Lng is double flng)
|
if (s.Facility.Lat is double flat && s.Facility.Lng is double flng)
|
||||||
s.DistanceKm = Geo.DistanceKm(Lat!.Value, Lng!.Value, flat, flng);
|
s.DistanceKm = Geo.DistanceKm(Lat!.Value, Lng!.Value, flat, flng);
|
||||||
}
|
Results = all
|
||||||
Results = results
|
|
||||||
.OrderBy(s => s.DistanceKm ?? double.MaxValue)
|
.OrderBy(s => s.DistanceKm ?? double.MaxValue)
|
||||||
.ThenBy(s => s.Date).ThenBy(s => s.StartTime)
|
.ThenBy(s => s.Date).ThenBy(s => s.StartTime)
|
||||||
.ToList();
|
.Skip(skip).Take(PageSize).ToList();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Results = results.OrderBy(s => s.Date).ThenBy(s => s.StartTime).ToList();
|
Results = await q.OrderBy(s => s.Date).ThenBy(s => s.StartTime)
|
||||||
|
.Skip(skip).Take(PageSize).ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SetSeo(Roles.FirstOrDefault(r => r.Id == RoleId)?.Name, Cities.FirstOrDefault(c => c.Id == CityId)?.Name);
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Title/H1/meta from the active role+city so the page targets «شیفت [نقش] [شهر]».</summary>
|
||||||
|
private void SetSeo(string? role, string? city)
|
||||||
|
{
|
||||||
|
PageHeading =
|
||||||
|
role is not null && city is not null ? $"شیفت {role} در {city}"
|
||||||
|
: role is not null ? $"شیفت {role}"
|
||||||
|
: city is not null ? $"شیفت کادر درمان در {city}"
|
||||||
|
: "شیفتهای خالی";
|
||||||
|
ViewData["Title"] = PageHeading;
|
||||||
|
ViewData["Description"] = role is not null || city is not null
|
||||||
|
? $"جدیدترین {PageHeading} در همکادر؛ مشاهده شیفتها و تماس مستقیم با مراکز درمانی."
|
||||||
|
: "شیفتهای خالی کادر درمان (پزشک، پرستار، ماما، تکنسین) در بیمارستانها و کلینیکهای تهران — همکادر.";
|
||||||
|
if (role is not null || city is not null)
|
||||||
|
PageIntro = $"در این صفحه جدیدترین {PageHeading}، گردآوریشده از منابع معتبر، را میبینید. "
|
||||||
|
+ "روی هر شیفت بزنید تا تاریخ، ساعت، پرداخت و راهِ تماسِ مستقیم با مرکز درمانی نمایش داده شود. "
|
||||||
|
+ "برای موارد مرتبط، نقش یا شهر دیگری را از لینکهای بالا انتخاب کنید.";
|
||||||
|
|
||||||
|
var crumbs = new List<Crumb> { new("خانه", "/"), new("شیفتها", "/Shifts") };
|
||||||
|
if (role is not null) crumbs.Add(new(role, "/شیفت/" + SeoSlug.Of(role)));
|
||||||
|
if (city is not null) crumbs.Add(new(city, null));
|
||||||
|
Breadcrumbs = crumbs;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,16 +13,6 @@
|
|||||||
comp = JalaliDate.Toman(pa) + " مدنظر";
|
comp = JalaliDate.Toman(pa) + " مدنظر";
|
||||||
else
|
else
|
||||||
comp = "توافقی";
|
comp = "توافقی";
|
||||||
string? telHref = null;
|
|
||||||
if (!string.IsNullOrWhiteSpace(t.Phone))
|
|
||||||
{
|
|
||||||
var digits = new string(t.Phone.Where(char.IsDigit).ToArray());
|
|
||||||
if (digits.Length >= 7) telHref = "tel:" + digits;
|
|
||||||
}
|
|
||||||
// Only Divar is surfaced as a fallback source (and only when no number was extracted).
|
|
||||||
// We never name other crawl sources (medjobs/telegram/…) publicly.
|
|
||||||
bool isDivar = !string.IsNullOrWhiteSpace(t.SourceUrl)
|
|
||||||
&& System.Uri.TryCreate(t.SourceUrl, UriKind.Absolute, out var su) && su.Host.Contains("divar");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
@@ -67,42 +57,40 @@
|
|||||||
<aside>
|
<aside>
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<h3 style="margin-top:0;">راههای ارتباطی</h3>
|
<h3 style="margin-top:0;">راههای ارتباطی</h3>
|
||||||
@{ var contacts = (t.Contacts ?? new List<JobsMedical.Web.Models.ContactMethod>()).OrderBy(c => c.SortOrder).ToList(); }
|
<button type="button" class="btn btn-accent btn-block btn-lg contact-trigger"
|
||||||
@if (contacts.Count > 0)
|
data-contact-type="talent" data-contact-id="@t.Id">📞 مشاهده راههای ارتباطی</button>
|
||||||
{
|
<p class="muted" style="font-size:12px; margin:10px 0 0;">با کلیک، شماره تماس و راههای ارتباطی نمایش داده میشود.</p>
|
||||||
<div class="contact-reveal">
|
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-block like-trigger" style="margin-top:10px;"
|
||||||
@foreach (var c in contacts)
|
data-like-type="talent" data-like-id="@t.Id" data-liked="@(Model.IsLiked ? "true" : "false")">
|
||||||
{
|
<span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> پسندیدم
|
||||||
var href = JobsMedical.Web.Services.ContactInfo.Href(c.Type, c.Value);
|
<span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
|
||||||
var label = JobsMedical.Web.Services.ContactInfo.Label(c.Type);
|
</button>
|
||||||
var icon = JobsMedical.Web.Services.ContactInfo.Icon(c.Type);
|
|
||||||
var cls = c.Type is JobsMedical.Web.Models.ContactType.Mobile or JobsMedical.Web.Models.ContactType.Phone ? "btn-accent" : "btn-outline";
|
|
||||||
<div class="contact-row">
|
|
||||||
<span class="c-meta"><span class="c-type">@icon @label</span><span class="c-val" dir="ltr">@c.Value</span></span>
|
|
||||||
@if (href is not null)
|
|
||||||
{
|
|
||||||
<a class="btn @cls" href="@href" target="_blank" rel="nofollow noopener">باز کردن</a>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
</div>
|
@if (t.Lat is not null && t.Lng is not null)
|
||||||
}
|
|
||||||
else if (telHref is not null)
|
|
||||||
{
|
{
|
||||||
<a href="@telHref" class="btn btn-accent btn-block btn-lg" dir="ltr">📞 @t.Phone</a>
|
var latS = t.Lat.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
}
|
var lngS = t.Lng.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
else if (isDivar)
|
<div class="card card-pad" style="margin-top:16px;">
|
||||||
|
<h3 style="margin-top:0;">موقعیت تقریبی</h3>
|
||||||
|
@if (!string.IsNullOrEmpty(Model.MapKey))
|
||||||
{
|
{
|
||||||
@* Divar hides the number behind a login-gated reveal — point to the original ad. *@
|
<div id="facmap" data-lat="@latS" data-lng="@lngS" data-approx="true" style="height:200px; border-radius:10px; overflow:hidden; border:1px solid var(--line);"></div>
|
||||||
<p class="muted" style="margin-top:0;">شماره مستقیم استخراج نشد.</p>
|
|
||||||
<a href="@t.SourceUrl" target="_blank" rel="nofollow noopener" class="btn btn-accent btn-block btn-lg">مشاهده شماره در دیوار ↗</a>
|
|
||||||
<p class="muted" style="font-size:12px; margin:10px 0 0;">برای دریافت شماره به آگهی اصلی در دیوار مراجعه کن.</p>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<p class="muted">شماره تماس ثبت نشده است.</p>
|
<div style="background:var(--primary-soft); border-radius:10px; height:140px; display:grid; place-items:center; color:var(--primary-dark); text-align:center; padding:10px;">
|
||||||
}
|
🗺️<br /><small class="muted" dir="ltr">@latS، @lngS</small>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
<p class="muted" style="font-size:12px; margin:8px 0 0;">📍 محدودهٔ تقریبیِ فعالیت (برگرفته از آگهی منبع)؛ موقعیت دقیق نیست.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.MapKey) && t.Lat is not null)
|
||||||
|
{
|
||||||
|
<partial name="_NeshanMap" model="Model.MapKey" />
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,9 +9,18 @@ namespace JobsMedical.Web.Pages.Talent;
|
|||||||
public class DetailsModel : PageModel
|
public class DetailsModel : PageModel
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
public DetailsModel(AppDbContext db) => _db = db;
|
private readonly JobsMedical.Web.Services.Scraping.SettingsService _settings;
|
||||||
|
|
||||||
|
public DetailsModel(AppDbContext db, JobsMedical.Web.Services.Scraping.SettingsService settings)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
public TalentListing? Item { get; private set; }
|
public TalentListing? Item { get; private set; }
|
||||||
|
public string? MapKey { get; private set; }
|
||||||
|
public int LikeCount { get; private set; }
|
||||||
|
public bool IsLiked { get; private set; }
|
||||||
|
|
||||||
public async Task<IActionResult> OnGetAsync(int id)
|
public async Task<IActionResult> OnGetAsync(int id)
|
||||||
{
|
{
|
||||||
@@ -22,6 +31,10 @@ public class DetailsModel : PageModel
|
|||||||
.Include(t => t.Contacts)
|
.Include(t => t.Contacts)
|
||||||
.FirstOrDefaultAsync(t => t.Id == id);
|
.FirstOrDefaultAsync(t => t.Id == id);
|
||||||
if (Item is null) return NotFound();
|
if (Item is null) return NotFound();
|
||||||
|
MapKey = (await _settings.GetAsync()).NeshanMapKey;
|
||||||
|
LikeCount = await _db.Likes.CountAsync(l => l.TargetType == LikeTargetType.Talent && l.TargetId == id);
|
||||||
|
var meId = int.TryParse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var n) ? n : (int?)null;
|
||||||
|
IsLiked = meId is int u && await _db.Likes.AnyAsync(l => l.UserId == u && l.TargetType == LikeTargetType.Talent && l.TargetId == id);
|
||||||
return Page();
|
return Page();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
@page
|
@page
|
||||||
@model JobsMedical.Web.Pages.Talent.IndexModel
|
@model JobsMedical.Web.Pages.Talent.IndexModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "آماده به کار — کادر درمان";
|
// Title/description are set in the page model (from the active role/city).
|
||||||
ViewData["Description"] = "فهرست کادر درمان آماده همکاری (پزشک، پرستار، ماما، تکنسین و…) در تهران و سایر شهرها — مرکز درمانی میتواند مستقیم تماس بگیرد.";
|
|
||||||
ViewData["q"] = Model.Q; // drives result highlighting in cards
|
ViewData["q"] = Model.Q; // drives result highlighting in cards
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>آماده به کار</h1>
|
<h1>@Model.PageHeading</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) نیروی کادر درمان آمادهی همکاری —
|
@JalaliDate.ToPersianDigits(Model.TotalCount.ToString()) نیروی کادر درمان آمادهی همکاری —
|
||||||
مراکز درمانی میتوانند مستقیم تماس بگیرند.
|
مراکز درمانی میتوانند مستقیم تماس بگیرند.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,6 +83,7 @@
|
|||||||
<partial name="_TalentCard" model="t" />
|
<partial name="_TalentCard" model="t" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<partial name="_Pager" model="(Model.CurrentPage, Model.TotalPages)" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,12 +17,20 @@ public class IndexModel : PageModel
|
|||||||
[BindProperty(SupportsGet = true)] public int? RoleId { get; set; }
|
[BindProperty(SupportsGet = true)] public int? RoleId { get; set; }
|
||||||
[BindProperty(SupportsGet = true)] public Gender? GenderFilter { get; set; }
|
[BindProperty(SupportsGet = true)] public Gender? GenderFilter { get; set; }
|
||||||
[BindProperty(SupportsGet = true)] public string? Q { get; set; } // deep search
|
[BindProperty(SupportsGet = true)] public string? Q { get; set; } // deep search
|
||||||
|
[BindProperty(SupportsGet = true)] public int Page { get; set; } = 1;
|
||||||
|
private const int PageSize = 24;
|
||||||
|
public int TotalCount { get; private set; }
|
||||||
|
public int TotalPages { get; private set; }
|
||||||
|
public int CurrentPage { get; private set; }
|
||||||
|
|
||||||
public List<TalentListing> Results { get; private set; } = new();
|
public List<TalentListing> Results { get; private set; } = new();
|
||||||
public List<City> Cities { get; private set; } = new();
|
public List<City> Cities { get; private set; } = new();
|
||||||
public List<District> Districts { get; private set; } = new();
|
public List<District> Districts { get; private set; } = new();
|
||||||
public List<Role> Roles { get; private set; } = new();
|
public List<Role> Roles { get; private set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Dynamic page heading/H1 + title, set from the active role/city for SEO.</summary>
|
||||||
|
public string PageHeading { get; private set; } = "کادر درمان آماده به کار";
|
||||||
|
|
||||||
public async Task OnGetAsync()
|
public async Task OnGetAsync()
|
||||||
{
|
{
|
||||||
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
|
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
|
||||||
@@ -57,6 +65,20 @@ public class IndexModel : PageModel
|
|||||||
EF.Functions.ILike(t.City.Name, like));
|
EF.Functions.ILike(t.City.Name, like));
|
||||||
}
|
}
|
||||||
|
|
||||||
Results = await q.OrderByDescending(t => t.CreatedAt).ToListAsync();
|
TotalCount = await q.CountAsync();
|
||||||
|
TotalPages = Math.Max(1, (int)Math.Ceiling(TotalCount / (double)PageSize));
|
||||||
|
CurrentPage = Math.Clamp(Page, 1, TotalPages);
|
||||||
|
Results = await q.OrderByDescending(t => t.CreatedAt)
|
||||||
|
.Skip((CurrentPage - 1) * PageSize).Take(PageSize).ToListAsync();
|
||||||
|
|
||||||
|
var role = Roles.FirstOrDefault(r => r.Id == RoleId)?.Name;
|
||||||
|
var city = Cities.FirstOrDefault(c => c.Id == CityId)?.Name;
|
||||||
|
PageHeading =
|
||||||
|
role is not null && city is not null ? $"{role} آماده به کار در {city}"
|
||||||
|
: role is not null ? $"{role} آماده به کار"
|
||||||
|
: city is not null ? $"کادر درمان آماده به کار در {city}"
|
||||||
|
: "کادر درمان آماده به کار";
|
||||||
|
ViewData["Title"] = PageHeading;
|
||||||
|
ViewData["Description"] = $"فهرست «آماده به کار» {(role ?? "کادر درمان")}{(city is not null ? " در " + city : "")} — همکادر؛ مشاهده و تماس مستقیم.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+209
-21
@@ -12,7 +12,16 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
builder.Services.AddRazorPages();
|
builder.Services.AddRazorPages(options =>
|
||||||
|
{
|
||||||
|
// Pretty SEO landing routes that target «استخدام [نقش] [شهر]» / «شیفت …» searches, in addition
|
||||||
|
// to the query-string forms (/Jobs?RoleId=…&CityId=…). The page resolves the slugs to filters.
|
||||||
|
// roleSlug is OPTIONAL: a required slug made `asp-page="/Shifts/Index"` (with no slug) generate an
|
||||||
|
// empty href, breaking the nav «شیفتها/استخدام» and the homepage «مشاهده همه» links. Optional →
|
||||||
|
// generation succeeds (e.g. «/شیفت») and the slug landing pages still work.
|
||||||
|
options.Conventions.AddPageRoute("/Jobs/Index", "استخدام/{roleSlug?}/{citySlug?}");
|
||||||
|
options.Conventions.AddPageRoute("/Shifts/Index", "شیفت/{roleSlug?}/{citySlug?}");
|
||||||
|
});
|
||||||
|
|
||||||
// Interest tracking + recommendation engine.
|
// Interest tracking + recommendation engine.
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
@@ -54,6 +63,10 @@ builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
|
|||||||
JobsMedical.Web.Services.Scraping.DivarListingSource>();
|
JobsMedical.Web.Services.Scraping.DivarListingSource>();
|
||||||
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
|
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
|
||||||
JobsMedical.Web.Services.Scraping.MedjobsListingSource>();
|
JobsMedical.Web.Services.Scraping.MedjobsListingSource>();
|
||||||
|
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
|
||||||
|
JobsMedical.Web.Services.Scraping.IranEstekhdamListingSource>();
|
||||||
|
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
|
||||||
|
JobsMedical.Web.Services.Scraping.MedboomListingSource>();
|
||||||
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
|
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
|
||||||
JobsMedical.Web.Services.Scraping.WebsiteListingSource>();
|
JobsMedical.Web.Services.Scraping.WebsiteListingSource>();
|
||||||
builder.Services.AddScoped<JobsMedical.Web.Services.Scraping.ListingArchiver>();
|
builder.Services.AddScoped<JobsMedical.Web.Services.Scraping.ListingArchiver>();
|
||||||
@@ -134,6 +147,28 @@ app.UseMiddleware<VisitorCookieMiddleware>();
|
|||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
// HTML pages list live, fast-changing data (listings get archived between crawls). The CDN must NOT
|
||||||
|
// serve a stale homepage/detail copy — that's how an archived (410) listing can still appear as a
|
||||||
|
// card. Force revalidation on HTML; never let a private (logged-in) page be cached by the CDN, and
|
||||||
|
// Vary on the auth cookie so an anonymous copy is never handed to a logged-in visitor (or vice-versa).
|
||||||
|
// Static assets (css/js/fonts/images) are untouched — they keep MapStaticAssets' long cache headers.
|
||||||
|
app.Use(async (ctx, next) =>
|
||||||
|
{
|
||||||
|
ctx.Response.OnStarting(() =>
|
||||||
|
{
|
||||||
|
if (ctx.Response.ContentType is string ct && ct.StartsWith("text/html")
|
||||||
|
&& !ctx.Response.Headers.ContainsKey("Cache-Control"))
|
||||||
|
{
|
||||||
|
ctx.Response.Headers.CacheControl = ctx.User.Identity?.IsAuthenticated == true
|
||||||
|
? "private, no-store"
|
||||||
|
: "no-cache, must-revalidate";
|
||||||
|
ctx.Response.Headers.Vary = "Cookie";
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
app.MapStaticAssets();
|
app.MapStaticAssets();
|
||||||
app.MapRazorPages()
|
app.MapRazorPages()
|
||||||
.WithStaticAssets();
|
.WithStaticAssets();
|
||||||
@@ -282,15 +317,46 @@ app.MapPost("/report", async (HttpContext ctx, AppDbContext db, VisitorContext v
|
|||||||
return Results.Redirect((string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl) + "?reported=1");
|
return Results.Redirect((string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl) + "?reported=1");
|
||||||
}).DisableAntiforgery();
|
}).DisableAntiforgery();
|
||||||
|
|
||||||
|
// Toggle a logged-in user's «پسندیدن» of a listing; returns the new state + total like count.
|
||||||
|
app.MapPost("/like", async (HttpContext ctx, AppDbContext db, [FromForm] string type, [FromForm] int id) =>
|
||||||
|
{
|
||||||
|
int? uid = ctx.User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier) is { } c
|
||||||
|
&& int.TryParse(c.Value, out var n) ? n : null;
|
||||||
|
if (uid is null) return Results.Unauthorized();
|
||||||
|
if (!Enum.TryParse<LikeTargetType>(type, true, out var tt)) return Results.BadRequest();
|
||||||
|
|
||||||
|
var existing = await db.Likes.FirstOrDefaultAsync(l => l.UserId == uid && l.TargetType == tt && l.TargetId == id);
|
||||||
|
bool liked;
|
||||||
|
if (existing is null) { db.Likes.Add(new Like { UserId = uid.Value, TargetType = tt, TargetId = id }); liked = true; }
|
||||||
|
else { db.Likes.Remove(existing); liked = false; }
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var count = await db.Likes.CountAsync(l => l.TargetType == tt && l.TargetId == id);
|
||||||
|
return Results.Json(new { liked, count });
|
||||||
|
}).RequireAuthorization().DisableAntiforgery();
|
||||||
|
|
||||||
app.MapGet("/sw.js", () => Results.Content("""
|
app.MapGet("/sw.js", () => Results.Content("""
|
||||||
const CACHE = 'hamkadr-v1';
|
const CACHE = 'hamkadr-v2';
|
||||||
self.addEventListener('install', e => { self.skipWaiting(); e.waitUntil(caches.open(CACHE).then(c => c.addAll(['/']))); });
|
const OFFLINE = '<!doctype html><html lang="fa" dir="rtl"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>آفلاین</title><body style="font-family:Vazirmatn,system-ui,sans-serif;text-align:center;padding:48px 20px;color:#334155"><h2 style="margin:0 0 8px">اتصال اینترنت برقرار نیست</h2><p style="color:#64748b">صفحه باز نشد؛ اتصال خود را بررسی و دوباره تلاش کنید.</p><button onclick="location.reload()" style="margin-top:14px;padding:10px 24px;border:0;border-radius:10px;background:#0d9488;color:#fff;font:inherit;cursor:pointer">تلاش دوباره</button></body></html>';
|
||||||
|
self.addEventListener('install', e => { self.skipWaiting(); e.waitUntil(caches.open(CACHE).then(c => c.add('/'))); });
|
||||||
self.addEventListener('activate', e => { e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k))))); self.clients.claim(); });
|
self.addEventListener('activate', e => { e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k))))); self.clients.claim(); });
|
||||||
self.addEventListener('fetch', e => {
|
self.addEventListener('fetch', e => {
|
||||||
const req = e.request;
|
const req = e.request;
|
||||||
if (req.method !== 'GET' || new URL(req.url).origin !== location.origin) return;
|
if (req.method !== 'GET' || new URL(req.url).origin !== location.origin) return;
|
||||||
e.respondWith(fetch(req).then(res => { const copy = res.clone(); caches.open(CACHE).then(c => c.put(req, copy)); return res; })
|
// Page navigations ALWAYS go to the network so listings are fresh (never a stale/archived card).
|
||||||
.catch(() => caches.match(req).then(m => m || caches.match('/'))));
|
// We only fall back when the device is truly offline — to a cached copy of THAT exact page, or an
|
||||||
|
// offline notice. We never substitute the homepage for a detail page (that was the "clicking a job
|
||||||
|
// just shows the homepage" bug) and we never cache HTML, so a 410 can't poison the cache.
|
||||||
|
if (req.mode === 'navigate') {
|
||||||
|
e.respondWith(fetch(req).catch(() => caches.match(req).then(m =>
|
||||||
|
m || new Response(OFFLINE, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }))));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Static assets (css/js/fonts/images) are fingerprinted — cache-first is safe and fast.
|
||||||
|
e.respondWith(caches.match(req).then(hit => hit || fetch(req).then(res => {
|
||||||
|
if (res.ok && res.type === 'basic') { const copy = res.clone(); caches.open(CACHE).then(c => c.put(req, copy)); }
|
||||||
|
return res;
|
||||||
|
})));
|
||||||
});
|
});
|
||||||
self.addEventListener('push', e => {
|
self.addEventListener('push', e => {
|
||||||
let d = { title: 'همکادر', body: 'فرصت جدید برای شما', url: '/' };
|
let d = { title: 'همکادر', body: 'فرصت جدید برای شما', url: '/' };
|
||||||
@@ -355,10 +421,105 @@ app.MapGet("/sitemap.xml", async (HttpContext ctx, AppDbContext db) =>
|
|||||||
foreach (var fId in await db.Facilities.Select(f => f.Id).ToListAsync())
|
foreach (var fId in await db.Facilities.Select(f => f.Id).ToListAsync())
|
||||||
Url($"{b}/Facilities/Details/{fId}", null, "weekly");
|
Url($"{b}/Facilities/Details/{fId}", null, "weekly");
|
||||||
|
|
||||||
|
// SEO landing pages: role-only and role×city combos that actually have live listings, so
|
||||||
|
// Google indexes pages targeting «استخدام [نقش] [شهر]» / «شیفت …». URL-encode each segment.
|
||||||
|
var roleNames = await db.Roles.ToDictionaryAsync(r => r.Id, r => r.Name);
|
||||||
|
var cityNames = await db.Cities.ToDictionaryAsync(c => c.Id, c => c.Name);
|
||||||
|
string Seg(string s) => Uri.EscapeDataString(s);
|
||||||
|
void Landing(string kind, int roleId, int? cityId)
|
||||||
|
{
|
||||||
|
if (!roleNames.TryGetValue(roleId, out var role)) return;
|
||||||
|
var loc = $"{b}/{Seg(kind)}/{Seg(SeoSlug.Of(role))}";
|
||||||
|
if (cityId is int c && cityNames.TryGetValue(c, out var city)) loc += $"/{Seg(SeoSlug.Of(city))}";
|
||||||
|
Url(loc, null, "daily");
|
||||||
|
}
|
||||||
|
|
||||||
|
var jobCombos = await db.JobOpenings
|
||||||
|
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCutoff)
|
||||||
|
.Select(j => new { j.RoleId, j.Facility.CityId }).Distinct().ToListAsync();
|
||||||
|
foreach (var rid in jobCombos.Select(x => x.RoleId).Distinct()) Landing("استخدام", rid, null);
|
||||||
|
foreach (var x in jobCombos) Landing("استخدام", x.RoleId, x.CityId);
|
||||||
|
|
||||||
|
var shiftCombos = await db.Shifts
|
||||||
|
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today)
|
||||||
|
.Select(s => new { s.RoleId, s.Facility.CityId }).Distinct().ToListAsync();
|
||||||
|
foreach (var rid in shiftCombos.Select(x => x.RoleId).Distinct()) Landing("شیفت", rid, null);
|
||||||
|
foreach (var x in shiftCombos) Landing("شیفت", x.RoleId, x.CityId);
|
||||||
|
|
||||||
sb.Append("</urlset>");
|
sb.Append("</urlset>");
|
||||||
return Results.Content(sb.ToString(), "application/xml");
|
return Results.Content(sb.ToString(), "application/xml");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Contact reveal (modal): a listing's contact channels as JSON, fetched lazily on click so
|
||||||
|
// personal numbers never sit in list-page HTML. Logs the Apply interest signal for shift/job. ----
|
||||||
|
app.MapGet("/contact", async (string? type, int id, AppDbContext db, InterestService interest) =>
|
||||||
|
{
|
||||||
|
object Item(ContactType ct, string value) => new
|
||||||
|
{
|
||||||
|
icon = ContactInfo.Icon(ct), label = ContactInfo.Label(ct), value, href = ContactInfo.Href(ct, value),
|
||||||
|
};
|
||||||
|
|
||||||
|
string? title = null, fallbackUrl = null, fallbackLabel = null;
|
||||||
|
var items = new List<object>();
|
||||||
|
|
||||||
|
switch ((type ?? "").ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "shift":
|
||||||
|
{
|
||||||
|
var s = await db.Shifts.Include(x => x.Role).Include(x => x.Facility).Include(x => x.Contacts)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
|
if (s is null) return Results.NotFound();
|
||||||
|
title = s.Role?.Name ?? "تماس";
|
||||||
|
items.AddRange(s.Contacts.OrderBy(c => c.SortOrder).Select(c => Item(c.Type, c.Value)));
|
||||||
|
// Only fall back to the facility's number for a REAL named employer — the shared
|
||||||
|
// «نامشخص» placeholder's phone is NOT this ad's number (it leaked one number onto many posts).
|
||||||
|
if (SeoJsonLd.HasRealEmployer(s.Facility))
|
||||||
|
{
|
||||||
|
if (items.Count == 0 && !string.IsNullOrWhiteSpace(s.Facility!.Phone)) items.Add(Item(ContactType.Phone, s.Facility.Phone!));
|
||||||
|
if (!string.IsNullOrWhiteSpace(s.Facility!.BaleId)) items.Add(Item(ContactType.Bale, s.Facility.BaleId!));
|
||||||
|
}
|
||||||
|
if (items.Count == 0 && !string.IsNullOrWhiteSpace(s.SourceUrl)
|
||||||
|
&& Uri.TryCreate(s.SourceUrl, UriKind.Absolute, out var ss) && ss.AbsolutePath.Trim('/').Length > 0)
|
||||||
|
{ fallbackUrl = s.SourceUrl; fallbackLabel = ss.Host.Contains("divar") ? "مشاهده شماره در دیوار ↗" : "مشاهده آگهی در منبع ↗"; }
|
||||||
|
await interest.LogAsync(InterestEventType.Apply, id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "job":
|
||||||
|
{
|
||||||
|
var j = await db.JobOpenings.Include(x => x.Role).Include(x => x.Facility).Include(x => x.Contacts)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
|
if (j is null) return Results.NotFound();
|
||||||
|
title = j.Title;
|
||||||
|
items.AddRange(j.Contacts.OrderBy(c => c.SortOrder).Select(c => Item(c.Type, c.Value)));
|
||||||
|
if (SeoJsonLd.HasRealEmployer(j.Facility))
|
||||||
|
{
|
||||||
|
if (items.Count == 0 && !string.IsNullOrWhiteSpace(j.Facility!.Phone)) items.Add(Item(ContactType.Phone, j.Facility.Phone!));
|
||||||
|
if (!string.IsNullOrWhiteSpace(j.Facility!.BaleId)) items.Add(Item(ContactType.Bale, j.Facility.BaleId!));
|
||||||
|
}
|
||||||
|
if (items.Count == 0 && !string.IsNullOrWhiteSpace(j.SourceUrl)
|
||||||
|
&& Uri.TryCreate(j.SourceUrl, UriKind.Absolute, out var js) && js.AbsolutePath.Trim('/').Length > 0)
|
||||||
|
{ fallbackUrl = j.SourceUrl; fallbackLabel = js.Host.Contains("divar") ? "مشاهده شماره در دیوار ↗" : "مشاهده آگهی در منبع ↗"; }
|
||||||
|
await interest.LogJobAsync(InterestEventType.Apply, id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "talent":
|
||||||
|
{
|
||||||
|
var t = await db.TalentListings.Include(x => x.Role).Include(x => x.Contacts)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
|
if (t is null) return Results.NotFound();
|
||||||
|
title = string.IsNullOrWhiteSpace(t.PersonName) ? (t.Role?.Name ?? "آماده به کار") : t.PersonName;
|
||||||
|
items.AddRange(t.Contacts.OrderBy(c => c.SortOrder).Select(c => Item(c.Type, c.Value)));
|
||||||
|
if (items.Count == 0 && !string.IsNullOrWhiteSpace(t.Phone)) items.Add(Item(ContactType.Mobile, t.Phone!));
|
||||||
|
if (items.Count == 0 && !string.IsNullOrWhiteSpace(t.SourceUrl)
|
||||||
|
&& Uri.TryCreate(t.SourceUrl, UriKind.Absolute, out var su) && su.AbsolutePath.Trim('/').Length > 0)
|
||||||
|
{ fallbackUrl = t.SourceUrl; fallbackLabel = su.Host.Contains("divar") ? "مشاهده شماره در دیوار ↗" : "مشاهده آگهی در منبع ↗"; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: return Results.BadRequest();
|
||||||
|
}
|
||||||
|
return Results.Json(new { title, contacts = items, fallbackUrl, fallbackLabel });
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Instant search suggestions (typeahead dropdown) ----
|
// ---- Instant search suggestions (typeahead dropdown) ----
|
||||||
app.MapGet("/search/suggest", async (string? q, AppDbContext db) =>
|
app.MapGet("/search/suggest", async (string? q, AppDbContext db) =>
|
||||||
{
|
{
|
||||||
@@ -369,21 +530,48 @@ app.MapGet("/search/suggest", async (string? q, AppDbContext db) =>
|
|||||||
var jobCut = JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc;
|
var jobCut = JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc;
|
||||||
var talentCut = JobsMedical.Web.Services.Scraping.ListingPolicy.TalentCutoffUtc;
|
var talentCut = JobsMedical.Web.Services.Scraping.ListingPolicy.TalentCutoffUtc;
|
||||||
|
|
||||||
var shifts = await db.Shifts
|
// Plain (un-marked) snippet around the first occurrence of the term — the client highlights it.
|
||||||
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today &&
|
static string? Snip(string? text, string term, string? fallback)
|
||||||
(EF.Functions.ILike(s.Facility.Name, like) || EF.Functions.ILike(s.Role.Name, like) || EF.Functions.ILike(s.SpecialtyRequired, like)))
|
{
|
||||||
.OrderByDescending(s => s.CreatedAt).Take(5)
|
if (!string.IsNullOrWhiteSpace(text))
|
||||||
.Select(s => new SuggestItem("شیفت", s.Role.Name + " — " + s.Facility.Name, "/Shifts/Details/" + s.Id, s.Facility.City.Name + " · " + s.SpecialtyRequired)).ToListAsync();
|
{
|
||||||
var jobs = await db.JobOpenings
|
var flat = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ").Trim();
|
||||||
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCut &&
|
var i = flat.IndexOf(term, StringComparison.OrdinalIgnoreCase);
|
||||||
(EF.Functions.ILike(j.Title, like) || EF.Functions.ILike(j.Facility.Name, like) || EF.Functions.ILike(j.Role.Name, like)))
|
if (i >= 0)
|
||||||
.OrderByDescending(j => j.CreatedAt).Take(5)
|
{
|
||||||
.Select(j => new SuggestItem("استخدام", j.Title, "/Jobs/Details/" + j.Id, j.Facility.Name + " · " + j.Facility.City.Name)).ToListAsync();
|
var start = Math.Max(0, i - 40);
|
||||||
var talent = await db.TalentListings
|
var end = Math.Min(flat.Length, i + term.Length + 40);
|
||||||
.Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= talentCut &&
|
return (start > 0 ? "…" : "") + flat.Substring(start, end - start) + (end < flat.Length ? "…" : "");
|
||||||
(EF.Functions.ILike(t.Tags ?? "", like) || EF.Functions.ILike(t.Role.Name, like) || EF.Functions.ILike(t.City.Name, like) || EF.Functions.ILike(t.PersonName ?? "", like)))
|
}
|
||||||
.OrderByDescending(t => t.CreatedAt).Take(5)
|
}
|
||||||
.Select(t => new SuggestItem("آمادهبهکار", (t.PersonName ?? t.Role.Name) + " — " + t.City.Name, "/Talent/Details/" + t.Id, t.Tags)).ToListAsync();
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define each filtered query once, then reuse it for BOTH the Take(5) preview and the total count.
|
||||||
|
var shiftQ = db.Shifts.Where(s => s.Status == ShiftStatus.Open && s.Date >= today &&
|
||||||
|
(EF.Functions.ILike(s.Facility.Name, like) || EF.Functions.ILike(s.Role.Name, like)
|
||||||
|
|| EF.Functions.ILike(s.SpecialtyRequired, like) || EF.Functions.ILike(s.Description ?? "", like)));
|
||||||
|
var jobQ = db.JobOpenings.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCut &&
|
||||||
|
(EF.Functions.ILike(j.Title, like) || EF.Functions.ILike(j.Facility.Name, like)
|
||||||
|
|| EF.Functions.ILike(j.Role.Name, like) || EF.Functions.ILike(j.Description ?? "", like)));
|
||||||
|
var talentQ = db.TalentListings.Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= talentCut &&
|
||||||
|
(EF.Functions.ILike(t.Tags ?? "", like) || EF.Functions.ILike(t.Role.Name, like) || EF.Functions.ILike(t.City.Name, like)
|
||||||
|
|| EF.Functions.ILike(t.PersonName ?? "", like) || EF.Functions.ILike(t.Description ?? "", like)));
|
||||||
|
|
||||||
|
var shiftRows = await shiftQ.OrderByDescending(s => s.CreatedAt).Take(5)
|
||||||
|
.Select(s => new { s.Id, Role = s.Role.Name, Fac = s.Facility.Name, City = s.Facility.City.Name, s.Description }).ToListAsync();
|
||||||
|
var shifts = shiftRows.Select(s => new SuggestItem("شیفت", s.Role + " — " + s.Fac, "/Shifts/Details/" + s.Id, Snip(s.Description, term, s.City))).ToList();
|
||||||
|
|
||||||
|
var jobRows = await jobQ.OrderByDescending(j => j.CreatedAt).Take(5)
|
||||||
|
.Select(j => new { j.Id, j.Title, Fac = j.Facility.Name, City = j.Facility.City.Name, j.Description }).ToListAsync();
|
||||||
|
var jobs = jobRows.Select(j => new SuggestItem("استخدام", j.Title, "/Jobs/Details/" + j.Id, Snip(j.Description, term, j.Fac + " · " + j.City))).ToList();
|
||||||
|
|
||||||
|
var talentRows = await talentQ.OrderByDescending(t => t.CreatedAt).Take(5)
|
||||||
|
.Select(t => new { t.Id, t.PersonName, Role = t.Role.Name, City = t.City.Name, t.Tags, t.Description }).ToListAsync();
|
||||||
|
var talent = talentRows.Select(t => new SuggestItem("آمادهبهکار", (t.PersonName ?? t.Role) + " — " + t.City, "/Talent/Details/" + t.Id, Snip(t.Description ?? t.Tags, term, t.Tags))).ToList();
|
||||||
|
|
||||||
|
// Total matches across all three types (drives the result count shown in the dropdown).
|
||||||
|
var total = await shiftQ.CountAsync() + await jobQ.CountAsync() + await talentQ.CountAsync();
|
||||||
|
|
||||||
// round-robin merge so all three types appear, capped at 5
|
// round-robin merge so all three types appear, capped at 5
|
||||||
var merged = new List<SuggestItem>();
|
var merged = new List<SuggestItem>();
|
||||||
@@ -393,7 +581,7 @@ app.MapGet("/search/suggest", async (string? q, AppDbContext db) =>
|
|||||||
if (merged.Count < 5 && i < jobs.Count) merged.Add(jobs[i]);
|
if (merged.Count < 5 && i < jobs.Count) merged.Add(jobs[i]);
|
||||||
if (merged.Count < 5 && i < talent.Count) merged.Add(talent[i]);
|
if (merged.Count < 5 && i < talent.Count) merged.Add(talent[i]);
|
||||||
}
|
}
|
||||||
return Results.Json(merged.Take(5));
|
return Results.Json(new { items = merged.Take(5), total });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -25,6 +25,36 @@ public static class JalaliDate
|
|||||||
|
|
||||||
private static readonly char[] PersianDigits = { '۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹' };
|
private static readonly char[] PersianDigits = { '۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹' };
|
||||||
|
|
||||||
|
/// <summary>Convert a UTC timestamp (we store everything as <c>DateTime.UtcNow</c>) to Tehran
|
||||||
|
/// wall-clock. Iran is a fixed UTC+3:30 (DST abolished in 2022), so a flat offset is exact and
|
||||||
|
/// needs no timezone database.</summary>
|
||||||
|
public static DateTime ToTehran(DateTime utc) => utc.AddMinutes(210);
|
||||||
|
|
||||||
|
/// <summary>Relative "added X ago" in Persian from a UTC timestamp: «همین حالا»، «۵ دقیقه پیش»،
|
||||||
|
/// «۲ ساعت پیش»، «۳ روز پیش»، «۲ هفته پیش»، «۴ ماه پیش»، «۱ سال پیش».</summary>
|
||||||
|
public static string TimeAgo(DateTime utc)
|
||||||
|
{
|
||||||
|
var span = DateTime.UtcNow - utc;
|
||||||
|
if (span < TimeSpan.Zero) span = TimeSpan.Zero;
|
||||||
|
var mins = (int)span.TotalMinutes;
|
||||||
|
if (mins < 1) return "همین حالا";
|
||||||
|
if (mins < 60) return ToPersianDigits(mins.ToString()) + " دقیقه پیش";
|
||||||
|
var hours = (int)span.TotalHours;
|
||||||
|
if (hours < 24) return ToPersianDigits(hours.ToString()) + " ساعت پیش";
|
||||||
|
var days = (int)span.TotalDays;
|
||||||
|
if (days < 7) return ToPersianDigits(days.ToString()) + " روز پیش";
|
||||||
|
if (days < 30) return ToPersianDigits((days / 7).ToString()) + " هفته پیش";
|
||||||
|
if (days < 365) return ToPersianDigits((days / 30).ToString()) + " ماه پیش";
|
||||||
|
return ToPersianDigits((days / 365).ToString()) + " سال پیش";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Jalali date + Tehran time, e.g. «۳۰ خرداد ۱۴۰۵ ۱۶:۲۱» — for UTC-stored timestamps.</summary>
|
||||||
|
public static string DateTimeLabel(DateTime utc)
|
||||||
|
{
|
||||||
|
var t = ToTehran(utc);
|
||||||
|
return ToLongDate(DateOnly.FromDateTime(t)) + " " + ToPersianDigits(t.ToString("HH:mm"));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Convert Latin digits in a string to Persian digits.</summary>
|
/// <summary>Convert Latin digits in a string to Persian digits.</summary>
|
||||||
public static string ToPersianDigits(string input)
|
public static string ToPersianDigits(string input)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -69,8 +69,11 @@ public class HeuristicListingParser : IListingParser
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
p.Kind = (jobSignals && !shiftSignals) ? ListingKind.Job : ListingKind.Shift;
|
// A dated SHIFT requires an explicit shift signal («شیفت/آنکال/کشیک/نوبت»). Otherwise the ad
|
||||||
p.Notes.Add(p.Kind == ListingKind.Job ? "نوع: استخدام (تشخیص خودکار)" : "نوع: شیفت (تشخیص خودکار)");
|
// is an ongoing hiring post → Job. (Defaulting to Shift forced a fabricated date/time onto
|
||||||
|
// generic ads like «پرستار درمانگاه», which the source never stated.)
|
||||||
|
p.Kind = shiftSignals ? ListingKind.Shift : ListingKind.Job;
|
||||||
|
p.Notes.Add(p.Kind == ListingKind.Shift ? "نوع: شیفت (تشخیص خودکار)" : "نوع: استخدام (تشخیص خودکار)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Roles (an ad can name several at once: «پرستار سالمند و کودک و همراه بیمار») ---
|
// --- Roles (an ad can name several at once: «پرستار سالمند و کودک و همراه بیمار») ---
|
||||||
@@ -137,13 +140,12 @@ public class HeuristicListingParser : IListingParser
|
|||||||
{ p.Notes.Add("پرداخت درصدی/سهمی (درصد نامشخص)"); }
|
{ p.Notes.Add("پرداخت درصدی/سهمی (درصد نامشخص)"); }
|
||||||
|
|
||||||
// --- Fixed pay (strip phone numbers first so they're never read as money) ---
|
// --- Fixed pay (strip phone numbers first so they're never read as money) ---
|
||||||
if (ContainsAny(text, "توافقی", "توافق")) { p.PayNegotiable = true; p.Notes.Add("حقوق: توافقی"); }
|
// A STATED amount wins over «توافقی»: ads often say a number AND «… بقیه توافقی»; showing the
|
||||||
else
|
// figure is far more useful than «توافقی». Fall back to negotiable only when no amount is found.
|
||||||
{
|
|
||||||
var amount = ExtractAmount(StripPhones(text));
|
var amount = ExtractAmount(StripPhones(text));
|
||||||
if (amount is not null) { p.PayAmount = amount; p.Notes.Add($"حقوق تخمینی: {amount:#,0} تومان"); }
|
if (amount is not null) { p.PayAmount = amount; p.Notes.Add($"حقوق تخمینی: {amount:#,0} تومان"); }
|
||||||
|
else if (ContainsAny(text, "توافقی", "توافق")) { p.PayNegotiable = true; p.Notes.Add("حقوق: توافقی"); }
|
||||||
else if (p.SharePercent is null) p.Notes.Add("حقوق: تشخیص داده نشد");
|
else if (p.SharePercent is null) p.Notes.Add("حقوق: تشخیص داده نشد");
|
||||||
}
|
|
||||||
|
|
||||||
// --- Talent extras (only meaningful for «آماده به کار») ---
|
// --- Talent extras (only meaningful for «آماده به کار») ---
|
||||||
if (p.Kind == ListingKind.Talent)
|
if (p.Kind == ListingKind.Talent)
|
||||||
@@ -218,12 +220,17 @@ public class HeuristicListingParser : IListingParser
|
|||||||
{
|
{
|
||||||
if (NameStops.Contains(w)) break;
|
if (NameStops.Contains(w)) break;
|
||||||
if (Regex.IsMatch(w, @"\d")) break; // numbers/phones aren't names
|
if (Regex.IsMatch(w, @"\d")) break; // numbers/phones aren't names
|
||||||
|
if (!w.Any(char.IsLetter)) break; // emoji / punctuation («📍») isn't a name
|
||||||
if (w.Length == 1) break; // stray letters
|
if (w.Length == 1) break; // stray letters
|
||||||
picked.Add(w);
|
picked.Add(w);
|
||||||
if (picked.Count >= 3) break;
|
if (picked.Count >= 3) break;
|
||||||
}
|
}
|
||||||
if (picked.Count == 0) continue; // bare keyword (e.g. just «بیمارستان») isn't useful
|
if (picked.Count == 0) continue; // bare keyword (e.g. just «بیمارستان») isn't useful
|
||||||
return (kw + " " + string.Join(" ", picked)).Trim();
|
var candidate = (kw + " " + string.Join(" ", picked)).Trim();
|
||||||
|
// Reject names that are only filler/verb/source noise («بیمارستان هستم», «... از مدجابز») —
|
||||||
|
// a real name couldn't be extracted, so fall back to the shared placeholder downstream.
|
||||||
|
if (Scraping.FacilityMatcher.IsJunkName(candidate)) continue;
|
||||||
|
return candidate;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -231,6 +238,16 @@ public class HeuristicListingParser : IListingParser
|
|||||||
// Titles that introduce a person's name in «آماده به کار» posts.
|
// Titles that introduce a person's name in «آماده به کار» posts.
|
||||||
private static readonly string[] PersonTitles = { "دکتر", "خانم دکتر", "آقای دکتر", "مهندس", "سرکار خانم", "جناب آقای", "خانم", "آقای" };
|
private static readonly string[] PersonTitles = { "دکتر", "خانم دکتر", "آقای دکتر", "مهندس", "سرکار خانم", "جناب آقای", "خانم", "آقای" };
|
||||||
|
|
||||||
|
// Words that are NOT a person's name — verbs/fillers/availability/role words the extractor was
|
||||||
|
// grabbing after a title («خانم هستم»، «دکتر ام»، «دکتر داروساز آماده»). Stop collecting at one.
|
||||||
|
private static readonly string[] NameNoise =
|
||||||
|
{
|
||||||
|
"هستم", "هستیم", "هستش", "ام", "بودم", "میباشم", "میباشد", "باشم", "آماده", "آمادهام",
|
||||||
|
"جویای", "بکار", "بهکار", "کار", "همکاری", "نیازمند", "استخدام", "جذب", "عزیز", "محترم",
|
||||||
|
"گرامی", "خانم", "آقا", "اقا", "دکتر", "پزشک", "پرستار", "بهیار", "ماما", "دندانپزشک",
|
||||||
|
"داروساز", "تکنسین", "کارشناس", "متخصص", "عمومی", "مراقب", "کمک",
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>Best-effort person name: a title (دکتر/خانم/…) plus up to two following words.</summary>
|
/// <summary>Best-effort person name: a title (دکتر/خانم/…) plus up to two following words.</summary>
|
||||||
private static string? ExtractPersonName(string text)
|
private static string? ExtractPersonName(string text)
|
||||||
{
|
{
|
||||||
@@ -246,6 +263,7 @@ public class HeuristicListingParser : IListingParser
|
|||||||
foreach (var w in words)
|
foreach (var w in words)
|
||||||
{
|
{
|
||||||
if (NameStops.Contains(w)) break;
|
if (NameStops.Contains(w)) break;
|
||||||
|
if (NameNoise.Any(n => Normalize(n) == Normalize(w))) break; // «خانم هستم»/«دکتر ام»…
|
||||||
if (Regex.IsMatch(w, @"[\d]")) break;
|
if (Regex.IsMatch(w, @"[\d]")) break;
|
||||||
if (w.Length == 1) break;
|
if (w.Length == 1) break;
|
||||||
picked.Add(w);
|
picked.Add(w);
|
||||||
@@ -275,6 +293,14 @@ public class HeuristicListingParser : IListingParser
|
|||||||
bool hasToman = latin.Contains("تومان") || latin.Contains("تومن");
|
bool hasToman = latin.Contains("تومان") || latin.Contains("تومن");
|
||||||
bool hasRial = (latin.Contains("ریال") || latin.Contains("ريال")) && !hasToman;
|
bool hasRial = (latin.Contains("ریال") || latin.Contains("ريال")) && !hasToman;
|
||||||
|
|
||||||
|
// Iranian salary shorthand: a 1–3 digit number means MILLIONS of toman — «۱۵ تومان»،
|
||||||
|
// «۴۰ تا ۵۰ تومان»، «۲۰ میلیون»، «۲۰م». Take the LOWER bound of a range. The lookarounds keep
|
||||||
|
// this from ever matching part of a long literal-toman number (the digits must end at the unit).
|
||||||
|
var collo = Regex.Match(latin,
|
||||||
|
@"(?<!\d)(\d{1,3})(?:\s*تا\s*(\d{1,3}))?\s*(?:میلیون|م(?![ا-یA-Za-z])|تومان|تومن)(?!\s*\d)");
|
||||||
|
if (collo.Success && int.TryParse(collo.Groups[1].Value, out var lo) && lo is > 0 and <= 500)
|
||||||
|
return (long)lo * 1_000_000;
|
||||||
|
|
||||||
// e.g. "۲ میلیون" / "2.5 میلیون [ریال]"
|
// e.g. "۲ میلیون" / "2.5 میلیون [ریال]"
|
||||||
var million = Regex.Match(latin, @"(\d+(?:[.,]\d+)?)\s*میلیون\s*(ریال|ريال)?");
|
var million = Regex.Match(latin, @"(\d+(?:[.,]\d+)?)\s*میلیون\s*(ریال|ريال)?");
|
||||||
if (million.Success && double.TryParse(million.Groups[1].Value.Replace(",", "."),
|
if (million.Success && double.TryParse(million.Groups[1].Value.Replace(",", "."),
|
||||||
@@ -356,7 +382,9 @@ public class HeuristicListingParser : IListingParser
|
|||||||
if (d.Length == 10 && d[0] == '9') d = "0" + d;
|
if (d.Length == 10 && d[0] == '9') d = "0" + d;
|
||||||
Add(ContactType.Mobile, d);
|
Add(ContactType.Mobile, d);
|
||||||
}
|
}
|
||||||
foreach (Match m in Regex.Matches(latin, @"(?<!\d)0\d{2,3}[\s-]?\d{7,8}(?!\d)"))
|
// Landline area codes start 0[1-8] (021 Tehran, 026 Karaj, …) — never 09, which is a MOBILE.
|
||||||
|
// The old 0\d{2,3} matched 09xx numbers and mislabeled mobiles as «تلفن ثابت».
|
||||||
|
foreach (Match m in Regex.Matches(latin, @"(?<!\d)0[1-8]\d{1,2}[\s-]?\d{7,8}(?!\d)"))
|
||||||
Add(ContactType.Phone, Regex.Replace(m.Value, @"\D", ""));
|
Add(ContactType.Phone, Regex.Replace(m.Value, @"\D", ""));
|
||||||
|
|
||||||
return list.Take(8).ToList();
|
return list.Take(8).ToList();
|
||||||
|
|||||||
@@ -4,14 +4,19 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace JobsMedical.Web.Services;
|
namespace JobsMedical.Web.Services;
|
||||||
|
|
||||||
public record Recommendation(Shift Shift, double Score, List<string> Reasons);
|
/// <summary>A recommended opportunity — either an open <see cref="Shift"/> or an open
|
||||||
|
/// <see cref="JobOpening"/>. Exactly one of the two is set.</summary>
|
||||||
|
public record Recommendation(double Score, List<string> Reasons, Shift? Shift = null, JobOpening? Job = null)
|
||||||
|
{
|
||||||
|
public bool IsJob => Job is not null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stage 1 of the recommendation engine: a transparent, rule-based pattern engine.
|
/// Stage 1 of the recommendation engine: a transparent, rule-based pattern engine. It scores open
|
||||||
/// It scores open shifts against a visitor's explicit preferences AND their recent behavior
|
/// opportunities — BOTH shifts AND job openings — against a visitor's explicit preferences and their
|
||||||
/// (which roles/facilities/shift-types they keep engaging with), and returns the top matches
|
/// recent behavior, and returns the top matches each with a human-readable reason. Covering jobs (not
|
||||||
/// each with a human-readable reason. No ML/AI infra required — works from the first visit,
|
/// just shifts) matters because most roles — especially doctors — exist as استخدام, not dated shifts;
|
||||||
/// and every result is explainable. Behavioral data logged now feeds the ML stages later.
|
/// a shift-only feed would only ever recommend the handful of (mostly nurse) shifts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RecommendationService
|
public class RecommendationService
|
||||||
{
|
{
|
||||||
@@ -24,7 +29,6 @@ public class RecommendationService
|
|||||||
_interest = interest;
|
_interest = interest;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tunable weights — the whole point of a pattern engine is that these are legible.
|
|
||||||
private const double WRolePref = 40, WRoleBehavior = 15;
|
private const double WRolePref = 40, WRoleBehavior = 15;
|
||||||
private const double WCityPref = 20;
|
private const double WCityPref = 20;
|
||||||
private const double WShiftTypePref = 15, WShiftTypeBehavior = 8;
|
private const double WShiftTypePref = 15, WShiftTypeBehavior = 8;
|
||||||
@@ -36,64 +40,69 @@ public class RecommendationService
|
|||||||
public async Task<List<Recommendation>> GetForVisitorAsync(int take = 6)
|
public async Task<List<Recommendation>> GetForVisitorAsync(int take = 6)
|
||||||
{
|
{
|
||||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var jobCutoff = Scraping.ListingPolicy.JobCutoffUtc;
|
||||||
var prefs = await _interest.GetPreferencesAsync();
|
var prefs = await _interest.GetPreferencesAsync();
|
||||||
var events = await _interest.RecentEventsAsync(150);
|
var events = await _interest.RecentEventsAsync(150);
|
||||||
|
|
||||||
var candidates = await _db.Shifts
|
var shifts = await _db.Shifts
|
||||||
.Include(s => s.Facility).ThenInclude(f => f.City)
|
.Include(s => s.Facility).ThenInclude(f => f.City).Include(s => s.Role)
|
||||||
.Include(s => s.Role)
|
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today).ToListAsync();
|
||||||
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today)
|
var jobs = await _db.JobOpenings
|
||||||
.ToListAsync();
|
.Include(j => j.Facility).ThenInclude(f => f.City).Include(j => j.Role)
|
||||||
|
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCutoff).ToListAsync();
|
||||||
|
|
||||||
// Cold start: no preferences and no behavior → just show the freshest opportunities.
|
// Cold start: freshest of both, interleaved (jobs lead — that's where the volume/roles are).
|
||||||
if (prefs is null && events.Count == 0)
|
if (prefs is null && events.Count == 0)
|
||||||
{
|
{
|
||||||
return candidates
|
var cj = jobs.OrderByDescending(j => j.CreatedAt)
|
||||||
.OrderBy(s => s.Date).ThenBy(s => s.StartTime)
|
.Select(j => new Recommendation(0, new List<string> { "جدیدترین فرصتها" }, Job: j));
|
||||||
.Take(take)
|
var cs = shifts.OrderBy(s => s.Date).ThenBy(s => s.StartTime)
|
||||||
.Select(s => new Recommendation(s, 0, new() { "جدیدترین فرصتها" }))
|
.Select(s => new Recommendation(0, new List<string> { "جدیدترین فرصتها" }, Shift: s));
|
||||||
.ToList();
|
return Interleave(cj, cs).Take(take).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive behavioral affinities from the event log (shift events only — jobs are separate).
|
// Behavioral affinities — derived from BOTH shift and job events (role/facility span both;
|
||||||
var shiftEvents = events.Where(e => e.ShiftId is not null).ToList();
|
// shift-type is shift-only). Look up each engaged item's role/facility/type once.
|
||||||
var eventShiftIds = shiftEvents.Select(e => e.ShiftId!.Value).Distinct().ToList();
|
var positive = new[] { InterestEventType.View, InterestEventType.Click, InterestEventType.Save, InterestEventType.Apply };
|
||||||
var eventShifts = candidates.Where(s => eventShiftIds.Contains(s.Id))
|
var negative = new[] { InterestEventType.Dismiss, InterestEventType.HideFacility };
|
||||||
.Concat(await _db.Shifts.Include(s => s.Role)
|
|
||||||
.Where(s => eventShiftIds.Contains(s.Id)).ToListAsync())
|
|
||||||
.DistinctBy(s => s.Id)
|
|
||||||
.ToDictionary(s => s.Id);
|
|
||||||
|
|
||||||
var positive = new[] { InterestEventType.View, InterestEventType.Click,
|
var sIds = events.Where(e => e.ShiftId is not null).Select(e => e.ShiftId!.Value).Distinct().ToList();
|
||||||
InterestEventType.Save, InterestEventType.Apply };
|
var jIds = events.Where(e => e.JobOpeningId is not null).Select(e => e.JobOpeningId!.Value).Distinct().ToList();
|
||||||
|
var sMeta = (await _db.Shifts.Where(s => sIds.Contains(s.Id))
|
||||||
|
.Select(s => new { s.Id, s.RoleId, s.FacilityId, s.ShiftType }).ToListAsync()).ToDictionary(x => x.Id);
|
||||||
|
var jMeta = (await _db.JobOpenings.Where(j => jIds.Contains(j.Id))
|
||||||
|
.Select(j => new { j.Id, j.RoleId, j.FacilityId }).ToListAsync()).ToDictionary(x => x.Id);
|
||||||
|
|
||||||
var roleAffinity = TopBy(shiftEvents, positive, eventShifts, s => s.RoleId);
|
var roleCount = new Dictionary<int, int>();
|
||||||
var shiftTypeAffinity = TopBy(shiftEvents, positive, eventShifts, s => (int)s.ShiftType);
|
var facCount = new Dictionary<int, int>();
|
||||||
var facilityAffinity = TopBy(shiftEvents, positive, eventShifts, s => s.FacilityId);
|
var stCount = new Dictionary<int, int>();
|
||||||
|
var dismissedFacilities = new HashSet<int>();
|
||||||
|
foreach (var e in events)
|
||||||
|
{
|
||||||
|
int roleId, facId; int? st = null;
|
||||||
|
if (e.ShiftId is int si && sMeta.TryGetValue(si, out var sm)) { roleId = sm.RoleId; facId = sm.FacilityId; st = (int)sm.ShiftType; }
|
||||||
|
else if (e.JobOpeningId is int ji && jMeta.TryGetValue(ji, out var jm)) { roleId = jm.RoleId; facId = jm.FacilityId; }
|
||||||
|
else continue;
|
||||||
|
|
||||||
var dismissedFacilities = shiftEvents
|
if (positive.Contains(e.EventType))
|
||||||
.Where(e => e.EventType is InterestEventType.Dismiss or InterestEventType.HideFacility)
|
{
|
||||||
.Select(e => eventShifts.TryGetValue(e.ShiftId!.Value, out var s) ? s.FacilityId : 0)
|
Bump(roleCount, roleId); Bump(facCount, facId);
|
||||||
.Where(id => id != 0).ToHashSet();
|
if (st is int t) Bump(stCount, t);
|
||||||
|
}
|
||||||
|
else if (negative.Contains(e.EventType)) dismissedFacilities.Add(facId);
|
||||||
|
}
|
||||||
|
var roleAffinity = Top3(roleCount);
|
||||||
|
var facilityAffinity = Top3(facCount);
|
||||||
|
var shiftTypeAffinity = Top3(stCount);
|
||||||
|
|
||||||
var results = new List<Recommendation>();
|
var results = new List<Recommendation>();
|
||||||
foreach (var s in candidates)
|
|
||||||
|
foreach (var s in shifts)
|
||||||
{
|
{
|
||||||
// Skip listings whose gender requirement conflicts with the person's gender.
|
if (GenderConflicts(prefs, s.GenderRequirement)) continue;
|
||||||
if (prefs?.Gender is Gender pg && pg != Gender.Any
|
double score = 0; var reasons = new List<string>();
|
||||||
&& s.GenderRequirement != Gender.Any && s.GenderRequirement != pg)
|
ScoreCommon(ref score, reasons, prefs, s.RoleId, s.Role?.Name, s.Facility.CityId, s.Facility.City?.Name,
|
||||||
continue;
|
s.FacilityId, s.Facility?.Name, roleAffinity, facilityAffinity, dismissedFacilities);
|
||||||
|
|
||||||
double score = 0;
|
|
||||||
var reasons = new List<string>();
|
|
||||||
|
|
||||||
if (prefs?.RoleId is int pr && pr == s.RoleId)
|
|
||||||
{ score += WRolePref; reasons.Add($"متناسب با نقش مورد علاقه شما ({s.Role.Name})"); }
|
|
||||||
else if (roleAffinity.Contains(s.RoleId))
|
|
||||||
{ score += WRoleBehavior; reasons.Add($"چون به فرصتهای «{s.Role.Name}» علاقه نشان دادی"); }
|
|
||||||
|
|
||||||
if (prefs?.CityId is int pc && pc == s.Facility.CityId)
|
|
||||||
{ score += WCityPref; reasons.Add($"در شهر مورد علاقه شما ({s.Facility.City.Name})"); }
|
|
||||||
|
|
||||||
if (prefs?.PreferredShiftType is ShiftType pst && pst == s.ShiftType)
|
if (prefs?.PreferredShiftType is ShiftType pst && pst == s.ShiftType)
|
||||||
{ score += WShiftTypePref; reasons.Add($"نوع شیفت دلخواه شما ({ShiftTypeLabel(s.ShiftType)})"); }
|
{ score += WShiftTypePref; reasons.Add($"نوع شیفت دلخواه شما ({ShiftTypeLabel(s.ShiftType)})"); }
|
||||||
@@ -103,41 +112,71 @@ public class RecommendationService
|
|||||||
if (prefs?.MinPay is long min && s.PayAmount is long pay && pay >= min)
|
if (prefs?.MinPay is long min && s.PayAmount is long pay && pay >= min)
|
||||||
{ score += WPayMeetsExpectation; reasons.Add("حقوق بالاتر از حد انتظار شما"); }
|
{ score += WPayMeetsExpectation; reasons.Add("حقوق بالاتر از حد انتظار شما"); }
|
||||||
|
|
||||||
if (facilityAffinity.Contains(s.FacilityId))
|
|
||||||
{ score += WFacilityAffinity; reasons.Add($"از مرکزی که قبلاً به آن علاقه نشان دادی ({s.Facility.Name})"); }
|
|
||||||
|
|
||||||
if (dismissedFacilities.Contains(s.FacilityId))
|
|
||||||
score -= PenaltyDismissedFacility;
|
|
||||||
|
|
||||||
// Sooner shifts and freshly posted ones get a small nudge.
|
|
||||||
var daysOut = s.Date.DayNumber - today.DayNumber;
|
var daysOut = s.Date.DayNumber - today.DayNumber;
|
||||||
if (daysOut <= 3) score += WSoon;
|
if (daysOut <= 3) score += WSoon;
|
||||||
if ((DateTime.UtcNow - s.CreatedAt).TotalDays <= 2) score += WFreshness;
|
if ((DateTime.UtcNow - s.CreatedAt).TotalDays <= 2) score += WFreshness;
|
||||||
|
|
||||||
if (reasons.Count == 0) reasons.Add("پیشنهاد بر اساس فعالیت شما");
|
if (reasons.Count == 0) reasons.Add("پیشنهاد بر اساس فعالیت شما");
|
||||||
results.Add(new Recommendation(s, score, reasons));
|
results.Add(new Recommendation(score, reasons, Shift: s));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var j in jobs)
|
||||||
|
{
|
||||||
|
if (GenderConflicts(prefs, j.GenderRequirement)) continue;
|
||||||
|
double score = 0; var reasons = new List<string>();
|
||||||
|
ScoreCommon(ref score, reasons, prefs, j.RoleId, j.Role?.Name, j.Facility.CityId, j.Facility.City?.Name,
|
||||||
|
j.FacilityId, j.Facility?.Name, roleAffinity, facilityAffinity, dismissedFacilities);
|
||||||
|
|
||||||
|
if (prefs?.MinPay is long min && j.SalaryMin is long pay && pay >= min)
|
||||||
|
{ score += WPayMeetsExpectation; reasons.Add("حقوق بالاتر از حد انتظار شما"); }
|
||||||
|
if ((DateTime.UtcNow - j.CreatedAt).TotalDays <= 2) score += WFreshness;
|
||||||
|
|
||||||
|
if (reasons.Count == 0) reasons.Add("پیشنهاد بر اساس فعالیت شما");
|
||||||
|
results.Add(new Recommendation(score, reasons, Job: j));
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
.Where(r => r.Score > 0)
|
.Where(r => r.Score > 0)
|
||||||
.OrderByDescending(r => r.Score).ThenBy(r => r.Shift.Date)
|
.OrderByDescending(r => r.Score)
|
||||||
.Take(take)
|
.Take(take)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Keys the visitor engaged with most (positive events), top 3.</summary>
|
/// <summary>Role + city + facility scoring shared by shifts and jobs.</summary>
|
||||||
private static HashSet<int> TopBy(
|
private static void ScoreCommon(ref double score, List<string> reasons, UserPreferences? prefs,
|
||||||
List<InterestEvent> events, InterestEventType[] positive,
|
int roleId, string? roleName, int cityId, string? cityName, int facilityId, string? facilityName,
|
||||||
Dictionary<int, Shift> shiftById, Func<Shift, int> key)
|
HashSet<int> roleAffinity, HashSet<int> facilityAffinity, HashSet<int> dismissedFacilities)
|
||||||
{
|
{
|
||||||
return events
|
if (prefs?.RoleId is int pr && pr == roleId)
|
||||||
.Where(e => e.ShiftId is not null && positive.Contains(e.EventType)
|
{ score += WRolePref; reasons.Add($"متناسب با نقش مورد علاقه شما ({roleName})"); }
|
||||||
&& shiftById.ContainsKey(e.ShiftId.Value))
|
else if (roleAffinity.Contains(roleId))
|
||||||
.GroupBy(e => key(shiftById[e.ShiftId!.Value]))
|
{ score += WRoleBehavior; reasons.Add($"چون به فرصتهای «{roleName}» علاقه نشان دادی"); }
|
||||||
.OrderByDescending(g => g.Count())
|
|
||||||
.Take(3)
|
if (prefs?.CityId is int pc && pc == cityId)
|
||||||
.Select(g => g.Key)
|
{ score += WCityPref; reasons.Add($"در شهر مورد علاقه شما ({cityName})"); }
|
||||||
.ToHashSet();
|
|
||||||
|
if (facilityAffinity.Contains(facilityId))
|
||||||
|
{ score += WFacilityAffinity; reasons.Add($"از مرکزی که قبلاً به آن علاقه نشان دادی ({facilityName})"); }
|
||||||
|
|
||||||
|
if (dismissedFacilities.Contains(facilityId)) score -= PenaltyDismissedFacility;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool GenderConflicts(UserPreferences? prefs, Gender req)
|
||||||
|
=> prefs?.Gender is Gender pg && pg != Gender.Any && req != Gender.Any && req != pg;
|
||||||
|
|
||||||
|
private static void Bump(Dictionary<int, int> d, int k) => d[k] = d.GetValueOrDefault(k) + 1;
|
||||||
|
private static HashSet<int> Top3(Dictionary<int, int> d) => d.OrderByDescending(k => k.Value).Take(3).Select(k => k.Key).ToHashSet();
|
||||||
|
|
||||||
|
private static IEnumerable<Recommendation> Interleave(IEnumerable<Recommendation> a, IEnumerable<Recommendation> b)
|
||||||
|
{
|
||||||
|
using var ea = a.GetEnumerator();
|
||||||
|
using var eb = b.GetEnumerator();
|
||||||
|
bool ha = ea.MoveNext(), hb = eb.MoveNext();
|
||||||
|
while (ha || hb)
|
||||||
|
{
|
||||||
|
if (ha) { yield return ea.Current; ha = ea.MoveNext(); }
|
||||||
|
if (hb) { yield return eb.Current; hb = eb.MoveNext(); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ShiftTypeLabel(ShiftType t) => t switch
|
private static string ShiftTypeLabel(ShiftType t) => t switch
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -8,7 +9,13 @@ namespace JobsMedical.Web.Services.Scraping;
|
|||||||
public record AiStructured(
|
public record AiStructured(
|
||||||
string? Kind, string? Role, string? City, string? District, string? ShiftType,
|
string? Kind, string? Role, string? City, string? District, string? ShiftType,
|
||||||
string? EmploymentType, long? PayAmount, int? SharePercent, string? Title, string? FacilityName,
|
string? EmploymentType, long? PayAmount, int? SharePercent, string? Title, string? FacilityName,
|
||||||
string? Phone = null, string? PersonName = null, int? YearsExperience = null, bool? IsLicensed = null);
|
string? Phone = null, string? PersonName = null, int? YearsExperience = null, bool? IsLicensed = null,
|
||||||
|
// Dynamic taxonomy: the model may name a role/category outside the seeded set (ingestion
|
||||||
|
// resolves-or-creates it). Tags carry the post's skills/requirements (ICU, MMT, پروانهدار…).
|
||||||
|
string? Category = null, IReadOnlyList<string>? Tags = null,
|
||||||
|
// Approximate coords the model infers from a named neighborhood — used ONLY as a geocoding
|
||||||
|
// fallback (validated against Tehran's bbox), when the source ad and the local table have none.
|
||||||
|
double? Lat = null, double? Lng = null);
|
||||||
|
|
||||||
/// <summary>An AI verdict on a raw listing.</summary>
|
/// <summary>An AI verdict on a raw listing.</summary>
|
||||||
public record AiAuditResult(string Decision, int Confidence, string? Reason, AiStructured? Data)
|
public record AiAuditResult(string Decision, int Confidence, string? Reason, AiStructured? Data)
|
||||||
@@ -21,6 +28,11 @@ public interface IAiAuditor
|
|||||||
{
|
{
|
||||||
/// <summary>Audit a raw post. Returns null when AI is off or the call fails (fail safe → manual).</summary>
|
/// <summary>Audit a raw post. Returns null when AI is off or the call fails (fail safe → manual).</summary>
|
||||||
Task<AiAuditResult?> AuditAsync(string rawText, AppSetting settings, CancellationToken ct = default);
|
Task<AiAuditResult?> AuditAsync(string rawText, AppSetting settings, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Diagnostic: runs a real call and returns a detailed, human-readable Persian
|
||||||
|
/// success/error string (HTTP status, response snippet, exception detail) so the admin can
|
||||||
|
/// see exactly why the AI service won't connect. Never throws.</summary>
|
||||||
|
Task<string> TestAsync(string rawText, AppSetting settings, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -39,8 +51,11 @@ public class OpenAiCompatibleAuditor : IAiAuditor
|
|||||||
confidence: عدد ۰ تا ۱۰۰
|
confidence: عدد ۰ تا ۱۰۰
|
||||||
reason: توضیح کوتاه فارسی
|
reason: توضیح کوتاه فارسی
|
||||||
kind: shift (شیفت توسط مرکز) | job (استخدام توسط مرکز) | talent (کادر درمان که خودش «آماده به کار» است)
|
kind: shift (شیفت توسط مرکز) | job (استخدام توسط مرکز) | talent (کادر درمان که خودش «آماده به کار» است)
|
||||||
role: عنوان دقیق نقش درمانی (مثل پرستار، پزشک عمومی، دندانپزشک، تکنسین اتاق عمل، ماما، کارشناس آزمایشگاه)
|
role: «حرفهٔ پایه»، نه با توصیفگر. گروه سنی/بخش/سطح را در tags بگذار («پرستار کودک»→role «پرستار»). فقط برای حرفهٔ پایهٔ متفاوت که در فهرست نیست نقش جدید بساز.
|
||||||
|
category: فقط یکی از این پنج: پزشک | پرستار | ماما | تکنسین | دندانپزشک. اگر نگنجید «سایر». هرگز گروه جدید نساز.
|
||||||
|
tags: آرایهٔ کلیدواژههای بالینی (مهارت/بخش/گواهی/گروه سنی/سطح) مثل "ICU"،"دیالیز"،"کودک"،"پروانهدار". بدون مبلغ/پرداخت/تماس/شهر یا جملهٔ ناقص. اگر نبود [].
|
||||||
city, district: نام شهر و محله/منطقه در صورت ذکر
|
city, district: نام شهر و محله/منطقه در صورت ذکر
|
||||||
|
lat, lng: اگر محله/منطقه را در تهران تشخیص دادی، مختصاتِ تقریبیِ مرکزِ همان محله را بهصورت عدد اعشاری برگردان (lat حدود ۳۵.x، lng حدود ۵۱.x)؛ در غیر این صورت null. حدس نزن.
|
||||||
shiftType: day|evening|night|oncall (فقط برای shift)
|
shiftType: day|evening|night|oncall (فقط برای shift)
|
||||||
employmentType: fulltime|parttime|contract|plan
|
employmentType: fulltime|parttime|contract|plan
|
||||||
payAmount: عدد تومان یا null ، sharePercent: عدد ۰ تا ۱۰۰ یا null (مثل «۵۰٪ تسویه»)
|
payAmount: عدد تومان یا null ، sharePercent: عدد ۰ تا ۱۰۰ یا null (مثل «۵۰٪ تسویه»)
|
||||||
@@ -63,6 +78,79 @@ public class OpenAiCompatibleAuditor : IAiAuditor
|
|||||||
if (!s.AiEnabled || string.IsNullOrWhiteSpace(s.AiEndpoint)) return null;
|
if (!s.AiEnabled || string.IsNullOrWhiteSpace(s.AiEndpoint)) return null;
|
||||||
|
|
||||||
try
|
try
|
||||||
|
{
|
||||||
|
var (status, body) = await SendAsync(rawText, s, ct);
|
||||||
|
if (!IsSuccess(status))
|
||||||
|
{
|
||||||
|
// Log the actual status + response body — the provider usually explains the failure
|
||||||
|
// here (bad key, unknown model, quota), so don't throw it away with EnsureSuccessStatusCode.
|
||||||
|
_log.LogWarning("AI endpoint {Endpoint} returned HTTP {Status}: {Body}",
|
||||||
|
s.AiEndpoint, (int)status, Truncate(body, 600));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = ExtractContent(body);
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
_log.LogWarning("AI endpoint {Endpoint} returned no message content (response shape not OpenAI-compatible?). Body: {Body}",
|
||||||
|
s.AiEndpoint, Truncate(body, 600));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParseVerdict(content);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_log.LogWarning("AI call to {Endpoint} timed out (proxy={Proxy}).", s.AiEndpoint, s.AiUseProxy);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning(ex, "AI audit failed for endpoint {Endpoint} (proxy={Proxy}) — falling back to rule-based decision.",
|
||||||
|
s.AiEndpoint, s.AiUseProxy);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> TestAsync(string rawText, AppSetting s, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!s.AiEnabled || string.IsNullOrWhiteSpace(s.AiEndpoint))
|
||||||
|
return "هوش مصنوعی غیرفعال است یا آدرس سرویس خالی است. ابتدا آن را فعال و ذخیره کن.";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (status, body) = await SendAsync(rawText, s, ct);
|
||||||
|
if (!IsSuccess(status))
|
||||||
|
return $"❌ سرویس کد HTTP {(int)status} ({status}) برگرداند.\nآدرس: {s.AiEndpoint}\nپروکسی: {(s.AiUseProxy ? "روشن" : "خاموش")}\nپاسخ سرویس:\n{Truncate(body, 800)}";
|
||||||
|
|
||||||
|
var content = ExtractContent(body);
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
return $"❌ پاسخ دریافت شد ولی محتوای پیام خالی بود — ساختار پاسخ با OpenAI سازگار نیست؟\nپاسخ خام:\n{Truncate(body, 800)}";
|
||||||
|
|
||||||
|
var v = ParseVerdict(content);
|
||||||
|
return v is null
|
||||||
|
? $"⚠️ مدل پاسخ داد ولی JSON قابلخواندن نبود. (response_format=json_object را پشتیبانی نمیکند؟)\nمحتوا:\n{Truncate(content, 800)}"
|
||||||
|
: $"✅ اتصال موفق — تصمیم: {v.Decision} | اطمینان: {v.Confidence}٪ | نقش: {v.Data?.Role} | شهر: {v.Data?.City} | شیفت: {v.Data?.ShiftType}";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return "❌ مهلت پاسخگویی تمام شد (timeout ۱۰۰ ثانیه). اگر تیک «از طریق پروکسی» روشن است، صحت آدرس پروکسی را بررسی کن.";
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
// DNS failure, connection refused, TLS error, proxy unreachable — the common Iran cases.
|
||||||
|
var inner = ex.InnerException is { } i ? $" — {i.Message}" : "";
|
||||||
|
return $"❌ خطای شبکه/پروکسی: {ex.Message}{inner}\nآدرس: {s.AiEndpoint}\nپروکسی: {(s.AiUseProxy ? "روشن" : "خاموش")}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"❌ خطا: {ex.GetType().Name}: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POSTs the chat-completions request and returns the raw status + body. Shared by
|
||||||
|
/// AuditAsync (fail-safe) and TestAsync (diagnostic) so both exercise the identical call path.</summary>
|
||||||
|
private async Task<(HttpStatusCode status, string body)> SendAsync(string rawText, AppSetting s, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var payload = new
|
var payload = new
|
||||||
{
|
{
|
||||||
@@ -71,9 +159,9 @@ public class OpenAiCompatibleAuditor : IAiAuditor
|
|||||||
response_format = new { type = "json_object" },
|
response_format = new { type = "json_object" },
|
||||||
messages = new object[]
|
messages = new object[]
|
||||||
{
|
{
|
||||||
// Admin prompt + an authoritative output schema, so classification/tags stay
|
// Hardcoded, code-owned prompt (NOT the stored AiSystemPrompt) + the authoritative
|
||||||
// correct even if the stored prompt predates the talent/phone fields.
|
// output schema, so classification/tags can never be broken by an admin edit.
|
||||||
new { role = "system", content = s.AiSystemPrompt + "\n\n" + OutputSchema },
|
new { role = "system", content = AppSetting.DefaultPrompt + "\n\n" + OutputSchema },
|
||||||
new { role = "user", content = "آگهی خام:\n" + rawText + "\n\nفقط با JSON پاسخ بده." },
|
new { role = "user", content = "آگهی خام:\n" + rawText + "\n\nفقط با JSON پاسخ بده." },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -87,22 +175,31 @@ public class OpenAiCompatibleAuditor : IAiAuditor
|
|||||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", s.AiApiKey);
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", s.AiApiKey);
|
||||||
|
|
||||||
using var resp = await client.SendAsync(req, ct);
|
using var resp = await client.SendAsync(req, ct);
|
||||||
resp.EnsureSuccessStatusCode();
|
|
||||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||||
|
return (resp.StatusCode, body);
|
||||||
using var doc = JsonDocument.Parse(body);
|
|
||||||
var content = doc.RootElement
|
|
||||||
.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString();
|
|
||||||
if (string.IsNullOrWhiteSpace(content)) return null;
|
|
||||||
|
|
||||||
return ParseVerdict(content);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
|
private static bool IsSuccess(HttpStatusCode s) => (int)s is >= 200 and < 300;
|
||||||
|
|
||||||
|
/// <summary>Pulls choices[0].message.content out of an OpenAI-style response. Returns null on any
|
||||||
|
/// unexpected shape (e.g. an error object) rather than throwing, so the caller can show the body.</summary>
|
||||||
|
private static string? ExtractContent(string body)
|
||||||
{
|
{
|
||||||
_log.LogWarning(ex, "AI audit failed — falling back to rule-based decision.");
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
if (doc.RootElement.TryGetProperty("choices", out var choices)
|
||||||
|
&& choices.ValueKind == JsonValueKind.Array && choices.GetArrayLength() > 0
|
||||||
|
&& choices[0].TryGetProperty("message", out var msg)
|
||||||
|
&& msg.TryGetProperty("content", out var content))
|
||||||
|
return content.GetString();
|
||||||
|
}
|
||||||
|
catch (JsonException) { }
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private static string Truncate(string? s, int max)
|
||||||
|
=> string.IsNullOrEmpty(s) ? "(خالی)" : (s.Length <= max ? s : s[..max] + " …");
|
||||||
|
|
||||||
private static AiAuditResult? ParseVerdict(string json)
|
private static AiAuditResult? ParseVerdict(string json)
|
||||||
{
|
{
|
||||||
@@ -113,20 +210,38 @@ public class OpenAiCompatibleAuditor : IAiAuditor
|
|||||||
if (start < 0 || end <= start) return null;
|
if (start < 0 || end <= start) return null;
|
||||||
json = json.Substring(start, end - start + 1);
|
json = json.Substring(start, end - start + 1);
|
||||||
|
|
||||||
using var doc = JsonDocument.Parse(json);
|
JsonDocument doc;
|
||||||
|
try { doc = JsonDocument.Parse(json); }
|
||||||
|
catch (JsonException) { return null; } // model returned non-JSON content
|
||||||
|
using (doc)
|
||||||
|
{
|
||||||
var r = doc.RootElement;
|
var r = doc.RootElement;
|
||||||
// Guard on ValueKind == Number first — TryGetInt32/64 THROW on null/string values
|
// Guard on ValueKind == Number first — TryGetInt32/64 THROW on null/string values
|
||||||
// (the model often returns payAmount/sharePercent as null), which would fail the whole parse.
|
// (the model often returns payAmount/sharePercent as null), which would fail the whole parse.
|
||||||
string? S(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
string? S(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||||
int I(string k, int d) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n) ? n : d;
|
int I(string k, int d) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n) ? n : d;
|
||||||
long? L(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt64(out var n) ? n : null;
|
long? L(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt64(out var n) ? n : null;
|
||||||
|
double? D(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out var n) ? n : null;
|
||||||
int? NI(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n) ? n : null;
|
int? NI(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n) ? n : null;
|
||||||
bool? B(string k) => r.TryGetProperty(k, out var v) && (v.ValueKind == JsonValueKind.True || v.ValueKind == JsonValueKind.False) ? v.GetBoolean() : null;
|
bool? B(string k) => r.TryGetProperty(k, out var v) && (v.ValueKind == JsonValueKind.True || v.ValueKind == JsonValueKind.False) ? v.GetBoolean() : null;
|
||||||
|
// Array-of-strings reader (tolerates the model returning a single string instead of an array).
|
||||||
|
IReadOnlyList<string>? SA(string k)
|
||||||
|
{
|
||||||
|
if (!r.TryGetProperty(k, out var v)) return null;
|
||||||
|
var list = new List<string>();
|
||||||
|
if (v.ValueKind == JsonValueKind.Array)
|
||||||
|
foreach (var el in v.EnumerateArray())
|
||||||
|
if (el.ValueKind == JsonValueKind.String && el.GetString() is { Length: > 0 } s) list.Add(s);
|
||||||
|
else if (v.ValueKind == JsonValueKind.String && v.GetString() is { Length: > 0 } one) list.Add(one);
|
||||||
|
return list.Count > 0 ? list : null;
|
||||||
|
}
|
||||||
|
|
||||||
var decision = (S("decision") ?? "review").ToLowerInvariant();
|
var decision = (S("decision") ?? "review").ToLowerInvariant();
|
||||||
var data = new AiStructured(S("kind"), S("role"), S("city"), S("district"), S("shiftType"),
|
var data = new AiStructured(S("kind"), S("role"), S("city"), S("district"), S("shiftType"),
|
||||||
S("employmentType"), L("payAmount"), NI("sharePercent"), S("title"), S("facilityName"),
|
S("employmentType"), L("payAmount"), NI("sharePercent"), S("title"), S("facilityName"),
|
||||||
Phone: S("phone"), PersonName: S("personName"), YearsExperience: NI("yearsExperience"), IsLicensed: B("isLicensed"));
|
Phone: S("phone"), PersonName: S("personName"), YearsExperience: NI("yearsExperience"), IsLicensed: B("isLicensed"),
|
||||||
|
Category: S("category"), Tags: SA("tags"), Lat: D("lat"), Lng: D("lng"));
|
||||||
return new AiAuditResult(decision, Math.Clamp(I("confidence", 50), 0, 100), S("reason"), data);
|
return new AiAuditResult(decision, Math.Clamp(I("confidence", 50), 0, 100), S("reason"), data);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,17 +36,20 @@ public class BaleListingSource : IListingSource
|
|||||||
var items = new List<ScrapedItem>();
|
var items = new List<ScrapedItem>();
|
||||||
foreach (var update in result.EnumerateArray())
|
foreach (var update in result.EnumerateArray())
|
||||||
{
|
{
|
||||||
var text = TextOf(update, "channel_post") ?? TextOf(update, "message");
|
var post = Msg(update, "channel_post") ?? Msg(update, "message");
|
||||||
if (!string.IsNullOrWhiteSpace(text) && text!.Trim().Length >= 15)
|
if (post is not { } p) continue;
|
||||||
items.Add(new ScrapedItem("بله", text.Trim()));
|
var text = p.TryGetProperty("text", out var t) && t.ValueKind == JsonValueKind.String ? t.GetString() : null;
|
||||||
|
if (string.IsNullOrWhiteSpace(text) || text!.Trim().Length < 15) continue;
|
||||||
|
// Bot API messages carry a unix `date` — keep it so stale posts can be aged out.
|
||||||
|
DateTime? postedAt = p.TryGetProperty("date", out var d) && d.ValueKind == JsonValueKind.Number && d.TryGetInt64(out var epoch)
|
||||||
|
? DateTimeOffset.FromUnixTimeSeconds(epoch).UtcDateTime : null;
|
||||||
|
items.Add(new ScrapedItem("بله", text.Trim(), PostedAt: postedAt));
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
catch (Exception ex) { _log.LogWarning(ex, "Bale fetch failed."); return Array.Empty<ScrapedItem>(); }
|
catch (Exception ex) { _log.LogWarning(ex, "Bale fetch failed."); return Array.Empty<ScrapedItem>(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? TextOf(JsonElement update, string key)
|
private static JsonElement? Msg(JsonElement update, string key)
|
||||||
=> update.TryGetProperty(key, out var m)
|
=> update.TryGetProperty(key, out var m) && m.ValueKind == JsonValueKind.Object ? m : null;
|
||||||
&& m.TryGetProperty("text", out var t) && t.ValueKind == JsonValueKind.String
|
|
||||||
? t.GetString() : null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,17 +59,31 @@ public class DivarListingSource : IListingSource
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
using var doc = JsonDocument.Parse(body);
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var cityLabel = CityLabel(s.DivarCity); // every result is from the city we searched
|
||||||
foreach (var (text, token) in Harvest(doc.RootElement).Take(25))
|
foreach (var (text, token) in Harvest(doc.RootElement).Take(25))
|
||||||
{
|
{
|
||||||
var url = token is not null ? $"https://divar.ir/v/{token}" : "https://divar.ir";
|
// Only a real post token gives a usable deep link. Without one, leave SourceUrl null —
|
||||||
var withPhone = text;
|
// a bare «https://divar.ir» just opens Divar's homepage, which is useless to the user.
|
||||||
|
var url = token is not null ? $"https://divar.ir/v/{token}" : null;
|
||||||
|
var itemText = text;
|
||||||
|
// Stamp the city so the parser/AI always resolve a location (Divar's own location
|
||||||
|
// line isn't always in the search row; the searched city is authoritative).
|
||||||
|
if (!string.IsNullOrWhiteSpace(cityLabel) && !text.Contains(cityLabel))
|
||||||
|
itemText += $"\n📍 {cityLabel}";
|
||||||
|
double? lat = null, lng = null;
|
||||||
if (token is not null)
|
if (token is not null)
|
||||||
{
|
{
|
||||||
var phones = await RevealPhonesAsync(client, token, s, ct);
|
// One detail fetch yields the FULL description, the phone, AND the map center.
|
||||||
if (phones.Count > 0 && !phones.Any(text.Contains))
|
// (The search row only carries a short one-line summary — the rich ad body lives
|
||||||
withPhone = text + "\nشماره تماس: " + string.Join("، ", phones);
|
// on the post detail, so without this the listing looked "censored".)
|
||||||
|
var (phones, gLat, gLng, fullDesc) = await FetchDetailAsync(client, token, ct);
|
||||||
|
if (!string.IsNullOrWhiteSpace(fullDesc) && !itemText.Contains(fullDesc))
|
||||||
|
itemText += "\n" + fullDesc;
|
||||||
|
if (phones.Count > 0 && !phones.Any(itemText.Contains))
|
||||||
|
itemText += "\nشماره تماس: " + string.Join("، ", phones);
|
||||||
|
lat = gLat; lng = gLng;
|
||||||
}
|
}
|
||||||
items.Add(new ScrapedItem("دیوار", withPhone, url));
|
items.Add(new ScrapedItem("دیوار", itemText, url, lat, lng));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex) { _log.LogWarning(ex, "Divar fetch failed for query {Query}", q); }
|
catch (Exception ex) { _log.LogWarning(ex, "Divar fetch failed for query {Query}", q); }
|
||||||
@@ -95,16 +109,31 @@ public class DivarListingSource : IListingSource
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Persian display name for the searched city (slug/number/Persian → Persian), used to
|
||||||
|
/// stamp every Divar result with its (authoritative) location.</summary>
|
||||||
|
private static string CityLabel(string? city) => (city ?? "").Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"1" or "tehran" or "تهران" => "تهران",
|
||||||
|
"3" or "isfahan" or "esfahan" or "اصفهان" => "اصفهان",
|
||||||
|
"4" or "mashhad" or "مشهد" => "مشهد",
|
||||||
|
"5" or "shiraz" or "شیراز" => "شیراز",
|
||||||
|
"6" or "tabriz" or "تبریز" => "تبریز",
|
||||||
|
"1745" or "karaj" or "کرج" => "کرج",
|
||||||
|
_ => (city ?? "").Trim(),
|
||||||
|
};
|
||||||
|
|
||||||
// The post detail endpoint returns the FULL description — many Divar job ads write the phone
|
// The post detail endpoint returns the FULL description — many Divar job ads write the phone
|
||||||
// straight into the body, so we can harvest it without Divar's (login-gated) contact reveal.
|
// straight into the body, so we can harvest it without Divar's (login-gated) contact reveal.
|
||||||
private const string PostDetailUrl = "https://api.divar.ir/v8/posts-v2/web/";
|
private const string PostDetailUrl = "https://api.divar.ir/v8/posts-v2/web/";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetch a post's detail JSON and harvest any contact number it contains (mostly numbers the
|
/// Fetch a post's detail JSON ONCE and harvest both (a) any contact number it contains (mostly
|
||||||
/// poster wrote into the description). Divar's true "نمایش شماره" reveal is auth-gated; this
|
/// numbers the poster wrote into the description; Divar's true "نمایش شماره" reveal is auth-gated)
|
||||||
/// covers the common case where the number is in the ad text. Fails soft.
|
/// and (b) the post's APPROXIMATE map coordinates (the privacy-fuzzed center Divar shows as a
|
||||||
|
/// circle). Fails soft — returns whatever it could extract.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<List<string>> RevealPhonesAsync(HttpClient client, string token, AppSetting s, CancellationToken ct)
|
private async Task<(List<string> phones, double? lat, double? lng, string? description)> FetchDetailAsync(
|
||||||
|
HttpClient client, string token, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -112,18 +141,101 @@ public class DivarListingSource : IListingSource
|
|||||||
req.Headers.TryAddWithoutValidation("User-Agent", Ua);
|
req.Headers.TryAddWithoutValidation("User-Agent", Ua);
|
||||||
req.Headers.TryAddWithoutValidation("Accept", "application/json");
|
req.Headers.TryAddWithoutValidation("Accept", "application/json");
|
||||||
using var resp = await client.SendAsync(req, ct);
|
using var resp = await client.SendAsync(req, ct);
|
||||||
if (!resp.IsSuccessStatusCode) return new();
|
if (!resp.IsSuccessStatusCode) return (new(), null, null, null);
|
||||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||||
if (body.Contains("BLOCKING_VIEW")) return new();
|
if (body.Contains("BLOCKING_VIEW")) return (new(), null, null, null);
|
||||||
return HtmlUtil.HarvestPhones(body);
|
var phones = HtmlUtil.HarvestPhones(body);
|
||||||
|
double? lat = null, lng = null; string? desc = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
if (FindLatLng(doc.RootElement) is { } g) { lat = g.lat; lng = g.lng; }
|
||||||
|
desc = FindLongestText(doc.RootElement); // the full ad body
|
||||||
|
}
|
||||||
|
catch (JsonException) { /* detail wasn't JSON — phones still harvested from text */ }
|
||||||
|
return (phones, lat, lng, desc);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_log.LogWarning(ex, "Divar detail/reveal failed for {Token}", token);
|
_log.LogWarning(ex, "Divar detail/reveal failed for {Token}", token);
|
||||||
return new();
|
return (new(), null, null, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The full ad description in Divar's detail JSON = the longest free-text string. We skip
|
||||||
|
/// Divar's own safety/boilerplate notices (which mention «دیوار») and absurdly long blobs.</summary>
|
||||||
|
private static string? FindLongestText(JsonElement root)
|
||||||
|
{
|
||||||
|
string? best = null;
|
||||||
|
var stack = new Stack<JsonElement>();
|
||||||
|
stack.Push(root);
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
var e = stack.Pop();
|
||||||
|
switch (e.ValueKind)
|
||||||
|
{
|
||||||
|
case JsonValueKind.Object:
|
||||||
|
foreach (var p in e.EnumerateObject()) stack.Push(p.Value);
|
||||||
|
break;
|
||||||
|
case JsonValueKind.Array:
|
||||||
|
foreach (var it in e.EnumerateArray()) stack.Push(it);
|
||||||
|
break;
|
||||||
|
case JsonValueKind.String:
|
||||||
|
var s = e.GetString();
|
||||||
|
if (s is { Length: >= 40 and <= 4000 } && s.Contains(' ') && !s.Contains("دیوار")
|
||||||
|
&& (best is null || s.Length > best.Length)) best = s;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best?.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iran's bounding box — guards against picking up an unrelated number pair (timestamps, ids…).
|
||||||
|
private const double MinLat = 24, MaxLat = 40, MinLng = 44, MaxLng = 64;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tolerantly find an approximate (lat, lng) anywhere in Divar's detail JSON. Divar's shape
|
||||||
|
/// shifts (sometimes `latitude`/`longitude`, sometimes nested under `location`/`coordinates`),
|
||||||
|
/// so we walk the tree and accept the first OBJECT that holds BOTH a latitude-like and a
|
||||||
|
/// longitude-like numeric property whose values fall inside Iran. Pairing within one object
|
||||||
|
/// avoids matching a stray lat to an unrelated lng. Returns null if nothing plausible is found.
|
||||||
|
/// </summary>
|
||||||
|
private static (double lat, double lng)? FindLatLng(JsonElement el)
|
||||||
|
{
|
||||||
|
if (el.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
double? lat = null, lng = null;
|
||||||
|
foreach (var p in el.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (lat is null && IsLatKey(p.Name) && TryNum(p.Value, out var la)) lat = la;
|
||||||
|
else if (lng is null && IsLngKey(p.Name) && TryNum(p.Value, out var lo)) lng = lo;
|
||||||
|
}
|
||||||
|
if (lat is double L && lng is double G && L is >= MinLat and <= MaxLat && G is >= MinLng and <= MaxLng)
|
||||||
|
return (L, G);
|
||||||
|
foreach (var p in el.EnumerateObject())
|
||||||
|
if (FindLatLng(p.Value) is { } r) return r;
|
||||||
|
}
|
||||||
|
else if (el.ValueKind == JsonValueKind.Array)
|
||||||
|
foreach (var item in el.EnumerateArray())
|
||||||
|
if (FindLatLng(item) is { } r) return r;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsLatKey(string k) => k.Equals("latitude", StringComparison.OrdinalIgnoreCase) || k.Equals("lat", StringComparison.OrdinalIgnoreCase);
|
||||||
|
private static bool IsLngKey(string k) =>
|
||||||
|
k.Equals("longitude", StringComparison.OrdinalIgnoreCase) || k.Equals("lng", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| k.Equals("lon", StringComparison.OrdinalIgnoreCase) || k.Equals("long", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>Coordinate may be a JSON number or a numeric string ("35.7"). Invariant culture.</summary>
|
||||||
|
private static bool TryNum(JsonElement v, out double d)
|
||||||
|
{
|
||||||
|
if (v.ValueKind == JsonValueKind.Number) return v.TryGetDouble(out d);
|
||||||
|
if (v.ValueKind == JsonValueKind.String)
|
||||||
|
return double.TryParse(v.GetString(), System.Globalization.NumberStyles.Float,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out d);
|
||||||
|
d = 0; return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static readonly string[] DescKeys =
|
private static readonly string[] DescKeys =
|
||||||
{ "description", "middle_description_text", "subtitle", "bottom_description_text", "normal_text" };
|
{ "description", "middle_description_text", "subtitle", "bottom_description_text", "normal_text" };
|
||||||
|
|
||||||
@@ -134,9 +246,11 @@ public class DivarListingSource : IListingSource
|
|||||||
if (el.TryGetProperty("title", out var t) && t.ValueKind == JsonValueKind.String)
|
if (el.TryGetProperty("title", out var t) && t.ValueKind == JsonValueKind.String)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder(t.GetString());
|
var sb = new StringBuilder(t.GetString());
|
||||||
|
// Append ALL present description fields — the location/time line («… در تهران، جنتآباد»)
|
||||||
|
// is usually in bottom_description_text, so don't stop at the first match.
|
||||||
foreach (var k in DescKeys)
|
foreach (var k in DescKeys)
|
||||||
if (el.TryGetProperty(k, out var d) && d.ValueKind == JsonValueKind.String)
|
if (el.TryGetProperty(k, out var d) && d.ValueKind == JsonValueKind.String && d.GetString() is { Length: > 0 } v)
|
||||||
{ sb.Append(" — ").Append(d.GetString()); break; }
|
sb.Append(" — ").Append(v);
|
||||||
var text = sb.ToString().Trim();
|
var text = sb.ToString().Trim();
|
||||||
if (text.Length >= 15) yield return (text, FindToken(el));
|
if (text.Length >= 15) yield return (text, FindToken(el));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ public static class FacilityMatcher
|
|||||||
{
|
{
|
||||||
"بیمارستان", "زایشگاه", "پلی کلینیک", "پلیکلینیک", "درمانگاه", "کلینیک",
|
"بیمارستان", "زایشگاه", "پلی کلینیک", "پلیکلینیک", "درمانگاه", "کلینیک",
|
||||||
"مرکز درمانی", "مرکز جراحی", "مجتمع پزشکی", "مجتمع درمانی", "مرکز", "مجتمع",
|
"مرکز درمانی", "مرکز جراحی", "مجتمع پزشکی", "مجتمع درمانی", "مرکز", "مجتمع",
|
||||||
"آزمایشگاه", "مطب", "تخصصی", "فوق تخصصی", "فوقتخصصی", "عمومی", "دکتر", "دی کلینیک",
|
"آزمایشگاه", "داروخانه", "آسایشگاه", "مطب", "تخصصی", "فوق تخصصی", "فوقتخصصی", "عمومی", "دکتر", "دی کلینیک",
|
||||||
|
// Generic descriptors — never the distinctive part of a name. Stripping them stops false
|
||||||
|
// merges like «درمانگاه شبانهروزی اسفند» → «پلی کلینیک شبانه روزی» (they share «شبانه روزی»).
|
||||||
|
"شبانه روزی", "شبانهروزی", "خیریه", "دولتی", "خصوصی", "۲۴ ساعته", "24 ساعته", "تامین اجتماعی",
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Lower-cased, Arabic→Persian folded, punctuation-stripped, whitespace-collapsed.</summary>
|
/// <summary>Lower-cased, Arabic→Persian folded, punctuation-stripped, whitespace-collapsed.</summary>
|
||||||
@@ -47,6 +50,42 @@ public static class FacilityMatcher
|
|||||||
return Regex.Replace(n, @"\s+", " ").Trim();
|
return Regex.Replace(n, @"\s+", " ").Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filler/verb/locator tokens that are never a real facility name — the parser sweeps these in
|
||||||
|
// when an ad has no named facility («بیمارستان هستم», «مطب نیازمندیم سه», «کلینیک های فقط منطقه»).
|
||||||
|
private static readonly string[] JunkCoreWords =
|
||||||
|
{
|
||||||
|
"هستم", "هستیم", "هستش", "میشوم", "میشم", "بشوم", "میباشد", "باشد", "میباشم",
|
||||||
|
"نیازمندیم", "نیازمند", "نیازمندم", "داریم", "دارم", "میخواهیم", "میخوام",
|
||||||
|
"حتی", "تعدادی", "فقط", "منطقه", "واقع", "های", "مبتدی", "محترم", "خوب",
|
||||||
|
"سه", "دو", "یک", "چند", "این", "آن", "همکار", "نیرو",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Crawl-source names that must never appear as a public facility («مرکز درمانی (از مدجابز)»),
|
||||||
|
// plus the shared placeholder text.
|
||||||
|
private static readonly string[] SourceMarkers =
|
||||||
|
{
|
||||||
|
"مدجابز", "مدجاب", "از تلگرام", "از دیوار", "از بله", "از کانال", "ثبت نشده", "نامشخص",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when a name is NOT a usable facility name: a bare type word («بیمارستان»), a name whose
|
||||||
|
/// distinctive core is only filler/verb tokens («بیمارستان هستم» → «هستم»), or a leaked crawl
|
||||||
|
/// source / placeholder («... از مدجابز», «نامشخص»). Such an ad has no real named facility and
|
||||||
|
/// should fall back to the shared placeholder instead of forging a fake one.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsJunkName(string? name)
|
||||||
|
{
|
||||||
|
var normalized = Normalize(name);
|
||||||
|
if (normalized.Length == 0) return true;
|
||||||
|
if (SourceMarkers.Any(m => normalized.Contains(Normalize(m)))) return true;
|
||||||
|
|
||||||
|
var core = Core(name);
|
||||||
|
if (core.Length == 0) return true; // bare type word only («بیمارستان»، «کلینیک»)
|
||||||
|
|
||||||
|
var tokens = core.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
return tokens.All(t => t.Length <= 1 || JunkCoreWords.Contains(t));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>True when two names almost certainly denote the same facility.</summary>
|
/// <summary>True when two names almost certainly denote the same facility.</summary>
|
||||||
public static bool IsSame(string? a, string? b)
|
public static bool IsSame(string? a, string? b)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ using JobsMedical.Web.Models;
|
|||||||
|
|
||||||
namespace JobsMedical.Web.Services.Scraping;
|
namespace JobsMedical.Web.Services.Scraping;
|
||||||
|
|
||||||
/// <summary>One raw post pulled from a source (a Telegram message, a Divar ad, etc.).</summary>
|
/// <summary>One raw post pulled from a source (a Telegram message, a Divar ad, etc.).
|
||||||
public record ScrapedItem(string Source, string RawText, string? SourceUrl = null);
|
/// Lat/Lng are an APPROXIMATE location when the source exposes one (e.g. Divar's privacy-fuzzed
|
||||||
|
/// map center) — used to place an aggregated facility on the map / enable «near me».
|
||||||
|
/// PostedAt is the post's ORIGINAL publish time when the source exposes it (Telegram <time>,
|
||||||
|
/// Bale message date…) — used to drop stale applicant ads at ingest. Null when unknown.</summary>
|
||||||
|
public record ScrapedItem(string Source, string RawText, string? SourceUrl = null,
|
||||||
|
double? Lat = null, double? Lng = null, DateTime? PostedAt = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A pluggable source the ingestion engine pulls from. Configuration (enabled, channels, tokens)
|
/// A pluggable source the ingestion engine pulls from. Configuration (enabled, channels, tokens)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using JobsMedical.Web.Models;
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Services.Scraping;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scrapes clinical job ads from iranestekhdam.ir. It reads the site's monthly ad sitemaps
|
||||||
|
/// (sitemap-ads.xml → sitemap-ads-YYYY-M.xml) to enumerate ad URLs, keeps only those whose
|
||||||
|
/// readable Persian slug names a CLINICAL role (veterinary / non-clinical excluded), then fetches
|
||||||
|
/// each ad page and extracts its title + description (+ any phone). These are EMPLOYER ads at NAMED
|
||||||
|
/// facilities (بیمارستان/درمانگاه/کلینیک/آزمایشگاه …) — far higher quality than classifieds, so they
|
||||||
|
/// directly improve the «نامشخص»-facility problem. Content-hash dedupe ingests each ad once; the
|
||||||
|
/// medical-gate validator + AI auditor + junk filters do the final screening on top.
|
||||||
|
/// </summary>
|
||||||
|
public class IranEstekhdamListingSource : IListingSource
|
||||||
|
{
|
||||||
|
private const string SitemapIndex = "https://iranestekhdam.ir/sitemap-ads.xml";
|
||||||
|
private readonly ScrapeHttpClients _clients;
|
||||||
|
private readonly ILogger<IranEstekhdamListingSource> _log;
|
||||||
|
|
||||||
|
public IranEstekhdamListingSource(ScrapeHttpClients clients, ILogger<IranEstekhdamListingSource> log)
|
||||||
|
{
|
||||||
|
_clients = clients;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name => "ایراناستخدام (iranestekhdam.ir)";
|
||||||
|
|
||||||
|
// Clinical-role markers matched against the DECODED Persian URL slug. Words are hyphen-joined in
|
||||||
|
// the slug, so substring matching works on the decoded form.
|
||||||
|
private static readonly string[] RoleSlugs =
|
||||||
|
{
|
||||||
|
"پرستار", "بهیار", "کمک-پرستار", "کمک-بهیار", "پزشک", "دندان", "مامایی", "ماما", "تکنسین",
|
||||||
|
"رادیولوژ", "سونوگراف", "فیزیوتراپ", "کاردرمان", "گفتاردرمان", "شنوایی", "بینایی", "اپتومتر",
|
||||||
|
"دیالیز", "اتاق-عمل", "بیهوش", "تزریقات", "فوریت", "اورژانس", "داروساز", "نسخه", "سالمند",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Slugs that share a substring with a clinical role but are NOT کادر درمان — drop them.
|
||||||
|
private static readonly string[] ExcludeSlugs = { "دامپزشک", "دام-پزشک", "دامپزشکی" };
|
||||||
|
|
||||||
|
// LAUNCH = TEHRAN ONLY. We keep only ads located in Tehran (the ad's og:description reliably
|
||||||
|
// states «شهر تهران»). Other major cities named in the slug are pre-dropped to save fetches.
|
||||||
|
// When the engine is proven and we expand nationwide, make this a per-source city setting.
|
||||||
|
private const string Tehran = "تهران";
|
||||||
|
private static readonly string[] OtherCitySlugs =
|
||||||
|
{
|
||||||
|
"شیراز", "اصفهان", "مشهد", "تبریز", "کرج", "اهواز", "قم", "یزد", "رشت", "کرمان", "اراک",
|
||||||
|
"اردبیل", "همدان", "کرمانشاه", "زنجان", "قزوین", "ساری", "گرگان", "بندرعباس", "بوشهر",
|
||||||
|
"سنندج", "خرم-آباد", "بیرجند", "سمنان", "شهرکرد", "ایلام", "یاسوج", "زاهدان", "ارومیه",
|
||||||
|
"نجف-آباد", "کاشان", "قائم-شهر", "بابل", "آمل", "دزفول", "ملارد", "پاکدشت",
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ScrapedItem>> FetchAsync(AppSetting s, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!s.IranEstekhdamEnabled) return Array.Empty<ScrapedItem>();
|
||||||
|
var max = Math.Clamp(s.IranEstekhdamMaxAds, 1, 500);
|
||||||
|
var client = _clients.For(s, s.IranEstekhdamUseProxy);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 1. sitemap index → the monthly ad sitemaps (newest first as listed by the site)
|
||||||
|
var index = await client.GetStringAsync(SitemapIndex, ct);
|
||||||
|
var monthly = Locs(index).Where(u => u.Contains("sitemap-ads-")).ToList();
|
||||||
|
if (monthly.Count == 0) { _log.LogWarning("iranestekhdam: no monthly ad sitemaps found"); return Array.Empty<ScrapedItem>(); }
|
||||||
|
|
||||||
|
// 2. pool clinical-role candidate URLs, pre-dropping obvious non-Tehran slugs. We gather
|
||||||
|
// more than `max` because the authoritative Tehran check (on the ad text) trims further.
|
||||||
|
var pool = new List<string>();
|
||||||
|
var budget = max * 5;
|
||||||
|
foreach (var sm in monthly)
|
||||||
|
{
|
||||||
|
if (pool.Count >= budget) break;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var u in Locs(await client.GetStringAsync(sm, ct)))
|
||||||
|
{
|
||||||
|
if (IsClinicalSlug(u) && !IsOtherCitySlug(u) && !pool.Contains(u)) pool.Add(u);
|
||||||
|
if (pool.Count >= budget) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _log.LogWarning(ex, "iranestekhdam: sitemap {Sm} failed", sm); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. fetch each ad → keep only Tehran ones (text must name «تهران»), up to `max`.
|
||||||
|
var items = new List<ScrapedItem>();
|
||||||
|
foreach (var url in pool)
|
||||||
|
{
|
||||||
|
if (items.Count >= max) break;
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var html = await client.GetStringAsync(url, ct);
|
||||||
|
var text = ExtractAd(html);
|
||||||
|
if (text.Length < 25 || !text.Contains(Tehran)) continue; // Tehran-only launch filter
|
||||||
|
items.Add(new ScrapedItem("ایراناستخدام", text, url));
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _log.LogWarning(ex, "iranestekhdam: ad {Url} failed", url); }
|
||||||
|
}
|
||||||
|
_log.LogInformation("iranestekhdam: fetched {Count} Tehran clinical ads (from {Pool} pooled)", items.Count, pool.Count);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning(ex, "iranestekhdam fetch failed");
|
||||||
|
return Array.Empty<ScrapedItem>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsClinicalSlug(string url)
|
||||||
|
{
|
||||||
|
var slug = Uri.UnescapeDataString(url);
|
||||||
|
if (ExcludeSlugs.Any(slug.Contains)) return false;
|
||||||
|
return RoleSlugs.Any(slug.Contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOtherCitySlug(string url)
|
||||||
|
{
|
||||||
|
var slug = Uri.UnescapeDataString(url);
|
||||||
|
return OtherCitySlugs.Any(slug.Contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> Locs(string xml)
|
||||||
|
=> Regex.Matches(xml, "<loc>([^<]+)</loc>").Select(m => m.Groups[1].Value.Trim());
|
||||||
|
|
||||||
|
/// <summary>Title (site suffix stripped) + the ad's description. iranestekhdam puts a complete,
|
||||||
|
/// structured summary (facility + city + district + role) in og:description, with the full
|
||||||
|
/// requirements in the .single-ad container — prefer whichever yields more text.</summary>
|
||||||
|
private static string ExtractAd(string html)
|
||||||
|
{
|
||||||
|
var title = Meta(html, "og:title");
|
||||||
|
if (title is not null) { var bar = title.IndexOf('|'); if (bar > 10) title = title[..bar].Trim(); }
|
||||||
|
|
||||||
|
var ogBody = Meta(html, "og:description");
|
||||||
|
var single = BetweenClass(html, "single-ad");
|
||||||
|
var singleText = single is null ? null : HtmlUtil.ToPlainText(single);
|
||||||
|
var body = (singleText?.Length ?? 0) > (ogBody?.Length ?? 0) ? singleText : ogBody;
|
||||||
|
|
||||||
|
var text = HtmlUtil.ToPlainText(string.Join("\n", new[] { title, body }.Where(p => !string.IsNullOrWhiteSpace(p))));
|
||||||
|
if (text.Length > 1800) text = text[..1800];
|
||||||
|
|
||||||
|
var phones = HtmlUtil.HarvestPhones(body ?? "");
|
||||||
|
if (phones.Count > 0 && !phones.Any(text.Contains))
|
||||||
|
text += "\nشماره تماس: " + string.Join("، ", phones);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Meta(string html, string prop)
|
||||||
|
{
|
||||||
|
var m = Regex.Match(html, $"<meta[^>]+property=[\"']{Regex.Escape(prop)}[\"'][^>]+content=[\"']([^\"']*)[\"']");
|
||||||
|
return m.Success ? System.Net.WebUtility.HtmlDecode(m.Groups[1].Value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? BetweenClass(string html, string cls)
|
||||||
|
{
|
||||||
|
var m = Regex.Match(html, $"<(?:div|article|section)[^>]+class=[\"'][^\"']*{Regex.Escape(cls)}[^\"']*[\"'][^>]*>(.*?)</(?:div|article|section)>",
|
||||||
|
RegexOptions.Singleline);
|
||||||
|
return m.Success ? m.Groups[1].Value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ using JobsMedical.Web.Models;
|
|||||||
|
|
||||||
namespace JobsMedical.Web.Services.Scraping;
|
namespace JobsMedical.Web.Services.Scraping;
|
||||||
|
|
||||||
public record ValidationResult(bool IsValid, bool IsSpam, int Confidence, List<string> Issues);
|
public record ValidationResult(bool IsValid, bool IsSpam, int Confidence, List<string> Issues, bool LooksMedical = false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scores a parsed listing for completeness and screens out spam. A listing must look like a
|
/// Scores a parsed listing for completeness and screens out spam. A listing must look like a
|
||||||
@@ -39,6 +39,24 @@ public class ListingValidator
|
|||||||
"بوتاکس و فیلر", "مزوتراپی", "فیلر صورت",
|
"بوتاکس و فیلر", "مزوتراپی", "فیلر صورت",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Domestic-helper ads (housekeeping/cleaning/servant) — not کادر درمان, even when they also
|
||||||
|
// mention سالمند/نگهداری. The «امور منزل / نظافت» phrasing is the giveaway.
|
||||||
|
private static readonly string[] DomesticMarkers =
|
||||||
|
{
|
||||||
|
"امور منزل", "امور سبک منزل", "امورسبک منزل", "کارهای منزل", "کار منزل", "نظافت منزل",
|
||||||
|
"نظافتچی", "خدمتکار", "کارگر منزل", "خدمات منزل", "مستخدم",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Home childcare / babysitting — a family hiring someone to mind their child at home. NOT کادر
|
||||||
|
// درمان even when phrased «پرستار کودک/بچه». Clinical pediatric roles say «بخش اطفال/کودکان/NICU»,
|
||||||
|
// not «نگهداری/بچهداری» or a parent self-identifying («پدر/مادر کودک»).
|
||||||
|
private static readonly string[] ChildcareMarkers =
|
||||||
|
{
|
||||||
|
"بچه داری", "بچهداری", "بچه دار ", "نگهداری کودک", "نگهداری از کودک", "نگهداری بچه",
|
||||||
|
"نگهداری از بچه", "نگهداری فرزند", "نگهداری نوزاد", "نگهداری شیرخوار", "پرستار بچه",
|
||||||
|
"پدر کودک", "مادر کودک", "نگهدار کودک", "نگهدار بچه", "مراقبت از کودک", "مراقبت از بچه",
|
||||||
|
};
|
||||||
|
|
||||||
// Words that signal a real staffing post (hiring, shift, or availability).
|
// Words that signal a real staffing post (hiring, shift, or availability).
|
||||||
private static readonly string[] StaffingIntent =
|
private static readonly string[] StaffingIntent =
|
||||||
{
|
{
|
||||||
@@ -64,7 +82,21 @@ public class ListingValidator
|
|||||||
if (isPromo)
|
if (isPromo)
|
||||||
{
|
{
|
||||||
issues.Add("آگهی تبلیغاتی/آموزشی است، نه استخدام/شیفت");
|
issues.Add("آگهی تبلیغاتی/آموزشی است، نه استخدام/شیفت");
|
||||||
return new ValidationResult(false, true, 0, issues); // IsSpam → auto-discard
|
return new ValidationResult(false, true, 0, issues, looksMedical); // IsSpam → auto-discard
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domestic-helper / housekeeping ads — out of scope (not کادر درمان), discard.
|
||||||
|
if (DomesticMarkers.Any(text.Contains))
|
||||||
|
{
|
||||||
|
issues.Add("آگهی خدماتِ منزل/نظافت است، نه کادر درمان");
|
||||||
|
return new ValidationResult(false, true, 0, issues, looksMedical); // IsSpam → auto-discard
|
||||||
|
}
|
||||||
|
|
||||||
|
// Home childcare / babysitting — out of scope (not کادر درمان), discard.
|
||||||
|
if (ChildcareMarkers.Any(text.Contains))
|
||||||
|
{
|
||||||
|
issues.Add("آگهی نگهداری کودک در منزل است، نه کادر درمان");
|
||||||
|
return new ValidationResult(false, true, 0, issues, looksMedical); // IsSpam → auto-discard
|
||||||
}
|
}
|
||||||
|
|
||||||
// «آماده به کار»: a worker offering themselves. No facility/shift-date expected; the role
|
// «آماده به کار»: a worker offering themselves. No facility/shift-date expected; the role
|
||||||
@@ -84,7 +116,7 @@ public class ListingValidator
|
|||||||
if (tlen < 20) { ts -= 20; issues.Add("متن خیلی کوتاه است"); }
|
if (tlen < 20) { ts -= 20; issues.Add("متن خیلی کوتاه است"); }
|
||||||
ts = Math.Clamp(ts, 0, 100);
|
ts = Math.Clamp(ts, 0, 100);
|
||||||
bool tValid = !isSpam && looksMedical && ts >= 50; // role(40)+medical(10) passes w/o phone
|
bool tValid = !isSpam && looksMedical && ts >= 50; // role(40)+medical(10) passes w/o phone
|
||||||
return new ValidationResult(tValid, isSpam, ts, issues);
|
return new ValidationResult(tValid, isSpam, ts, issues, looksMedical);
|
||||||
}
|
}
|
||||||
|
|
||||||
int score = 0;
|
int score = 0;
|
||||||
@@ -107,6 +139,6 @@ public class ListingValidator
|
|||||||
|
|
||||||
// Valid enough for the queue if it's medical, not spam, and reasonably complete.
|
// Valid enough for the queue if it's medical, not spam, and reasonably complete.
|
||||||
bool isValid = !isSpam && looksMedical && score >= 50;
|
bool isValid = !isSpam && looksMedical && score >= 50;
|
||||||
return new ValidationResult(isValid, isSpam, score, issues);
|
return new ValidationResult(isValid, isSpam, score, issues, looksMedical);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using JobsMedical.Web.Models;
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Services.Scraping;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scrapes clinical ads from medboom.ir («مرجع استخدام و نیازمندی علوم پزشکی») — a WordPress
|
||||||
|
/// ad-listing site like medjobs.ir. It enumerates ad posts via the WP sitemap
|
||||||
|
/// (wp-sitemap.xml → wp-sitemap-posts-post-N.xml), newest first, keeps clinical-role slugs, and
|
||||||
|
/// extracts each ad's title + description (+ phone). medboom skews toward DOCTORS/DENTISTS and
|
||||||
|
/// carries BOTH hiring («نیازمند…») and availability («آماده همکاری / جویای کار») posts, so it
|
||||||
|
/// directly broadens the role mix the nurse-heavy classifieds sources miss. Tehran-only for launch.
|
||||||
|
/// VPN-free (Iranian-hosted). Content-hash dedupe ingests each ad once; the validator/AI screen on top.
|
||||||
|
/// </summary>
|
||||||
|
public class MedboomListingSource : IListingSource
|
||||||
|
{
|
||||||
|
private const string SitemapIndex = "https://medboom.ir/wp-sitemap.xml";
|
||||||
|
private readonly ScrapeHttpClients _clients;
|
||||||
|
private readonly ILogger<MedboomListingSource> _log;
|
||||||
|
|
||||||
|
public MedboomListingSource(ScrapeHttpClients clients, ILogger<MedboomListingSource> log)
|
||||||
|
{
|
||||||
|
_clients = clients;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name => "مدبوم (medboom.ir)";
|
||||||
|
|
||||||
|
// Clinical-role markers matched against the decoded Persian ad slug.
|
||||||
|
private static readonly string[] RoleSlugs =
|
||||||
|
{
|
||||||
|
"پزشک", "دندان", "پرستار", "بهیار", "مامایی", "ماما", "تکنسین", "رادیولوژ", "سونوگراف",
|
||||||
|
"فیزیوتراپ", "کاردرمان", "گفتاردرمان", "شنوایی", "بینایی", "اپتومتر", "دیالیز", "اتاق-عمل",
|
||||||
|
"بیهوش", "هوشبری", "تزریقات", "فوریت", "اورژانس", "داروساز", "داروخانه", "نسخه", "سالمند",
|
||||||
|
"علوم-آزمایشگاهی", "آزمایشگاه", "مسئول-فنی", "مامو", "تغذیه", "روانشناس", "اپتیک",
|
||||||
|
};
|
||||||
|
// Veterinary + obvious non-staffing categories medboom also carries (equipment sale, real estate).
|
||||||
|
private static readonly string[] ExcludeSlugs =
|
||||||
|
{
|
||||||
|
"دامپزشک", "دام-پزشک", "دامپزشکی", "فروش", "اجاره", "املاک", "دستگاه", "تجهیزات", "ملک",
|
||||||
|
};
|
||||||
|
|
||||||
|
private const string Tehran = "تهران";
|
||||||
|
private static readonly string[] OtherCitySlugs =
|
||||||
|
{
|
||||||
|
"شیراز", "اصفهان", "مشهد", "تبریز", "کرج", "قم", "یزد", "رشت", "کرمان", "اراک", "اردبیل",
|
||||||
|
"همدان", "کرمانشاه", "زنجان", "قزوین", "ساری", "گرگان", "بندرعباس", "بوشهر", "سنندج",
|
||||||
|
"بیرجند", "سمنان", "شهرکرد", "ایلام", "یاسوج", "زاهدان", "ارومیه", "البرز", "اهواز", "کاشان",
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ScrapedItem>> FetchAsync(AppSetting s, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!s.MedboomEnabled) return Array.Empty<ScrapedItem>();
|
||||||
|
var max = Math.Clamp(s.MedboomMaxAds, 1, 500);
|
||||||
|
var client = _clients.For(s, s.MedboomUseProxy);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 1. WP sitemap index → the ad-post sitemaps. Process newest first (highest-numbered).
|
||||||
|
var index = await client.GetStringAsync(SitemapIndex, ct);
|
||||||
|
var postMaps = Locs(index).Where(u => u.Contains("posts-post-"))
|
||||||
|
.OrderByDescending(u => u).ToList();
|
||||||
|
if (postMaps.Count == 0) { _log.LogWarning("medboom: no ad-post sitemaps found"); return Array.Empty<ScrapedItem>(); }
|
||||||
|
|
||||||
|
// 2. pool clinical candidate URLs (newest first within each map), pre-dropping other cities.
|
||||||
|
var pool = new List<string>();
|
||||||
|
var budget = max * 6;
|
||||||
|
foreach (var sm in postMaps)
|
||||||
|
{
|
||||||
|
if (pool.Count >= budget) break;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var urls = Locs(await client.GetStringAsync(sm, ct)).Reverse(); // newest ads last → take from end
|
||||||
|
foreach (var u in urls)
|
||||||
|
{
|
||||||
|
if (IsClinicalSlug(u) && !IsOtherCitySlug(u) && !pool.Contains(u)) pool.Add(u);
|
||||||
|
if (pool.Count >= budget) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _log.LogWarning(ex, "medboom: sitemap {Sm} failed", sm); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. fetch each ad → keep only Tehran ones, up to `max`.
|
||||||
|
var items = new List<ScrapedItem>();
|
||||||
|
foreach (var url in pool)
|
||||||
|
{
|
||||||
|
if (items.Count >= max) break;
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var html = await client.GetStringAsync(url, ct);
|
||||||
|
var text = ExtractAd(html);
|
||||||
|
if (text.Length < 25 || !text.Contains(Tehran)) continue; // Tehran-only launch filter
|
||||||
|
items.Add(new ScrapedItem("مدبوم", text, url));
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _log.LogWarning(ex, "medboom: ad {Url} failed", url); }
|
||||||
|
}
|
||||||
|
_log.LogInformation("medboom: fetched {Count} Tehran clinical ads (from {Pool} pooled)", items.Count, pool.Count);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning(ex, "medboom fetch failed");
|
||||||
|
return Array.Empty<ScrapedItem>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsClinicalSlug(string url)
|
||||||
|
{
|
||||||
|
var slug = Uri.UnescapeDataString(url);
|
||||||
|
if (ExcludeSlugs.Any(slug.Contains)) return false;
|
||||||
|
return RoleSlugs.Any(slug.Contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOtherCitySlug(string url)
|
||||||
|
{
|
||||||
|
var slug = Uri.UnescapeDataString(url);
|
||||||
|
return OtherCitySlugs.Any(slug.Contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> Locs(string xml)
|
||||||
|
=> Regex.Matches(xml, "<loc>([^<]+)</loc>").Select(m => m.Groups[1].Value.Trim());
|
||||||
|
|
||||||
|
private static string ExtractAd(string html)
|
||||||
|
{
|
||||||
|
var title = Meta(html, "og:title");
|
||||||
|
if (title is not null) { var bar = title.IndexOf('|'); if (bar > 10) title = title[..bar].Trim(); }
|
||||||
|
|
||||||
|
var ogBody = Meta(html, "og:description");
|
||||||
|
var entry = BetweenClass(html, "entry-content");
|
||||||
|
var entryText = entry is null ? null : HtmlUtil.ToPlainText(entry);
|
||||||
|
var body = (entryText?.Length ?? 0) > (ogBody?.Length ?? 0) ? entryText : ogBody;
|
||||||
|
|
||||||
|
var text = HtmlUtil.ToPlainText(string.Join("\n", new[] { title, body }.Where(p => !string.IsNullOrWhiteSpace(p))));
|
||||||
|
if (text.Length > 1800) text = text[..1800];
|
||||||
|
|
||||||
|
var phones = HtmlUtil.HarvestPhones(body ?? "");
|
||||||
|
if (phones.Count > 0 && !phones.Any(text.Contains))
|
||||||
|
text += "\nشماره تماس: " + string.Join("، ", phones);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Meta(string html, string prop)
|
||||||
|
{
|
||||||
|
var m = Regex.Match(html, $"<meta[^>]+property=[\"']{Regex.Escape(prop)}[\"'][^>]+content=[\"']([^\"']*)[\"']");
|
||||||
|
return m.Success ? System.Net.WebUtility.HtmlDecode(m.Groups[1].Value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? BetweenClass(string html, string cls)
|
||||||
|
{
|
||||||
|
var m = Regex.Match(html, $"<(?:div|article|section)[^>]+class=[\"'][^\"']*{Regex.Escape(cls)}[^\"']*[\"'][^>]*>(.*?)</(?:div|article|section)>",
|
||||||
|
RegexOptions.Singleline);
|
||||||
|
return m.Success ? m.Groups[1].Value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,8 +31,7 @@ public class SettingsService
|
|||||||
s.AiEndpoint = incoming.AiEndpoint?.Trim();
|
s.AiEndpoint = incoming.AiEndpoint?.Trim();
|
||||||
s.AiApiKey = incoming.AiApiKey?.Trim();
|
s.AiApiKey = incoming.AiApiKey?.Trim();
|
||||||
s.AiModel = incoming.AiModel?.Trim();
|
s.AiModel = incoming.AiModel?.Trim();
|
||||||
s.AiSystemPrompt = string.IsNullOrWhiteSpace(incoming.AiSystemPrompt)
|
s.AiSystemPrompt = AppSetting.DefaultPrompt; // hardcoded & read-only — keep the column in sync
|
||||||
? AppSetting.DefaultPrompt : incoming.AiSystemPrompt;
|
|
||||||
s.AiAutoApprove = incoming.AiAutoApprove;
|
s.AiAutoApprove = incoming.AiAutoApprove;
|
||||||
s.AiUseProxy = incoming.AiUseProxy;
|
s.AiUseProxy = incoming.AiUseProxy;
|
||||||
// Channel scraping sources
|
// Channel scraping sources
|
||||||
@@ -56,6 +55,12 @@ public class SettingsService
|
|||||||
s.DivarQueries = incoming.DivarQueries?.Trim();
|
s.DivarQueries = incoming.DivarQueries?.Trim();
|
||||||
s.MedjobsEnabled = incoming.MedjobsEnabled;
|
s.MedjobsEnabled = incoming.MedjobsEnabled;
|
||||||
s.MedjobsMaxAds = Math.Clamp(incoming.MedjobsMaxAds, 1, 500);
|
s.MedjobsMaxAds = Math.Clamp(incoming.MedjobsMaxAds, 1, 500);
|
||||||
|
s.IranEstekhdamEnabled = incoming.IranEstekhdamEnabled;
|
||||||
|
s.IranEstekhdamMaxAds = Math.Clamp(incoming.IranEstekhdamMaxAds, 1, 500);
|
||||||
|
s.IranEstekhdamUseProxy = incoming.IranEstekhdamUseProxy;
|
||||||
|
s.MedboomEnabled = incoming.MedboomEnabled;
|
||||||
|
s.MedboomMaxAds = Math.Clamp(incoming.MedboomMaxAds, 1, 500);
|
||||||
|
s.MedboomUseProxy = incoming.MedboomUseProxy;
|
||||||
s.SmsEnabled = incoming.SmsEnabled;
|
s.SmsEnabled = incoming.SmsEnabled;
|
||||||
s.SmsApiKey = incoming.SmsApiKey?.Trim();
|
s.SmsApiKey = incoming.SmsApiKey?.Trim();
|
||||||
s.SmsTemplate = incoming.SmsTemplate?.Trim();
|
s.SmsTemplate = incoming.SmsTemplate?.Trim();
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Services.Scraping;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Coarse neighborhood → APPROXIMATE center geocoder for Tehran. Many ads (Medjobs/Telegram) name a
|
||||||
|
/// neighborhood but carry no coordinates; this lets us show an approximate-area circle from the name
|
||||||
|
/// alone. Centers are deliberately rough (the UI always labels them «محدودهٔ تقریبی»), never an
|
||||||
|
/// address. Extend the table freely — order doesn't matter, matching is name-normalized + substring.
|
||||||
|
/// </summary>
|
||||||
|
public static class TehranGeo
|
||||||
|
{
|
||||||
|
private static readonly (string Name, double Lat, double Lng)[] Raw =
|
||||||
|
{
|
||||||
|
("سعادتآباد", 35.7872, 51.3760), ("شهرک غرب", 35.7570, 51.3680), ("نارمک", 35.7448, 51.5085),
|
||||||
|
("تهرانپارس", 35.7350, 51.5400), ("ونک", 35.7560, 51.4100), ("تجریش", 35.8040, 51.4340),
|
||||||
|
("ولیعصر", 35.7986, 51.4087), ("پارکوی", 35.7986, 51.4087), ("گیشا", 35.7400, 51.3880),
|
||||||
|
("برج میلاد", 35.7448, 51.3753), ("پاسداران", 35.7890, 51.4560), ("میرداماد", 35.7600, 51.4300),
|
||||||
|
("جردن", 35.7700, 51.4180), ("آفریقا", 35.7700, 51.4180), ("ولنجک", 35.8080, 51.4080),
|
||||||
|
("نیاوران", 35.8170, 51.4700), ("زعفرانیه", 35.8100, 51.4200), ("الهیه", 35.7900, 51.4320),
|
||||||
|
("قیطریه", 35.7950, 51.4450), ("فرمانیه", 35.8000, 51.4700), ("دروس", 35.7850, 51.4500),
|
||||||
|
("یوسفآباد", 35.7370, 51.4050), ("امیرآباد", 35.7260, 51.3920), ("انقلاب", 35.7010, 51.3940),
|
||||||
|
("صادقیه", 35.7150, 51.3450), ("پونک", 35.7620, 51.3300), ("جنتآباد", 35.7600, 51.3100),
|
||||||
|
("اکباتان", 35.7150, 51.3100), ("ستارخان", 35.7200, 51.3550), ("مرزداران", 35.7400, 51.3500),
|
||||||
|
("نازیآباد", 35.6400, 51.4080), ("یافتآباد", 35.6600, 51.3500), ("شهرری", 35.5850, 51.4350),
|
||||||
|
("پیروزی", 35.7000, 51.4800), ("رسالت", 35.7450, 51.5000), ("حکیمیه", 35.7450, 51.5800),
|
||||||
|
("تهرانسر", 35.7100, 51.2500), ("شریعتی", 35.7600, 51.4400), ("سهروردی", 35.7300, 51.4300),
|
||||||
|
("آزادی", 35.7000, 51.3600), ("جمهوری", 35.6960, 51.3920), ("هفت تیر", 35.7250, 51.4230),
|
||||||
|
("ولیعصر پایین", 35.7100, 51.4070), ("نواب", 35.6850, 51.3750), ("سعدی", 35.6900, 51.4250),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Built once: normalized name → center. Insertion order kept for the substring pass.
|
||||||
|
private static readonly List<(string Key, double Lat, double Lng)> Map =
|
||||||
|
Raw.Select(x => (Norm(x.Name), x.Lat, x.Lng)).ToList();
|
||||||
|
|
||||||
|
/// <summary>First of the given names that maps to a known Tehran neighborhood center (exact, then
|
||||||
|
/// substring — «میدان ونک» → «ونک»). Returns null when nothing matches.</summary>
|
||||||
|
public static (double lat, double lng)? Locate(params string?[] names)
|
||||||
|
{
|
||||||
|
foreach (var raw in names)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw)) continue;
|
||||||
|
var n = Norm(raw);
|
||||||
|
foreach (var m in Map) if (m.Key == n) return (m.Lat, m.Lng); // exact
|
||||||
|
foreach (var m in Map) if (n.Contains(m.Key)) return (m.Lat, m.Lng); // «… ونک …»
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Norm(string s) => Regex.Replace(
|
||||||
|
s.Replace('ي', 'ی').Replace('ك', 'ک').Replace('', ' ').Trim(), @"\s+", " ").ToLowerInvariant();
|
||||||
|
}
|
||||||
@@ -33,21 +33,28 @@ public class TelegramListingSource : IListingSource
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var html = await client.GetStringAsync($"https://t.me/s/{ch}", ct);
|
var html = await client.GetStringAsync($"https://t.me/s/{ch}", ct);
|
||||||
foreach (var text in ExtractMessages(html).Take(20))
|
foreach (var (text, postedAt) in ExtractMessages(html).Take(20))
|
||||||
items.Add(new ScrapedItem($"تلگرام/{ch}", text, $"https://t.me/{ch}"));
|
items.Add(new ScrapedItem($"تلگرام/{ch}", text, $"https://t.me/{ch}", PostedAt: postedAt));
|
||||||
}
|
}
|
||||||
catch (Exception ex) { _log.LogWarning(ex, "Telegram fetch failed for {Channel}", ch); }
|
catch (Exception ex) { _log.LogWarning(ex, "Telegram fetch failed for {Channel}", ch); }
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<string> ExtractMessages(string html)
|
private static IEnumerable<(string text, DateTime? postedAt)> ExtractMessages(string html)
|
||||||
{
|
{
|
||||||
foreach (Match m in Regex.Matches(html,
|
foreach (Match m in Regex.Matches(html,
|
||||||
"<div class=\"tgme_widget_message_text[^\"]*\"[^>]*>(.*?)</div>", RegexOptions.Singleline))
|
"<div class=\"tgme_widget_message_text[^\"]*\"[^>]*>(.*?)</div>", RegexOptions.Singleline))
|
||||||
{
|
{
|
||||||
var text = HtmlUtil.ToPlainText(m.Groups[1].Value);
|
var text = HtmlUtil.ToPlainText(m.Groups[1].Value);
|
||||||
if (text.Length >= 15) yield return text;
|
if (text.Length < 15) continue;
|
||||||
|
// The message's date link (<time datetime="…">) follows its text in the same bubble —
|
||||||
|
// grab the nearest one after this match.
|
||||||
|
DateTime? postedAt = null;
|
||||||
|
var tail = html.Substring(m.Index + m.Length, Math.Min(2000, html.Length - (m.Index + m.Length)));
|
||||||
|
var dm = Regex.Match(tail, "datetime=\"([^\"]+)\"");
|
||||||
|
if (dm.Success && DateTimeOffset.TryParse(dm.Groups[1].Value, out var dto)) postedAt = dto.UtcDateTime;
|
||||||
|
yield return (text, postedAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +70,30 @@ internal static class HtmlUtil
|
|||||||
return s.Trim();
|
return s.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Best-effort age (in days) of a post from a Persian "time ago" phrase in its text
|
||||||
|
/// («دیروز»، «۳ روز پیش»، «هفته پیش»، «۲ هفته پیش»، «ماه پیش»…). Divar embeds this in the row
|
||||||
|
/// text, so we can age-filter it without a real timestamp. Now/minutes/hours → 0; null when no
|
||||||
|
/// such phrase is present (caller then treats age as unknown).</summary>
|
||||||
|
public static int? AgeDaysFromPersianText(string? text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return null;
|
||||||
|
var t = ToLatinDigits(text);
|
||||||
|
if (Regex.IsMatch(t, "لحظات|هم[ ]?اکنون|چند لحظه|دقیقه پیش|دقایقی پیش|ساعت پیش|ساعتی پیش")) return 0;
|
||||||
|
if (t.Contains("پریروز")) return 2;
|
||||||
|
if (t.Contains("دیروز")) return 1;
|
||||||
|
var m = Regex.Match(t, @"(\d+)\s*(روز|هفته|ماه|سال)\s*پیش");
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
var n = int.Parse(m.Groups[1].Value);
|
||||||
|
return m.Groups[2].Value switch
|
||||||
|
{ "روز" => n, "هفته" => n * 7, "ماه" => n * 30, "سال" => n * 365, _ => (int?)null };
|
||||||
|
}
|
||||||
|
if (Regex.IsMatch(t, @"هفته\s*پیش")) return 7; // bare «هفته پیش» = ۱ هفته
|
||||||
|
if (Regex.IsMatch(t, @"ماه\s*پیش")) return 30;
|
||||||
|
if (Regex.IsMatch(t, @"سال\s*پیش") || t.Contains("پارسال")) return 365;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Convert Persian/Arabic-Indic digits to Latin.</summary>
|
/// <summary>Convert Persian/Arabic-Indic digits to Latin.</summary>
|
||||||
public static string ToLatinDigits(string s)
|
public static string ToLatinDigits(string s)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ public static class SeoJsonLd
|
|||||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>Whether a facility is a REAL named employer (not the «نامشخص» placeholder used for
|
||||||
|
/// aggregated ads with no named center). Google for Jobs rejects a JobPosting whose
|
||||||
|
/// hiringOrganization is empty/placeholder, so callers should skip the JSON-LD when this is false.</summary>
|
||||||
|
public static bool HasRealEmployer(Facility? f)
|
||||||
|
=> f is not null && !string.IsNullOrWhiteSpace(f.Name) && !f.Name.Contains("نامشخص") && !f.Name.Contains("ثبت نشده");
|
||||||
|
|
||||||
public static string ShiftPosting(Shift s, string baseUrl)
|
public static string ShiftPosting(Shift s, string baseUrl)
|
||||||
{
|
{
|
||||||
var typeLabel = s.ShiftType switch
|
var typeLabel = s.ShiftType switch
|
||||||
@@ -94,6 +100,26 @@ public static class SeoJsonLd
|
|||||||
return Fix(JsonSerializer.Serialize(obj, Opts));
|
return Fix(JsonSerializer.Serialize(obj, Opts));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>schema.org structured data for a facility page — a Hospital/MedicalClinic with its
|
||||||
|
/// address, map coordinates, and aggregate review rating, so Google can show a rich place result.</summary>
|
||||||
|
public static string MedicalOrganization(Facility f, string baseUrl, double avgRating = 0, int ratingCount = 0)
|
||||||
|
{
|
||||||
|
var schemaType = f.Type == FacilityType.Hospital ? "Hospital" : "MedicalClinic";
|
||||||
|
var obj = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["@context"] = "https://schema.org",
|
||||||
|
["@type"] = schemaType,
|
||||||
|
["name"] = f.Name,
|
||||||
|
["url"] = $"{baseUrl}/Facilities/Details/{f.Id}",
|
||||||
|
["address"] = new { type = "PostalAddress", addressLocality = f.City?.Name, addressCountry = "IR", streetAddress = f.Address },
|
||||||
|
};
|
||||||
|
if (f.Lat is double la && f.Lng is double lo)
|
||||||
|
obj["geo"] = new { type = "GeoCoordinates", latitude = la, longitude = lo };
|
||||||
|
if (ratingCount > 0)
|
||||||
|
obj["aggregateRating"] = new { type = "AggregateRating", ratingValue = Math.Round(avgRating, 1), reviewCount = ratingCount };
|
||||||
|
return Fix(JsonSerializer.Serialize(obj, Opts));
|
||||||
|
}
|
||||||
|
|
||||||
public static string Organization(string baseUrl) => Fix(JsonSerializer.Serialize(new Dictionary<string, object?>
|
public static string Organization(string baseUrl) => Fix(JsonSerializer.Serialize(new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["@context"] = "https://schema.org",
|
["@context"] = "https://schema.org",
|
||||||
@@ -119,9 +145,48 @@ public static class SeoJsonLd
|
|||||||
},
|
},
|
||||||
}, Opts));
|
}, Opts));
|
||||||
|
|
||||||
|
/// <summary>BreadcrumbList JSON-LD from an ordered crumb trail (relative URLs are made absolute).
|
||||||
|
/// Google can then show the breadcrumb path in search results.</summary>
|
||||||
|
public static string Breadcrumb(IReadOnlyList<Crumb> items, string baseUrl)
|
||||||
|
{
|
||||||
|
var els = new List<object>();
|
||||||
|
for (var i = 0; i < items.Count; i++)
|
||||||
|
{
|
||||||
|
var el = new Dictionary<string, object?> { ["type"] = "ListItem", ["position"] = i + 1, ["name"] = items[i].Name };
|
||||||
|
if (!string.IsNullOrEmpty(items[i].Url))
|
||||||
|
el["item"] = items[i].Url!.StartsWith("http") ? items[i].Url : baseUrl + items[i].Url;
|
||||||
|
els.Add(el);
|
||||||
|
}
|
||||||
|
return Fix(JsonSerializer.Serialize(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["@context"] = "https://schema.org",
|
||||||
|
["@type"] = "BreadcrumbList",
|
||||||
|
["itemListElement"] = els,
|
||||||
|
}, Opts));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ItemList JSON-LD for a results/landing page — an ordered list of the listing URLs,
|
||||||
|
/// so Google understands it as a curated collection. Relative URLs are made absolute.</summary>
|
||||||
|
public static string ItemList(IEnumerable<string> urls, string baseUrl)
|
||||||
|
{
|
||||||
|
var els = urls.Select((u, i) => (object)new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["type"] = "ListItem", ["position"] = i + 1, ["url"] = u.StartsWith("http") ? u : baseUrl + u,
|
||||||
|
}).ToList();
|
||||||
|
return Fix(JsonSerializer.Serialize(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["@context"] = "https://schema.org",
|
||||||
|
["@type"] = "ItemList",
|
||||||
|
["itemListElement"] = els,
|
||||||
|
}, Opts));
|
||||||
|
}
|
||||||
|
|
||||||
// Nested anonymous objects use "type"/"queryyy" placeholders for @type / query-input;
|
// Nested anonymous objects use "type"/"queryyy" placeholders for @type / query-input;
|
||||||
// restore the @-prefixed schema.org keys here.
|
// restore the @-prefixed schema.org keys here.
|
||||||
private static string Fix(string json) => json
|
private static string Fix(string json) => json
|
||||||
.Replace("\"type\":", "\"@type\":")
|
.Replace("\"type\":", "\"@type\":")
|
||||||
.Replace("\"queryyy\":", "\"query-input\":");
|
.Replace("\"queryyy\":", "\"query-input\":");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>One step in a breadcrumb trail. <see cref="Url"/> is null for the current (last) page.</summary>
|
||||||
|
public record Crumb(string Name, string? Url = null);
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pretty-URL slugs for SEO landing pages (e.g. /استخدام/پزشک-عمومی/تهران). We keep Persian
|
||||||
|
/// characters — Google indexes UTF-8 URLs fine and they read naturally — and just turn spaces into
|
||||||
|
/// hyphens. Matching is tolerant of ي/ك, ZWNJ and hyphen/space variants so a hand-typed or
|
||||||
|
/// search-engine-rewritten slug still resolves.
|
||||||
|
/// </summary>
|
||||||
|
public static class SeoSlug
|
||||||
|
{
|
||||||
|
/// <summary>The canonical slug for a role/city name («پزشک عمومی» → «پزشک-عمومی»).</summary>
|
||||||
|
public static string Of(string? name) => Key(name);
|
||||||
|
|
||||||
|
/// <summary>True when <paramref name="slug"/> (from the URL) refers to <paramref name="name"/>.</summary>
|
||||||
|
public static bool Matches(string? name, string? slug) => Key(name) == Key(slug);
|
||||||
|
|
||||||
|
private static string Key(string? s) => Regex.Replace(
|
||||||
|
(s ?? "").Trim().Replace('ي', 'ی').Replace('ك', 'ک').Replace('', ' ').Replace('-', ' '),
|
||||||
|
@"\s+", "-").ToLowerInvariant();
|
||||||
|
}
|
||||||
@@ -71,6 +71,26 @@ a { color: inherit; text-decoration: none; }
|
|||||||
content: ""; position: absolute; inset-inline: 0; bottom: -6px; height: 2px;
|
content: ""; position: absolute; inset-inline: 0; bottom: -6px; height: 2px;
|
||||||
background: var(--accent); border-radius: 2px;
|
background: var(--accent); border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
/* «بیشتر» nav dropdown (native <details>) — secondary browse links (facilities, calendar). */
|
||||||
|
.nav-more { position: relative; }
|
||||||
|
.nav-more > summary { list-style: none; cursor: pointer; color: var(--muted); font-weight: 600; font-size: 15px; white-space: nowrap; padding: 4px 0; user-select: none; }
|
||||||
|
.nav-more > summary::-webkit-details-marker, .nav-more > summary::marker { display: none; content: ""; }
|
||||||
|
.nav-more > summary:hover, .nav-more[open] > summary, .nav-more > summary.active { color: var(--primary-dark); }
|
||||||
|
.nav-more-menu { position: absolute; top: 200%; inset-inline-end: 0; background: #fff; border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 12px 30px rgba(0,0,0,.12); padding: 6px; min-width: 180px; z-index: 60; display: flex; flex-direction: column; }
|
||||||
|
.nav-more-menu a { color: var(--text); font-weight: 600; font-size: 14px; padding: 9px 12px; border-radius: 8px; white-space: nowrap; }
|
||||||
|
.nav-more-menu a:hover, .nav-more-menu a.active { background: var(--primary-soft); color: var(--primary-dark); }
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pager { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 6px; margin: 26px 0 4px; }
|
||||||
|
.pager-btn, .pager-num {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; min-width: 38px; height: 38px;
|
||||||
|
padding: 0 12px; border: 1px solid var(--line); border-radius: 9px; background: #fff;
|
||||||
|
color: var(--text); font-weight: 600; font-size: 14px; text-decoration: none; transition: all .15s;
|
||||||
|
}
|
||||||
|
.pager-btn:hover, .pager-num:hover { border-color: var(--primary); color: var(--primary-dark); }
|
||||||
|
.pager-num.active { background: var(--primary); border-color: var(--primary); color: #fff; cursor: default; }
|
||||||
|
.pager-gap { padding: 0 2px; color: var(--muted); }
|
||||||
|
|
||||||
.cta-post { white-space: nowrap; box-shadow: 0 2px 8px rgba(240,132,62,.35); }
|
.cta-post { white-space: nowrap; box-shadow: 0 2px 8px rgba(240,132,62,.35); }
|
||||||
.header-actions { display: flex; align-items: center; gap: 12px; margin-inline-start: auto; }
|
.header-actions { display: flex; align-items: center; gap: 12px; margin-inline-start: auto; }
|
||||||
.nav-action { font-weight: 600; font-size: 15px; color: var(--muted); white-space: nowrap; transition: color .15s; }
|
.nav-action { font-weight: 600; font-size: 15px; color: var(--muted); white-space: nowrap; transition: color .15s; }
|
||||||
@@ -208,8 +228,8 @@ button, input, select, textarea, optgroup { font-family: inherit; }
|
|||||||
background: linear-gradient(135deg, #0e8f8a 0%, #0a6f6b 100%);
|
background: linear-gradient(135deg, #0e8f8a 0%, #0a6f6b 100%);
|
||||||
color: #fff; padding: clamp(40px, 8vw, 64px) 0 clamp(48px, 9vw, 80px); text-align: center;
|
color: #fff; padding: clamp(40px, 8vw, 64px) 0 clamp(48px, 9vw, 80px); text-align: center;
|
||||||
}
|
}
|
||||||
.hero h1 { font-size: clamp(23px, 5.5vw, 34px); font-weight: 900; margin: 0 0 14px; line-height: 1.45; }
|
.hero h1 { font-size: clamp(19px, 5.2vw, 34px); font-weight: 900; margin: 0 0 12px; line-height: 1.4; }
|
||||||
.hero p { font-size: clamp(15px, 3.2vw, 17px); opacity: .92; max-width: 620px; margin: 0 auto 30px; }
|
.hero p { font-size: clamp(13px, 3vw, 17px); opacity: .92; max-width: 620px; margin: 0 auto 28px; line-height: 1.7; }
|
||||||
|
|
||||||
/* search box on hero */
|
/* search box on hero */
|
||||||
.search-card {
|
.search-card {
|
||||||
@@ -294,13 +314,64 @@ mark { background: #fff3bf; color: inherit; padding: 0 2px; border-radius: 3px;
|
|||||||
.nav-search-results a:hover { background: var(--primary-soft); }
|
.nav-search-results a:hover { background: var(--primary-soft); }
|
||||||
.nav-search-results .ns-type { flex: 0 0 auto; font-size: 11px; font-weight: 700; color: var(--primary-dark);
|
.nav-search-results .ns-type { flex: 0 0 auto; font-size: 11px; font-weight: 700; color: var(--primary-dark);
|
||||||
background: var(--primary-soft); border-radius: 6px; padding: 2px 7px; }
|
background: var(--primary-soft); border-radius: 6px; padding: 2px 7px; }
|
||||||
.nav-search-results .ns-text { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
.nav-search-results .ns-text { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
.nav-search-results .ns-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.nav-search-results .ns-label { font-weight: 800; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.nav-search-results .ns-sub { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.nav-search-results .ns-sub { font-size: 11.5px; color: var(--muted); line-height: 1.5;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
/* ES-style matched snippet shown under a search-result card */
|
/* ES-style matched snippet shown under a search-result card */
|
||||||
.search-snippet { font-size: 12.5px; color: var(--muted); line-height: 1.6; margin: 4px 0 2px;
|
.search-snippet { font-size: 12.5px; color: var(--muted); line-height: 1.6; margin: 4px 0 2px;
|
||||||
background: var(--bg); border-inline-start: 3px solid var(--primary-soft); padding: 5px 9px; border-radius: 6px; }
|
background: var(--bg); border-inline-start: 3px solid var(--primary-soft); padding: 5px 9px; border-radius: 6px; }
|
||||||
.nav-search-results .ns-all { font-weight: 700; color: var(--primary-dark); justify-content: center; }
|
.nav-search-results .ns-all { font-weight: 700; color: var(--primary-dark); justify-content: center; }
|
||||||
|
|
||||||
|
/* Homepage hero — search-engine box (replaces the old filter form) */
|
||||||
|
.hero-search { position: relative; max-width: 720px; margin: 10px auto 0; }
|
||||||
|
.hero-search-pill { position: relative; display: flex; align-items: center; gap: 8px; background: var(--surface);
|
||||||
|
border-radius: 16px; padding: 8px; box-shadow: 0 18px 44px rgba(0,0,0,.20); }
|
||||||
|
.hero-search-pill .hs-ico { font-size: 18px; opacity: .55; flex: 0 0 auto; padding-inline-start: 10px; }
|
||||||
|
.hero-search-pill input { flex: 1; min-width: 0; border: none; background: transparent; font-family: inherit;
|
||||||
|
font-size: 16px; padding: 10px 4px; color: var(--ink); }
|
||||||
|
.hero-search-pill input:focus { outline: none; }
|
||||||
|
.hero-search-pill .btn { flex: 0 0 auto; }
|
||||||
|
.hs-submit .hs-submit-ico { display: none; } /* desktop shows the «جستجو» label; icon is mobile-only */
|
||||||
|
/* Dropdown anchors to the pill (its positioned ancestor) → sits directly under the input,
|
||||||
|
full pill width (override the header dropdown's min/max-width caps). */
|
||||||
|
.hero-search .nav-search-results { inset-inline: 0; min-width: 0; max-width: none; top: calc(100% + 8px); text-align: start; }
|
||||||
|
.hero-chips { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: center; margin-top: 14px; font-size: 13px; }
|
||||||
|
.hero-chips .hc-label { color: rgba(255,255,255,.85); }
|
||||||
|
.hero-chips a { background: rgba(255,255,255,.16); color: #fff; padding: 5px 13px; border-radius: 999px; font-weight: 600; transition: background .15s; }
|
||||||
|
.hero-chips a:hover { background: rgba(255,255,255,.3); }
|
||||||
|
/* Role quick-links on the list pages — internal links to the SEO landing pages. */
|
||||||
|
.role-links { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0 0 18px; }
|
||||||
|
.role-links .rl-label { color: var(--muted); font-size: 13px; font-weight: 700; }
|
||||||
|
.role-links .rl-chip { background: var(--surface); border: 1px solid var(--line); color: var(--ink);
|
||||||
|
padding: 5px 12px; border-radius: 999px; font-size: 13px; transition: all .15s; }
|
||||||
|
.role-links .rl-chip:hover { border-color: var(--primary); color: var(--primary); }
|
||||||
|
/* Breadcrumb trail (paired with BreadcrumbList JSON-LD). */
|
||||||
|
.breadcrumbs { font-size: 12.5px; color: var(--muted); margin: 0 0 12px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||||
|
.breadcrumbs a { color: var(--muted); }
|
||||||
|
.breadcrumbs a:hover { color: var(--primary); }
|
||||||
|
.breadcrumbs .bc-sep { opacity: .55; }
|
||||||
|
.breadcrumbs .bc-current { color: var(--ink); font-weight: 600; }
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
/* Smaller, tighter typography on phones */
|
||||||
|
.hero { padding: 28px 0 32px; }
|
||||||
|
.hero h1 { font-size: 18px; line-height: 1.55; margin-bottom: 8px; }
|
||||||
|
.hero p { font-size: 12.5px; line-height: 1.8; margin-bottom: 20px; }
|
||||||
|
.section-head h2, .page-head h1 { font-size: 18px; }
|
||||||
|
.hero-search { margin-top: 6px; }
|
||||||
|
/* Stay a single row: text input + a compact magnify button sitting inside the pill. */
|
||||||
|
.hero-search-pill { gap: 6px; padding: 6px; border-radius: 12px; }
|
||||||
|
.hero-search-pill .hs-ico { display: none; }
|
||||||
|
.hero-search-pill input { padding: 10px 10px; font-size: 14px; }
|
||||||
|
.hero-search-pill .btn.hs-submit { width: 44px; min-width: 44px; height: 44px; padding: 0;
|
||||||
|
border-radius: 10px; font-size: 18px; justify-content: center; }
|
||||||
|
.hs-submit .hs-submit-txt { display: none; }
|
||||||
|
.hs-submit .hs-submit-ico { display: inline; }
|
||||||
|
.hero-chips { gap: 6px; font-size: 12px; }
|
||||||
|
.hero-chips .hc-label { flex-basis: 100%; text-align: center; margin-bottom: 2px; }
|
||||||
|
.stat-pill .n { font-size: 18px; }
|
||||||
|
.stat-pill .l { font-size: 11px; }
|
||||||
|
}
|
||||||
/* Big search box on the /Search page head */
|
/* Big search box on the /Search page head */
|
||||||
.search-hero { display: flex; gap: 8px; max-width: 640px; margin: 6px 0 4px; }
|
.search-hero { display: flex; gap: 8px; max-width: 640px; margin: 6px 0 4px; }
|
||||||
.search-hero input { flex: 1; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px;
|
.search-hero input { flex: 1; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px;
|
||||||
@@ -324,6 +395,22 @@ mark { background: #fff3bf; color: inherit; padding: 0 2px; border-radius: 3px;
|
|||||||
.contact-row .btn { flex: 0 0 auto; padding: 6px 14px; }
|
.contact-row .btn { flex: 0 0 auto; padding: 6px 14px; }
|
||||||
.badge-gender { background: #f3eefb; color: #6b3fa0; }
|
.badge-gender { background: #f3eefb; color: #6b3fa0; }
|
||||||
|
|
||||||
|
/* ---------- Contact modal (lazy-loaded numbers) ---------- */
|
||||||
|
.contact-modal { position: fixed; inset: 0; z-index: 200; display: none; align-items: center;
|
||||||
|
justify-content: center; padding: 16px; background: rgba(15,23,42,.55); }
|
||||||
|
.contact-modal.show { display: flex; animation: revealIn .2s ease; }
|
||||||
|
.contact-modal-box { background: var(--surface); border-radius: 16px; width: 100%; max-width: 420px;
|
||||||
|
box-shadow: 0 24px 60px rgba(0,0,0,.3); overflow: hidden; animation: revealIn .25s cubic-bezier(.2,.7,.3,1); }
|
||||||
|
.contact-modal-head { display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 14px 16px; border-bottom: 1px solid var(--line); }
|
||||||
|
.contact-modal-head h3 { margin: 0; font-size: 16px; }
|
||||||
|
.contact-modal-x { background: none; border: none; font-size: 18px; cursor: pointer; color: var(--muted);
|
||||||
|
line-height: 1; padding: 4px 6px; border-radius: 8px; }
|
||||||
|
.contact-modal-x:hover { background: var(--bg); color: var(--ink); }
|
||||||
|
.contact-modal-body { padding: 14px 16px; }
|
||||||
|
/* The card-level trigger sits inside an <a>; show it as the primary action. */
|
||||||
|
.contact-trigger { cursor: pointer; }
|
||||||
|
|
||||||
/* ---------- Filters layout ---------- */
|
/* ---------- Filters layout ---------- */
|
||||||
.layout-2 { display: grid; grid-template-columns: 270px 1fr; gap: 24px; align-items: start; }
|
.layout-2 { display: grid; grid-template-columns: 270px 1fr; gap: 24px; align-items: start; }
|
||||||
.filter-card { position: sticky; top: 84px; }
|
.filter-card { position: sticky; top: 84px; }
|
||||||
@@ -430,6 +517,13 @@ mark { background: #fff3bf; color: inherit; padding: 0 2px; border-radius: 3px;
|
|||||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
||||||
.settings-panel h3:first-child { margin-top: 0; }
|
.settings-panel h3:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
/* Multi-select role checkboxes on the review/publish form */
|
||||||
|
.role-checks { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 6px; }
|
||||||
|
.role-check { display: flex; align-items: center; gap: 7px; padding: 7px 10px; border: 1px solid var(--line);
|
||||||
|
border-radius: 10px; cursor: pointer; font-size: 13.5px; font-weight: 600; background: var(--bg); }
|
||||||
|
.role-check input { width: 16px; height: 16px; flex: 0 0 auto; }
|
||||||
|
.role-check:has(input:checked) { border-color: var(--primary); background: var(--primary-soft); color: var(--primary-dark); }
|
||||||
|
|
||||||
/* Each ingestion source gets its own card so the settings don't run together. */
|
/* Each ingestion source gets its own card so the settings don't run together. */
|
||||||
.source-box { border: 1px solid var(--line); border-radius: 14px; padding: 14px; margin: 12px 0; background: var(--surface); }
|
.source-box { border: 1px solid var(--line); border-radius: 14px; padding: 14px; margin: 12px 0; background: var(--surface); }
|
||||||
.source-box .toggle-row { background: var(--bg); margin-bottom: 10px; }
|
.source-box .toggle-row { background: var(--bg); margin-bottom: 10px; }
|
||||||
@@ -533,6 +627,12 @@ mark { background: #fff3bf; color: inherit; padding: 0 2px; border-radius: 3px;
|
|||||||
.main-nav a.active { background: var(--primary-soft); border-radius: 8px; }
|
.main-nav a.active { background: var(--primary-soft); border-radius: 8px; }
|
||||||
.main-nav a:last-child { border-bottom: none; }
|
.main-nav a:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
/* In the burger panel the «بیشتر» menu expands inline (no floating dropdown). */
|
||||||
|
.nav-more { width: 100%; }
|
||||||
|
.nav-more > summary { padding: 13px 20px; font-size: 15px; border-bottom: 1px solid var(--line); }
|
||||||
|
.nav-more-menu { position: static; box-shadow: none; border: none; padding: 0; min-width: 0; }
|
||||||
|
.nav-more-menu a { padding: 13px 34px; border-bottom: 1px solid var(--line); border-radius: 0; }
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
flex-direction: column; align-items: stretch; gap: 0;
|
flex-direction: column; align-items: stretch; gap: 0;
|
||||||
margin: 0; padding: 8px 14px 16px; border-top: 1px solid var(--line);
|
margin: 0; padding: 8px 14px 16px; border-top: 1px solid var(--line);
|
||||||
|
|||||||
Reference in New Issue
Block a user