Compare commits
119 Commits
aa61efd46f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7acf94695f | |||
| 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 | |||
| 5e5d7f80ef | |||
| 8b0b21f24d | |||
| bd8d754ee8 | |||
| 69a630d185 | |||
| 3d1d72ed9b | |||
| 36612b6bf0 | |||
| eb7d0f6559 | |||
| 61afc957aa | |||
| 9db4deafbc | |||
| 234bcd1f88 | |||
| 6b657c7795 | |||
| e4dc5180ad | |||
| 48760c4e83 | |||
| 13e00ec011 | |||
| 386e25c8fd | |||
| 70c048a37b | |||
| fb02c81830 | |||
| 2bb8771ade | |||
| 490821a637 | |||
| f9d7c48d88 | |||
| 0622270cd2 | |||
| b092a5cfe5 | |||
| a5d6e212e2 | |||
| d238888710 | |||
| 213af9db48 | |||
| 4e5df73cf7 | |||
| bdcca5e548 | |||
| e6a796ab27 | |||
| a2fc70ae57 | |||
| 5f769b0293 | |||
| da6e86fa7f | |||
| 3d128ea051 | |||
| 487c7ca82f | |||
| 524c66e25e | |||
| 0c49b89891 | |||
| 018c0f0286 | |||
| 33c13ec524 | |||
| 69e4f305e9 | |||
| 2485173aad | |||
| 6af6a026a1 |
@@ -39,20 +39,18 @@ jobs:
|
||||
git fetch --depth=1 origin "${REF}"
|
||||
git checkout FETCH_HEAD
|
||||
|
||||
- name: Write NuGet config (Liara primary; Nexus optional)
|
||||
# NOTE: mirror.soroushasadi.com currently serves an incomplete TLS chain
|
||||
# (leaf only, no intermediate). .NET on Linux does NOT auto-fetch the
|
||||
# intermediate via AIA the way Windows does, so it fails with PartialChain.
|
||||
# Liara serves a complete chain, so it is the deterministic source here.
|
||||
# Re-add Nexus once nginx points ssl_certificate at fullchain.pem.
|
||||
- name: Write NuGet config (Nexus only)
|
||||
# Single source = our Nexus mirror. We do NOT list Liara as a fallback: NuGet loads
|
||||
# the service index of EVERY configured source, so a 500 from a fallback aborts the
|
||||
# whole restore (NU1301). Nexus is the source of truth.
|
||||
run: |
|
||||
cat > /tmp/nuget.ci.config << 'EOF'
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="liara"
|
||||
value="https://package-mirror.liara.ir/repository/nuget/index.json"
|
||||
<add key="nexus"
|
||||
value="https://mirror.soroushasadi.com/repository/nuget-group/index.json"
|
||||
protocolVersion="3" />
|
||||
</packageSources>
|
||||
<config>
|
||||
|
||||
@@ -21,3 +21,6 @@ Thumbs.db
|
||||
# local dev run logs
|
||||
/run.log
|
||||
/run.err
|
||||
|
||||
# Xray VPN config holds real credentials — keep it server-only.
|
||||
deploy/xray/config.json
|
||||
|
||||
@@ -11,9 +11,12 @@ pointed at that proxy from the admin panel, and only ingestion traffic goes thro
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Put your config** in `deploy/xray/config.json`. Replace the `proxy` outbound with your
|
||||
own vmess / vless / trojan outbound (templates below). Keep the `inbounds` and `routing`
|
||||
sections as-is so the local SOCKS/HTTP ports stay the same.
|
||||
1. **Create your config** from the example (it is git-ignored, so deploys never overwrite it):
|
||||
```bash
|
||||
cp deploy/xray/config.json.example deploy/xray/config.json
|
||||
nano deploy/xray/config.json # replace the `proxy` outbound with your vmess/vless/trojan
|
||||
```
|
||||
Keep the `inbounds` and `routing` sections as-is so the local SOCKS/HTTP ports stay the same.
|
||||
|
||||
2. **Start the sidecar** (it's behind a compose profile so normal deploys don't run it):
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.0",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -2,9 +2,9 @@
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<!-- Soroush Nexus mirror (primary) + Liara mirror (fallback) — nuget.org is filtered. -->
|
||||
<!-- Single source: Soroush Nexus mirror. No Liara fallback — NuGet probes every
|
||||
listed source's index, so a dead fallback (500) aborts the whole restore. -->
|
||||
<add key="nexus" value="https://mirror.soroushasadi.com/repository/nuget-group/index.json" protocolVersion="3" />
|
||||
<add key="liara" value="https://package-mirror.liara.ir/repository/nuget/index.json" protocolVersion="3" />
|
||||
</packageSources>
|
||||
<config>
|
||||
<add key="http_retry_count" value="6" />
|
||||
|
||||
+6
-7
@@ -1,17 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
NuGet source for the Docker image build (Linux .NET — runs `dotnet restore`
|
||||
inside the SDK container). Uses the Liara mirror because it serves a complete
|
||||
TLS chain. mirror.soroushasadi.com currently serves a leaf-only chain, which
|
||||
.NET on Linux rejects with PartialChain (Windows auto-fetches the missing
|
||||
intermediate via AIA; Linux does not). Re-add Nexus once nginx serves
|
||||
fullchain.pem for mirror.soroushasadi.com.
|
||||
inside the SDK container). Single source = our Nexus mirror. We deliberately
|
||||
do NOT list a fallback: NuGet loads the service index of every configured
|
||||
source, so a 500 from a fallback would abort the whole restore. nuget.org is
|
||||
filtered in Iran and is intentionally absent — Nexus is the source of truth.
|
||||
-->
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="liara"
|
||||
value="https://package-mirror.liara.ir/repository/nuget/index.json"
|
||||
<add key="nexus"
|
||||
value="https://mirror.soroushasadi.com/repository/nuget-group/index.json"
|
||||
protocolVersion="3" />
|
||||
</packageSources>
|
||||
<config>
|
||||
|
||||
@@ -19,6 +19,8 @@ public class AppDbContext : DbContext, IDataProtectionKeyContext
|
||||
public DbSet<Facility> Facilities => Set<Facility>();
|
||||
public DbSet<Shift> Shifts => Set<Shift>();
|
||||
public DbSet<JobOpening> JobOpenings => Set<JobOpening>();
|
||||
public DbSet<TalentListing> TalentListings => Set<TalentListing>();
|
||||
public DbSet<ContactMethod> ContactMethods => Set<ContactMethod>();
|
||||
public DbSet<Application> Applications => Set<Application>();
|
||||
public DbSet<RawListing> RawListings => Set<RawListing>();
|
||||
public DbSet<Visitor> Visitors => Set<Visitor>();
|
||||
@@ -30,7 +32,9 @@ public class AppDbContext : DbContext, IDataProtectionKeyContext
|
||||
public DbSet<Report> Reports => Set<Report>();
|
||||
public DbSet<FacilityDocument> FacilityDocuments => Set<FacilityDocument>();
|
||||
public DbSet<JobAlert> JobAlerts => Set<JobAlert>();
|
||||
public DbSet<IngestionRun> IngestionRuns => Set<IngestionRun>();
|
||||
public DbSet<Review> Reviews => Set<Review>();
|
||||
public DbSet<Like> Likes => Set<Like>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder b)
|
||||
{
|
||||
@@ -141,6 +145,35 @@ public class AppDbContext : DbContext, IDataProtectionKeyContext
|
||||
b.Entity<JobOpening>().HasIndex(j => j.Status);
|
||||
b.Entity<JobOpening>().HasIndex(j => j.FacilityId);
|
||||
|
||||
// Talent listings («آماده به کار») — no facility; keep role/city but don't cascade from them.
|
||||
b.Entity<TalentListing>()
|
||||
.HasOne(t => t.Role).WithMany()
|
||||
.HasForeignKey(t => t.RoleId).OnDelete(DeleteBehavior.Restrict);
|
||||
b.Entity<TalentListing>()
|
||||
.HasOne(t => t.City).WithMany()
|
||||
.HasForeignKey(t => t.CityId).OnDelete(DeleteBehavior.Restrict);
|
||||
b.Entity<TalentListing>()
|
||||
.HasOne(t => t.District).WithMany()
|
||||
.HasForeignKey(t => t.DistrictId).OnDelete(DeleteBehavior.SetNull);
|
||||
b.Entity<TalentListing>().HasIndex(t => t.Status);
|
||||
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>()
|
||||
.HasOne(c => c.TalentListing).WithMany(t => t.Contacts)
|
||||
.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<Notification>()
|
||||
@@ -152,5 +185,14 @@ public class AppDbContext : DbContext, IDataProtectionKeyContext
|
||||
// Dedupe ingested listings by content hash.
|
||||
b.Entity<RawListing>().HasIndex(r => r.ContentHash);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,42 @@ namespace JobsMedical.Web.Data;
|
||||
/// </summary>
|
||||
public static class SeedData
|
||||
{
|
||||
/// <summary>Canonical role taxonomy (name, category, sort). Add new roles here; they're
|
||||
/// inserted on every startup if missing, so existing DBs pick them up too.</summary>
|
||||
private static readonly (string Name, string Category, int SortOrder)[] CanonicalRoles =
|
||||
{
|
||||
("پزشک عمومی", "پزشک", 1),
|
||||
("پزشک متخصص", "پزشک", 2),
|
||||
("پرستار", "پرستار", 3),
|
||||
("ماما", "ماما", 4),
|
||||
("تکنسین اتاق عمل", "تکنسین", 5),
|
||||
("تکنسین فوریتهای پزشکی", "تکنسین", 6),
|
||||
("کارشناس آزمایشگاه", "تکنسین", 7),
|
||||
("دندانپزشک", "دندانپزشک", 8),
|
||||
("پرستار سالمندان", "پرستار", 9),
|
||||
};
|
||||
|
||||
public static async Task EnsureSeededAsync(AppDbContext db, bool includeDemo = true)
|
||||
{
|
||||
await SeedReferenceAsync(db);
|
||||
await EnsureRolesAsync(db);
|
||||
if (includeDemo) await SeedDemoAsync(db);
|
||||
}
|
||||
|
||||
/// <summary>Idempotently add any canonical role missing from the DB (no-op when all present).</summary>
|
||||
public static async Task EnsureRolesAsync(AppDbContext db)
|
||||
{
|
||||
var existing = await db.Roles.Select(r => r.Name).ToListAsync();
|
||||
var added = false;
|
||||
foreach (var (name, category, sort) in CanonicalRoles)
|
||||
if (!existing.Contains(name))
|
||||
{
|
||||
db.Roles.Add(new Role { Name = name, Category = category, SortOrder = sort, IsActive = true });
|
||||
added = true;
|
||||
}
|
||||
if (added) await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ---------- Reference data (always) ----------
|
||||
public static async Task SeedReferenceAsync(AppDbContext db)
|
||||
{
|
||||
@@ -29,14 +59,7 @@ public static class SeedData
|
||||
new City { Name = "شیراز", Province = "فارس", IsActive = false });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
db.Roles.AddRange(
|
||||
new Role { Name = "پزشک عمومی", Category = "پزشک", SortOrder = 1 },
|
||||
new Role { Name = "پزشک متخصص", Category = "پزشک", SortOrder = 2 },
|
||||
new Role { Name = "پرستار", Category = "پرستار", SortOrder = 3 },
|
||||
new Role { Name = "ماما", Category = "ماما", SortOrder = 4 },
|
||||
new Role { Name = "تکنسین اتاق عمل", Category = "تکنسین", SortOrder = 5 },
|
||||
new Role { Name = "تکنسین فوریتهای پزشکی", Category = "تکنسین", SortOrder = 6 },
|
||||
new Role { Name = "کارشناس آزمایشگاه", Category = "تکنسین", SortOrder = 7 });
|
||||
// Roles are seeded by EnsureRolesAsync (idempotent, runs every startup).
|
||||
|
||||
foreach (var n in new[] { "سعادتآباد", "شهرک غرب", "ولیعصر / پارکوی", "نارمک",
|
||||
"تهرانپارس", "گیشا / برج میلاد", "ونک", "تجریش" })
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobsMedical.Web.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AiUseProxy : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AiUseProxy",
|
||||
table: "AppSettings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AiUseProxy",
|
||||
table: "AppSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobsMedical.Web.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class IngestionRunLog : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "IngestionRuns",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
RunAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
Fetched = table.Column<int>(type: "integer", nullable: false),
|
||||
Queued = table.Column<int>(type: "integer", nullable: false),
|
||||
Published = table.Column<int>(type: "integer", nullable: false),
|
||||
Flagged = table.Column<int>(type: "integer", nullable: false),
|
||||
Spam = table.Column<int>(type: "integer", nullable: false),
|
||||
Duplicates = table.Column<int>(type: "integer", nullable: false),
|
||||
Detail = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_IngestionRuns", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "IngestionRuns");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1473
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobsMedical.Web.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTalentListing : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "LinkedTalentId",
|
||||
table: "RawListings",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TalentListings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
RoleId = table.Column<int>(type: "integer", nullable: false),
|
||||
PersonName = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: true),
|
||||
YearsExperience = table.Column<int>(type: "integer", nullable: true),
|
||||
IsLicensed = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CityId = table.Column<int>(type: "integer", nullable: false),
|
||||
DistrictId = table.Column<int>(type: "integer", nullable: true),
|
||||
AreaNote = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: true),
|
||||
Availability = table.Column<int>(type: "integer", nullable: true),
|
||||
Gender = table.Column<int>(type: "integer", nullable: false),
|
||||
PayType = table.Column<int>(type: "integer", nullable: false),
|
||||
PayAmount = table.Column<long>(type: "bigint", nullable: true),
|
||||
SharePercent = table.Column<int>(type: "integer", nullable: true),
|
||||
Phone = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
|
||||
Description = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
|
||||
Status = table.Column<int>(type: "integer", nullable: false),
|
||||
Source = table.Column<int>(type: "integer", nullable: false),
|
||||
SourceUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TalentListings", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TalentListings_Cities_CityId",
|
||||
column: x => x.CityId,
|
||||
principalTable: "Cities",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_TalentListings_Districts_DistrictId",
|
||||
column: x => x.DistrictId,
|
||||
principalTable: "Districts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_TalentListings_Roles_RoleId",
|
||||
column: x => x.RoleId,
|
||||
principalTable: "Roles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TalentListings_CityId_RoleId",
|
||||
table: "TalentListings",
|
||||
columns: new[] { "CityId", "RoleId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TalentListings_DistrictId",
|
||||
table: "TalentListings",
|
||||
column: "DistrictId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TalentListings_RoleId",
|
||||
table: "TalentListings",
|
||||
column: "RoleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TalentListings_Status",
|
||||
table: "TalentListings",
|
||||
column: "Status");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TalentListings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LinkedTalentId",
|
||||
table: "RawListings");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobsMedical.Web.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SocialPosting : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "InstagramHashtags",
|
||||
table: "AppSettings",
|
||||
type: "character varying(1000)",
|
||||
maxLength: 1000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SocialBaleBotToken",
|
||||
table: "AppSettings",
|
||||
type: "character varying(200)",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SocialBaleChatId",
|
||||
table: "AppSettings",
|
||||
type: "character varying(120)",
|
||||
maxLength: 120,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "SocialBaleEnabled",
|
||||
table: "AppSettings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "SocialEnabled",
|
||||
table: "AppSettings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SocialFooter",
|
||||
table: "AppSettings",
|
||||
type: "character varying(1000)",
|
||||
maxLength: 1000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SocialHeader",
|
||||
table: "AppSettings",
|
||||
type: "character varying(1000)",
|
||||
maxLength: 1000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "SocialInstagramEnabled",
|
||||
table: "AppSettings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "SocialLastPostedAt",
|
||||
table: "AppSettings",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SocialPostsPerDay",
|
||||
table: "AppSettings",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SocialTelegramBotToken",
|
||||
table: "AppSettings",
|
||||
type: "character varying(200)",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SocialTelegramChatId",
|
||||
table: "AppSettings",
|
||||
type: "character varying(120)",
|
||||
maxLength: 120,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "SocialTelegramEnabled",
|
||||
table: "AppSettings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "SocialUseProxy",
|
||||
table: "AppSettings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InstagramHashtags",
|
||||
table: "AppSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SocialBaleBotToken",
|
||||
table: "AppSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SocialBaleChatId",
|
||||
table: "AppSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SocialBaleEnabled",
|
||||
table: "AppSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SocialEnabled",
|
||||
table: "AppSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SocialFooter",
|
||||
table: "AppSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SocialHeader",
|
||||
table: "AppSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SocialInstagramEnabled",
|
||||
table: "AppSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SocialLastPostedAt",
|
||||
table: "AppSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SocialPostsPerDay",
|
||||
table: "AppSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SocialTelegramBotToken",
|
||||
table: "AppSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SocialTelegramChatId",
|
||||
table: "AppSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SocialTelegramEnabled",
|
||||
table: "AppSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SocialUseProxy",
|
||||
table: "AppSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobsMedical.Web.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ContactMethods : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ContactMethods",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
TalentListingId = table.Column<int>(type: "integer", nullable: false),
|
||||
Type = table.Column<int>(type: "integer", nullable: false),
|
||||
Value = table.Column<string>(type: "character varying(250)", maxLength: 250, nullable: false),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ContactMethods", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ContactMethods_TalentListings_TalentListingId",
|
||||
column: x => x.TalentListingId,
|
||||
principalTable: "TalentListings",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ContactMethods_TalentListingId",
|
||||
table: "ContactMethods",
|
||||
column: "TalentListingId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ContactMethods");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobsMedical.Web.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TalentTags : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Tags",
|
||||
table: "TalentListings",
|
||||
type: "character varying(500)",
|
||||
maxLength: 500,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Tags",
|
||||
table: "TalentListings");
|
||||
}
|
||||
}
|
||||
}
|
||||
+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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,9 @@ namespace JobsMedical.Web.Migrations
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<bool>("AiUseProxy")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("AutoIngestEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -96,6 +99,28 @@ namespace JobsMedical.Web.Migrations
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("InstagramHashtags")
|
||||
.HasMaxLength(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")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -130,6 +155,51 @@ namespace JobsMedical.Web.Migrations
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("SocialBaleBotToken")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("SocialBaleChatId")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<bool>("SocialBaleEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("SocialEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SocialFooter")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<string>("SocialHeader")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<bool>("SocialInstagramEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("SocialLastPostedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("SocialPostsPerDay")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SocialTelegramBotToken")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("SocialTelegramChatId")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<bool>("SocialTelegramEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("SocialUseProxy")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("TelegramChannels")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
@@ -233,6 +303,45 @@ namespace JobsMedical.Web.Migrations
|
||||
b.ToTable("Cities");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.ContactMethod", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int?>("JobOpeningId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("ShiftId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("TalentListingId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(250)
|
||||
.HasColumnType("character varying(250)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("JobOpeningId");
|
||||
|
||||
b.HasIndex("ShiftId");
|
||||
|
||||
b.HasIndex("TalentListingId");
|
||||
|
||||
b.ToTable("ContactMethods");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.District", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -418,6 +527,44 @@ namespace JobsMedical.Web.Migrations
|
||||
b.ToTable("FacilityDocuments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.IngestionRun", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Detail")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<int>("Duplicates")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Fetched")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Flagged")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Published")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Queued")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("RunAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Spam")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("IngestionRuns");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -535,6 +682,12 @@ namespace JobsMedical.Web.Migrations
|
||||
b.Property<int>("GenderRequirement")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<double?>("Lat")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("Lng")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Requirements")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
@@ -574,6 +727,36 @@ namespace JobsMedical.Web.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -629,9 +812,18 @@ namespace JobsMedical.Web.Migrations
|
||||
b.Property<DateTime>("FetchedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double?>("Lat")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<int?>("LinkedShiftId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("LinkedTalentId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<double?>("Lng")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ParsedJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -661,6 +853,8 @@ namespace JobsMedical.Web.Migrations
|
||||
|
||||
b.HasIndex("LinkedShiftId");
|
||||
|
||||
b.HasIndex("LinkedTalentId");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("RawListings");
|
||||
@@ -802,6 +996,12 @@ namespace JobsMedical.Web.Migrations
|
||||
b.Property<int>("GenderRequirement")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<double?>("Lat")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("Lng")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<long?>("PayAmount")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
@@ -846,6 +1046,96 @@ namespace JobsMedical.Web.Migrations
|
||||
b.ToTable("Shifts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.TalentListing", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("AreaNote")
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("character varying(150)");
|
||||
|
||||
b.Property<int?>("Availability")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("CityId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<int?>("DistrictId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Gender")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsLicensed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<double?>("Lat")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("Lng")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<long?>("PayAmount")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("PayType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("PersonName")
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("character varying(150)");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<int>("RoleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("SharePercent")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Source")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Tags")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<int?>("YearsExperience")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DistrictId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.HasIndex("CityId", "RoleId");
|
||||
|
||||
b.ToTable("TalentListings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1045,6 +1335,30 @@ namespace JobsMedical.Web.Migrations
|
||||
b.Navigation("Shift");
|
||||
});
|
||||
|
||||
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")
|
||||
.WithMany("Contacts")
|
||||
.HasForeignKey("TalentListingId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("JobOpening");
|
||||
|
||||
b.Navigation("Shift");
|
||||
|
||||
b.Navigation("TalentListing");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.District", b =>
|
||||
{
|
||||
b.HasOne("JobsMedical.Web.Models.City", "City")
|
||||
@@ -1183,6 +1497,17 @@ namespace JobsMedical.Web.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("JobsMedical.Web.Models.User", "User")
|
||||
@@ -1198,9 +1523,17 @@ namespace JobsMedical.Web.Migrations
|
||||
{
|
||||
b.HasOne("JobsMedical.Web.Models.Shift", "LinkedShift")
|
||||
.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("LinkedTalent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.Review", b =>
|
||||
@@ -1241,6 +1574,32 @@ namespace JobsMedical.Web.Migrations
|
||||
b.Navigation("Role");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.TalentListing", b =>
|
||||
{
|
||||
b.HasOne("JobsMedical.Web.Models.City", "City")
|
||||
.WithMany()
|
||||
.HasForeignKey("CityId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("JobsMedical.Web.Models.District", "District")
|
||||
.WithMany()
|
||||
.HasForeignKey("DistrictId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("JobsMedical.Web.Models.Role", "Role")
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("City");
|
||||
|
||||
b.Navigation("District");
|
||||
|
||||
b.Navigation("Role");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.UserPreferences", b =>
|
||||
{
|
||||
b.HasOne("JobsMedical.Web.Models.City", "City")
|
||||
@@ -1291,6 +1650,11 @@ namespace JobsMedical.Web.Migrations
|
||||
b.Navigation("Shifts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b =>
|
||||
{
|
||||
b.Navigation("Contacts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.Role", b =>
|
||||
{
|
||||
b.Navigation("Shifts");
|
||||
@@ -1299,6 +1663,13 @@ namespace JobsMedical.Web.Migrations
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.Shift", b =>
|
||||
{
|
||||
b.Navigation("Applications");
|
||||
|
||||
b.Navigation("Contacts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.TalentListing", b =>
|
||||
{
|
||||
b.Navigation("Contacts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.User", b =>
|
||||
|
||||
@@ -32,6 +32,10 @@ public class AppSetting
|
||||
/// <summary>If AI approves AND Mode is Automatic, publish without human review.</summary>
|
||||
public bool AiAutoApprove { get; set; } = false;
|
||||
|
||||
/// <summary>Route AI calls through the ingestion proxy (IngestProxyUrl) — needed when the AI
|
||||
/// endpoint (e.g. api.openai.com) is blocked in Iran.</summary>
|
||||
public bool AiUseProxy { get; set; } = false;
|
||||
|
||||
// --- Channel scraping sources (configured here, NOT in env) ---
|
||||
/// <summary>Run the ingestion worker on a timer.</summary>
|
||||
public bool AutoIngestEnabled { get; set; } = false;
|
||||
@@ -77,6 +81,18 @@ public class AppSetting
|
||||
/// <summary>Max ads to fetch per ingestion run (be polite; dedupe skips already-seen).</summary>
|
||||
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). ---
|
||||
public bool SmsEnabled { get; set; } = false;
|
||||
[MaxLength(200)] public string? SmsApiKey { get; set; }
|
||||
@@ -100,6 +116,32 @@ public class AppSetting
|
||||
[MaxLength(200)] public string? VapidPrivateKey { get; set; }
|
||||
[MaxLength(120)] public string? VapidSubject { get; set; } = "mailto:admin@hamkadr.ir";
|
||||
|
||||
// --- Social auto-posting: a daily «کادر آماده به کار» digest to Telegram/Bale (text) + an
|
||||
// Instagram caption/hashtags pack (you post the image manually). ---
|
||||
public bool SocialEnabled { get; set; } = false;
|
||||
/// <summary>How many digests to publish per day (evenly spaced).</summary>
|
||||
public int SocialPostsPerDay { get; set; } = 3;
|
||||
/// <summary>Lines added above/below the auto-generated body (your branding, links, etc.).</summary>
|
||||
[MaxLength(1000)] public string? SocialHeader { get; set; }
|
||||
[MaxLength(1000)] public string? SocialFooter { get; set; }
|
||||
/// <summary>Route the bot calls through the ingestion proxy (Telegram is filtered in Iran).</summary>
|
||||
public bool SocialUseProxy { get; set; } = true;
|
||||
|
||||
public bool SocialTelegramEnabled { get; set; } = false;
|
||||
[MaxLength(200)] public string? SocialTelegramBotToken { get; set; }
|
||||
/// <summary>Channel/chat to post to — «@channelusername» or a numeric chat id.</summary>
|
||||
[MaxLength(120)] public string? SocialTelegramChatId { get; set; }
|
||||
|
||||
public bool SocialBaleEnabled { get; set; } = false;
|
||||
[MaxLength(200)] public string? SocialBaleBotToken { get; set; }
|
||||
[MaxLength(120)] public string? SocialBaleChatId { get; set; }
|
||||
|
||||
public bool SocialInstagramEnabled { get; set; } = false;
|
||||
/// <summary>Extra hashtags appended to the generated Instagram caption (space/line separated).</summary>
|
||||
[MaxLength(1000)] public string? InstagramHashtags { get; set; }
|
||||
|
||||
public DateTime? SocialLastPostedAt { get; set; }
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Split a textarea (newline/comma separated) into trimmed non-empty items.</summary>
|
||||
@@ -108,17 +150,48 @@ public class AppSetting
|
||||
: s.Split(new[] { '\n', '\r', ',', '،' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.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 = """
|
||||
تو دستیار بررسی آگهیهای کاری حوزه درمان برای پلتفرم «همکادر» هستی.
|
||||
هر آگهی خام را بخوان و تصمیم بگیر:
|
||||
- approve: آگهی واقعی و مرتبط با شیفت/استخدام کادر درمان است و اطلاعات کافی دارد.
|
||||
- reject: تبلیغ، اسپم، نامرتبط، یا فاقد اطلاعات حداقلی است.
|
||||
- review: مرتبط است اما ناقص/مبهم و نیاز به بررسی انسانی دارد.
|
||||
نقش، شهر/محله، نوع شیفت، نوع همکاری، مبلغ یا درصد سهم، و عنوان را در صورت وجود استخراج کن.
|
||||
فقط با یک شیء JSON پاسخ بده با کلیدهای:
|
||||
decision (approve|reject|review)، confidence (0-100)، reason (فارسی کوتاه)،
|
||||
kind (shift|job)، role، city، district، shiftType (day|evening|night|oncall)،
|
||||
employmentType (fulltime|parttime|contract|plan)، payAmount (عدد تومان یا null)،
|
||||
sharePercent (0-100 یا null)، title، facilityName.
|
||||
تو دستیار دستهبندی آگهیهای کادر درمان تهران برای پلتفرم «همکادر» هستی. هر ورودی یک متن خام از
|
||||
کانالهای تلگرام/بله/دیوار است. وظیفه: (۱) تشخیص نوع، (۲) استخراج دقیق فیلدها و دستهبندی فرد،
|
||||
(۳) تصمیم تأیید/رد/بررسی. فقط یک شیء JSON معتبر برگردان؛ هیچ متن اضافهای ننویس.
|
||||
|
||||
نوع (kind):
|
||||
• shift = مرکز درمانی برای بازهٔ زمانی مشخص نیرو میخواهد.
|
||||
• job = مرکز درمانی استخدام دائم/قراردادی دارد.
|
||||
• talent= فردی از کادر درمان خودش را «آماده به کار / آماده همکاری» معرفی میکند
|
||||
(سمت عرضه؛ مرکز ندارد و شماره تماسِ خودِ فرد مهمترین فیلد است).
|
||||
|
||||
نقش (role) و گروه (category):
|
||||
اول سعی کن نقش را با یکی از نقشهای رایج تطبیق دهی: پزشک عمومی، پزشک متخصص، پرستار،
|
||||
پرستار سالمندان، ماما، تکنسین اتاق عمل، تکنسین فوریتهای پزشکی، کارشناس آزمایشگاه، دندانپزشک.
|
||||
نقش را به «حرفهٔ پایه» بنویس، نه با پیشوند/پسوندِ توصیفی. گروهِ سنی، بخش، سطح، یا جنسیت را در
|
||||
نقش نیاور و بهجایش در tags (و جنسیت را در فیلد gender) بگذار:
|
||||
«پرستار کودک» → نقش «پرستار» + تگ «کودک»
|
||||
«پرستار آقا» → نقش «پرستار» + جنسیت «آقا»
|
||||
«پرستار اورژانس» → نقش «پرستار» + تگ «اورژانس»
|
||||
«کارآموز تکنسین داروخانه» → نقش «تکنسین داروخانه» + تگ «کارآموز»
|
||||
فقط وقتی نقشِ جدید بساز که یک «حرفهٔ پایهٔ متفاوت» باشد که در فهرست نیست (مثل «تکنسین داروخانه»،
|
||||
«کارشناس رادیولوژی»، «شنواییسنج»). نقش جدید را کوتاه و رسمی بنویس، نه جمله.
|
||||
category را فقط یکی از این پنج گروه بگذار: پزشک | پرستار | ماما | تکنسین | دندانپزشک.
|
||||
اگر نقش در هیچکدام نگنجید، category = «سایر». هرگز گروهِ جدید نساز.
|
||||
|
||||
مهارتها/الزامات (tags): فقط کلیدواژههای بالینی و مرتبط را بهصورت آرایه برگردان — مهارت،
|
||||
بخش، گواهی، گروه سنی، سطح، یا شرط (مثل "ICU"، "NICU"، "دیالیز"، "اتاق عمل"، "کودک"، "سالمند",
|
||||
"MMT"، "CPR"، "پروانهدار"، "خانم"، "آقا"). هرگز مبلغ/پرداخت/توافقی، شماره تماس، شهر/محله، یا
|
||||
جملهٔ ناقص را بهعنوان تگ نگذار. اگر چیزی نبود [].
|
||||
|
||||
شهر (city): فقط نام شهر (مثل «تهران»). محله/منطقه را در district بگذار.
|
||||
|
||||
تصمیم (decision):
|
||||
• approve = آگهیِ واقعیِ مرتبط با کادر درمان تهران با اطلاعات کافی.
|
||||
• reject = اسپم/تبلیغ/نامرتبط/خارج از کادر درمانِ تهران.
|
||||
• review = مرتبط ولی مبهم/ناقص.
|
||||
confidence را ۰ تا ۱۰۰ بده و reason را کوتاه و فارسی بنویس.
|
||||
|
||||
برای talent: personName، yearsExperience، isLicensed (پروانهدار) و phone (ارقام لاتین)
|
||||
را در صورت ذکر پر کن. هر فیلدِ نامشخص = null.
|
||||
""";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace JobsMedical.Web.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One contact channel for a listing — an applicant («آماده به کار»), a <see cref="Shift"/>, or a
|
||||
/// <see cref="JobOpening"/>. A listing can carry several — e.g. three phones + an email + an
|
||||
/// 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>
|
||||
public class ContactMethod
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
// Owner — exactly one of these is non-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; }
|
||||
|
||||
[Required, MaxLength(250)]
|
||||
public string Value { get; set; } = "";
|
||||
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@@ -27,7 +27,8 @@ public enum ShiftStatus
|
||||
Open = 0, // باز
|
||||
Filled = 1, // پر شده
|
||||
Expired = 2, // منقضی
|
||||
Cancelled = 3 // لغو شده
|
||||
Cancelled = 3, // لغو شده
|
||||
Archived = 4 // بایگانیشده (پنهان از سایت، نگهداری برای تحلیل)
|
||||
}
|
||||
|
||||
public enum ShiftSource
|
||||
@@ -69,11 +70,13 @@ public enum EmploymentType
|
||||
Plan = 3 // طرح
|
||||
}
|
||||
|
||||
/// <summary>What an aggregated/raw listing turned out to be — a shift or a hiring opening.</summary>
|
||||
/// <summary>What an aggregated/raw listing turned out to be — a shift, a hiring opening, or a
|
||||
/// worker advertising themselves as available («آماده به کار»).</summary>
|
||||
public enum ListingKind
|
||||
{
|
||||
Shift = 0,
|
||||
Job = 1
|
||||
Job = 1,
|
||||
Talent = 2
|
||||
}
|
||||
|
||||
/// <summary>Which listing types a job alert watches.</summary>
|
||||
@@ -102,6 +105,23 @@ public enum IngestionMode
|
||||
Automatic = 1 // موارد تأییدشده (طبق آستانه/هوش مصنوعی) خودکار منتشر میشوند
|
||||
}
|
||||
|
||||
/// <summary>A way to reach an applicant («آماده به کار»). One listing can have several.</summary>
|
||||
public enum ContactType
|
||||
{
|
||||
Mobile = 0, // موبایل
|
||||
Phone = 1, // تلفن ثابت
|
||||
Email = 2, // ایمیل
|
||||
Telegram = 3, // تلگرام
|
||||
Bale = 4, // بله
|
||||
WhatsApp = 5, // واتساپ
|
||||
Instagram = 6, // اینستاگرام
|
||||
Website = 7, // وبسایت / لینک
|
||||
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 ReportStatus { Open = 0, Resolved = 1, Dismissed = 2 }
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace JobsMedical.Web.Models;
|
||||
|
||||
/// <summary>One ingestion run's outcome — kept so admins see a history of what was crawled,
|
||||
/// how much was found, queued, published, flagged, etc. (with a per-source breakdown).</summary>
|
||||
public class IngestionRun
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public DateTime RunAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public int Fetched { get; set; } // total items pulled from all sources
|
||||
public int Queued { get; set; } // sent to the review queue
|
||||
public int Published { get; set; } // auto-published
|
||||
public int Flagged { get; set; } // needs-review
|
||||
public int Spam { get; set; } // discarded as spam/irrelevant
|
||||
public int Duplicates { get; set; } // skipped (already seen)
|
||||
|
||||
/// <summary>Human-readable per-source breakdown, e.g. "دیوار: یافت ۱۲…؛ مدجابز: یافت ۴۰…".</summary>
|
||||
[MaxLength(2000)] public string? Detail { get; set; }
|
||||
}
|
||||
@@ -40,8 +40,16 @@ public class JobOpening
|
||||
[MaxLength(500)]
|
||||
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;
|
||||
|
||||
/// <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.
|
||||
[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;
|
||||
}
|
||||
@@ -24,9 +24,17 @@ public class RawListing
|
||||
public int? LinkedShiftId { get; set; } // شیفت ساختهشده از این آگهی
|
||||
public Shift? LinkedShift { get; set; }
|
||||
|
||||
public int? LinkedTalentId { get; set; } // آگهی «آماده به کار» ساختهشده از این متن
|
||||
public TalentListing? LinkedTalent { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
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>
|
||||
[MaxLength(64)]
|
||||
public string? ContentHash { get; set; }
|
||||
|
||||
@@ -40,10 +40,19 @@ public class Shift
|
||||
[MaxLength(500)]
|
||||
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 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.
|
||||
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
|
||||
public double? DistanceKm { get; set; }
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace JobsMedical.Web.Models;
|
||||
|
||||
/// <summary>
|
||||
/// «آماده به کار» — a healthcare worker advertising *themselves* as available for work
|
||||
/// (the supply side), as opposed to a <see cref="Shift"/>/<see cref="JobOpening"/> posted by a
|
||||
/// facility (the demand side). Very common in Iranian medical channels ("پرستار آماده همکاری…").
|
||||
/// There is no facility; the valuable field is the contact <see cref="Phone"/>.
|
||||
/// </summary>
|
||||
public class TalentListing
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public int RoleId { get; set; }
|
||||
public Role Role { get; set; } = null!;
|
||||
|
||||
[MaxLength(150)]
|
||||
public string? PersonName { get; set; } // «دکتر سپیده علیزاده» (best-effort)
|
||||
|
||||
public int? YearsExperience { get; set; } // سابقه (سال)
|
||||
public bool IsLicensed { get; set; } // پروانهدار / دارای پروانه
|
||||
|
||||
public int CityId { get; set; }
|
||||
public City City { get; set; } = null!;
|
||||
|
||||
public int? DistrictId { get; set; }
|
||||
public District? District { get; set; }
|
||||
|
||||
[MaxLength(150)]
|
||||
public string? AreaNote { get; set; } // «فقط منطقه ۱» وقتی محله دقیق نگاشت نشد
|
||||
|
||||
/// <summary>Searchable keyword tags (space-separated): certs/skills (mmt, icu…), پروانهدار,
|
||||
/// role, city. Drives deep search + tag chips.</summary>
|
||||
[MaxLength(500)]
|
||||
public string? Tags { get; set; }
|
||||
|
||||
public EmploymentType? Availability { get; set; } // تماموقت/پارهوقت/قراردادی...
|
||||
public Gender Gender { get; set; } = Gender.Any; // جنسیت فرد
|
||||
|
||||
// Expected compensation — reuses the shift/job comp model.
|
||||
public PayType PayType { get; set; } = PayType.Negotiable;
|
||||
public long? PayAmount { get; set; } // مبلغ مدنظر (تومان)
|
||||
public int? SharePercent { get; set; } // درصد/سهم درآمد مدنظر («۵۰٪ تسویه»)
|
||||
|
||||
[MaxLength(30)]
|
||||
public string? Phone { get; set; } // primary phone (kept for cards/back-compat)
|
||||
|
||||
/// <summary>All contact channels (phones, email, Instagram, Telegram, Bale, website…).</summary>
|
||||
public ICollection<ContactMethod> Contacts { get; set; } = new List<ContactMethod>();
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
public ShiftStatus Status { get; set; } = ShiftStatus.Open;
|
||||
public ShiftSource Source { get; set; } = ShiftSource.Admin;
|
||||
|
||||
[MaxLength(500)]
|
||||
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;
|
||||
|
||||
// Transient: distance (km) when "near me" is active. Not persisted.
|
||||
[NotMapped] public double? DistanceKm { get; set; }
|
||||
}
|
||||
@@ -106,7 +106,7 @@ public class LoginModel : PageModel
|
||||
// Route to the right panel for the account type.
|
||||
return user.Role switch
|
||||
{
|
||||
UserRole.Admin => RedirectToPage("/Admin/Index"),
|
||||
UserRole.Admin => RedirectToPage("/Admin/Overview"),
|
||||
UserRole.FacilityAdmin => RedirectToPage("/Employer/Index"),
|
||||
_ => RedirectToPage("/Me/Index"),
|
||||
};
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<h1>پنل مدیریت — جمعآوری و صف آگهیها</h1>
|
||||
<p class="muted">
|
||||
آگهیهای جمعآوریشده از منابع را بررسی، ساختارمند و منتشر کن.
|
||||
(@JalaliDate.ToPersianDigits(Model.Queue.Count.ToString()) در صف،
|
||||
@JalaliDate.ToPersianDigits(Model.Flagged.Count.ToString()) پرچمخورده)
|
||||
(@JalaliDate.ToPersianDigits(Model.QueueTotal.ToString()) در صف،
|
||||
@JalaliDate.ToPersianDigits(Model.FlaggedTotal.ToString()) پرچمخورده)
|
||||
· <a asp-page="/Admin/Overview">داشبورد</a>
|
||||
· <a asp-page="/Admin/Users">کاربران</a>
|
||||
· <a asp-page="/Admin/Facilities">مراکز</a>
|
||||
@@ -40,6 +40,77 @@
|
||||
<p class="muted" style="font-size:11px; margin:8px 0 0;">
|
||||
موتور: واکشی ← حذف تکراری ← تجزیه ← اعتبارسنجی ← صف بررسی.
|
||||
</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;" />
|
||||
|
||||
@@ -62,6 +133,43 @@
|
||||
</aside>
|
||||
|
||||
<div>
|
||||
@if (Model.Runs.Count > 0)
|
||||
{
|
||||
<h2 style="font-size:20px; margin-top:0; display:flex; justify-content:space-between; align-items:center;">
|
||||
تاریخچه جمعآوری
|
||||
<a class="btn btn-outline" style="padding:5px 12px; font-size:13px;" asp-page="/Admin/Ingested">همه نتایج جمعآوری ←</a>
|
||||
</h2>
|
||||
<div class="card card-pad" style="margin-bottom:18px; overflow-x:auto;">
|
||||
<table style="width:100%; border-collapse:collapse; font-size:13px; white-space:nowrap;">
|
||||
<thead>
|
||||
<tr style="text-align:start; color:var(--muted);">
|
||||
<th style="padding:6px 8px;">زمان</th>
|
||||
<th style="padding:6px 8px;">یافتشده</th>
|
||||
<th style="padding:6px 8px;">صف</th>
|
||||
<th style="padding:6px 8px;">منتشر</th>
|
||||
<th style="padding:6px 8px;">پرچم</th>
|
||||
<th style="padding:6px 8px;">اسپم</th>
|
||||
<th style="padding:6px 8px;">تکراری</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var run in Model.Runs)
|
||||
{
|
||||
<tr style="border-top:1px solid var(--line);" title="@run.Detail">
|
||||
<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.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;">@JalaliDate.ToPersianDigits(run.Flagged.ToString())</td>
|
||||
<td style="padding:6px 8px;">@JalaliDate.ToPersianDigits(run.Spam.ToString())</td>
|
||||
<td style="padding:6px 8px;">@JalaliDate.ToPersianDigits(run.Duplicates.ToString())</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted" style="font-size:11px; margin:8px 0 0;">جزئیات هر منبع را با نگهداشتن نشانگر روی هر ردیف ببین. لاگ کامل: <code dir="ltr">docker logs hamkadr_api</code></p>
|
||||
</div>
|
||||
}
|
||||
<h2 style="font-size:20px; margin-top:0;">صف بررسی</h2>
|
||||
@if (Model.Queue.Count == 0)
|
||||
{
|
||||
@@ -73,9 +181,19 @@
|
||||
{
|
||||
<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>
|
||||
<p class="muted" style="font-size:13px;">اعتبارسنجی اینها را کامل ندانست؛ در صورت صحت میتوانی منتشرشان کنی.</p>
|
||||
@@ -83,6 +201,16 @@
|
||||
{
|
||||
<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>
|
||||
|
||||
@@ -13,25 +13,37 @@ public class IndexModel : PageModel
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
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;
|
||||
_ingest = ingest;
|
||||
_scopes = scopes;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public List<RawListing> Queue { 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 int PublishedShifts { get; private set; }
|
||||
public int PublishedJobs { get; private set; }
|
||||
public List<IngestionRun> Runs { get; private set; } = new();
|
||||
|
||||
[BindProperty] public string? SourceChannel { get; set; }
|
||||
[BindProperty] public string? RawText { 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()
|
||||
{
|
||||
@@ -48,6 +60,14 @@ public class IndexModel : PageModel
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
/// <summary>Fast triage — reject (discard) a queued/flagged item without opening the review page.</summary>
|
||||
public async Task<IActionResult> OnPostQuickDiscardAsync(int id)
|
||||
{
|
||||
var raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (raw is not null) { raw.Status = RawListingStatus.Discarded; await _db.SaveChangesAsync(); }
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostRunIngestionAsync()
|
||||
{
|
||||
var s = await _ingest.RunAsync();
|
||||
@@ -56,16 +76,139 @@ public class IndexModel : PageModel
|
||||
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
|
||||
.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
|
||||
.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;
|
||||
PublishedShifts = await _db.Shifts.CountAsync(s => s.Source != ShiftSource.Direct);
|
||||
PublishedJobs = await _db.JobOpenings.CountAsync();
|
||||
Runs = await _db.IngestionRuns.OrderByDescending(r => r.RunAt).Take(15).ToListAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
@page
|
||||
@model JobsMedical.Web.Pages.Admin.IngestedModel
|
||||
@{
|
||||
ViewData["Title"] = "نتایج جمعآوری";
|
||||
string P(int n) => JalaliDate.ToPersianDigits(n.ToString());
|
||||
int C(JobsMedical.Web.Models.RawListingStatus s) => Model.Counts.GetValueOrDefault(s);
|
||||
string Pill(string key, string label, int count) =>
|
||||
$"<a class=\"ing-pill {(Model.Status == key || (Model.Status is null && key == "all") ? "active" : "")}\" href=\"?status={key}\">{label} ({P(count)})</a>";
|
||||
}
|
||||
|
||||
<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>
|
||||
}
|
||||
|
||||
@{ int publishedCount = Model.Counts.GetValueOrDefault(JobsMedical.Web.Models.RawListingStatus.Normalized); }
|
||||
@if (publishedCount > 0)
|
||||
{
|
||||
<form method="post" asp-page-handler="ArchivePublished"
|
||||
onsubmit="return confirm('همه آگهیهای منتشرشده از جمعآوری از سایت پنهان (بایگانی) میشوند. دادهها حذف نمیشوند و برای تحلیل باقی میمانند. ادامه میدهی؟');"
|
||||
style="margin-bottom:14px;">
|
||||
<button type="submit" class="btn btn-outline">
|
||||
🗄 بایگانی گروهی همهی منتشرشدهها (@JalaliDate.ToPersianDigits(publishedCount.ToString()))
|
||||
</button>
|
||||
<span class="muted" style="font-size:12px; margin-inline-start:8px;">از سایت پنهان میکند ولی هیچچیز حذف نمیشود (آرشیو برای تحلیل).</span>
|
||||
</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">
|
||||
@Html.Raw(Pill("all", "همه", Model.Counts.Values.Sum()))
|
||||
@Html.Raw(Pill("new", "در صف", C(JobsMedical.Web.Models.RawListingStatus.New)))
|
||||
@Html.Raw(Pill("flagged", "پرچمخورده", C(JobsMedical.Web.Models.RawListingStatus.Flagged)))
|
||||
@Html.Raw(Pill("published", "منتشرشده", C(JobsMedical.Web.Models.RawListingStatus.Normalized)))
|
||||
@Html.Raw(Pill("discarded", "ردشده/اسپم", C(JobsMedical.Web.Models.RawListingStatus.Discarded)))
|
||||
</div>
|
||||
|
||||
<p class="muted" style="font-size:13px;">@P(Model.Total) نتیجه (نمایش حداکثر ۲۰۰ مورد اخیر).</p>
|
||||
|
||||
@if (Model.Items.Count == 0)
|
||||
{
|
||||
<div class="card empty-state">موردی با این فیلتر نیست.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var r in Model.Items)
|
||||
{
|
||||
var (cls, label) = r.Status switch
|
||||
{
|
||||
JobsMedical.Web.Models.RawListingStatus.New => ("badge-day", "در صف"),
|
||||
JobsMedical.Web.Models.RawListingStatus.Flagged => ("badge-type", "پرچمخورده"),
|
||||
JobsMedical.Web.Models.RawListingStatus.Normalized => ("badge-verified", "منتشر شد"),
|
||||
_ => ("badge-gender", "رد شد"),
|
||||
};
|
||||
<div class="card card-pad" style="margin-bottom:10px;">
|
||||
<div class="row" style="display:flex; justify-content:space-between; align-items:center; gap:8px; flex-wrap:wrap;">
|
||||
<strong>@r.SourceChannel</strong>
|
||||
<span style="display:flex; gap:6px; align-items:center;">
|
||||
<span class="badge @cls">@label</span>
|
||||
<span class="badge badge-type">اطمینان @P(r.Confidence)٪</span>
|
||||
<span class="muted" style="font-size:12px;">@JalaliDate.DateTimeLabel(r.FetchedAt)</span>
|
||||
</span>
|
||||
</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>
|
||||
@if (!string.IsNullOrEmpty(r.ValidationNotes)) { <p class="muted" style="font-size:12px; margin:0 0 6px;">⚠ @r.ValidationNotes</p> }
|
||||
@if (r.Status == JobsMedical.Web.Models.RawListingStatus.New || r.Status == JobsMedical.Web.Models.RawListingStatus.Flagged)
|
||||
{
|
||||
<a class="btn btn-outline" style="padding:4px 12px; font-size:13px;" asp-page="/Admin/Review" asp-route-id="@r.Id">بررسی و انتشار ←</a>
|
||||
}
|
||||
else if (r.LinkedShiftId is int sid)
|
||||
{
|
||||
<a class="btn btn-outline" style="padding:4px 12px; font-size:13px;" asp-page="/Shifts/Details" asp-route-id="@sid" target="_blank">مشاهده آگهی منتشرشده</a>
|
||||
}
|
||||
else if (r.LinkedTalentId is int tid)
|
||||
{
|
||||
<a class="btn btn-outline" style="padding:4px 12px; font-size:13px;" asp-page="/Talent/Details" asp-route-id="@tid" target="_blank">مشاهده «آماده به کار» منتشرشده</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,99 @@
|
||||
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>Every crawled item with its outcome (queued / published / flagged / discarded),
|
||||
/// filterable by status and source — the full audit trail of ingestion.</summary>
|
||||
[Authorize(Roles = "Admin")]
|
||||
public class IngestedModel : PageModel
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
public IngestedModel(AppDbContext db) => _db = db;
|
||||
|
||||
public List<RawListing> Items { get; private set; } = new();
|
||||
public int Total { get; private set; }
|
||||
public Dictionary<RawListingStatus, int> Counts { get; private set; } = new();
|
||||
public List<SourceStat> SourceBreakdown { get; private set; } = new();
|
||||
[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? Source { get; set; }
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
Counts = await _db.RawListings.GroupBy(r => r.Status)
|
||||
.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 st = Status?.ToLowerInvariant() switch
|
||||
{
|
||||
"new" => (RawListingStatus?)RawListingStatus.New,
|
||||
"flagged" => RawListingStatus.Flagged,
|
||||
"published" => RawListingStatus.Normalized,
|
||||
"discarded" => RawListingStatus.Discarded,
|
||||
_ => null,
|
||||
};
|
||||
if (st is not null) q = q.Where(r => r.Status == st);
|
||||
if (!string.IsNullOrWhiteSpace(Source)) q = q.Where(r => r.SourceChannel.Contains(Source));
|
||||
|
||||
Total = await q.CountAsync();
|
||||
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>
|
||||
/// 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
|
||||
/// rows are retained untouched as the permanent archive.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> OnPostArchivePublishedAsync()
|
||||
{
|
||||
var shifts = await _db.Shifts
|
||||
.Where(s => s.Source == ShiftSource.Aggregated && s.Status != ShiftStatus.Archived)
|
||||
.ExecuteUpdateAsync(u => u.SetProperty(s => s.Status, ShiftStatus.Archived));
|
||||
var jobs = await _db.JobOpenings
|
||||
.Where(j => j.Source == ShiftSource.Aggregated && j.Status != ShiftStatus.Archived)
|
||||
.ExecuteUpdateAsync(u => u.SetProperty(j => j.Status, ShiftStatus.Archived));
|
||||
var talent = await _db.TalentListings
|
||||
.Where(t => t.Source == ShiftSource.Aggregated && t.Status != ShiftStatus.Archived)
|
||||
.ExecuteUpdateAsync(u => u.SetProperty(t => t.Status, ShiftStatus.Archived));
|
||||
|
||||
string P(int n) => JalaliDate.ToPersianDigits(n.ToString());
|
||||
Message = $"بایگانی شد (از سایت پنهان، در پایگاهداده نگهداری شد): {P(shifts)} شیفت، {P(jobs)} استخدام، {P(talent)} آمادهبهکار.";
|
||||
return RedirectToPage(new { Status });
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@
|
||||
<span class="badge @(r.Status == ReportStatus.Open ? "badge-day" : "badge-type")">@StatusLabel(r.Status)</span>
|
||||
</div>
|
||||
<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;">
|
||||
<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)
|
||||
|
||||
@@ -10,11 +10,25 @@
|
||||
</div>
|
||||
|
||||
<div class="container section">
|
||||
@if (Model.Error is not null)
|
||||
{
|
||||
<div class="alert alert-error" style="margin-bottom:16px;">⚠ @Model.Error</div>
|
||||
}
|
||||
<div class="detail-grid">
|
||||
<div>
|
||||
<div class="card card-pad">
|
||||
<h3 style="margin-top:0;">متن خام</h3>
|
||||
<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>
|
||||
|
||||
@if (Model.Parsed is not null)
|
||||
@@ -42,25 +56,33 @@
|
||||
<select name="Kind" id="kindSelect">
|
||||
<option value="0" selected="@(Model.Kind == JobsMedical.Web.Models.ListingKind.Shift)">شیفت</option>
|
||||
<option value="1" selected="@(Model.Kind == JobsMedical.Web.Models.ListingKind.Job)">استخدام</option>
|
||||
<option value="2" selected="@(Model.Kind == JobsMedical.Web.Models.ListingKind.Talent)">آماده به کار (معرفی نیرو)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<div class="filter-group" id="facilityGroup">
|
||||
<label>مرکز درمانی</label>
|
||||
<select name="FacilityId">
|
||||
<option value="0" selected="@(Model.FacilityId == 0)">— انتخاب نشده —</option>
|
||||
@foreach (var f in Model.Facilities)
|
||||
{
|
||||
<option value="@f.Id">@f.Name — @f.City?.Name</option>
|
||||
<option value="@f.Id" selected="@(Model.FacilityId == f.Id)">@f.Name — @f.City?.Name</option>
|
||||
}
|
||||
</select>
|
||||
<input type="text" name="NewFacilityName" placeholder="یا نام مرکز جدید را وارد کن…" style="margin-top:6px;" />
|
||||
<p class="muted" style="font-size:11px; margin:4px 0 0;">اگر مرکز در فهرست نیست، نامش را اینجا بنویس تا بهصورت «تأییدنشده» ساخته شود.</p>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>نقش</label>
|
||||
<select name="RoleId">
|
||||
<label>نقشها (میتوانی چند مورد انتخاب کنی)</label>
|
||||
<div class="role-checks">
|
||||
@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 class="filter-group">
|
||||
@@ -116,6 +138,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="talentFields" style="display:none;">
|
||||
<div class="filter-group">
|
||||
<label>نام فرد (اختیاری)</label>
|
||||
<input type="text" name="PersonName" value="@Model.PersonName" placeholder="مثلاً دکتر سپیده علیزاده" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>شهر</label>
|
||||
<select name="TalentCityId">
|
||||
@foreach (var c in Model.Cities)
|
||||
{
|
||||
<option value="@c.Id" selected="@(Model.TalentCityId == c.Id)">@c.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group" style="display:flex; gap:8px;">
|
||||
<div style="flex:1;"><label>سابقه (سال)</label><input type="number" name="YearsExperience" value="@Model.YearsExperience" min="0" max="60" dir="ltr" /></div>
|
||||
<div style="flex:1;"><label>محدوده کاری</label><input type="text" name="AreaNote" value="@Model.AreaNote" placeholder="مثلاً فقط منطقه ۱" /></div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>شماره تماس</label>
|
||||
<input type="text" name="Phone" value="@Model.Phone" placeholder="۰۹۱۲…" dir="ltr" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:600;">
|
||||
<input type="checkbox" name="IsLicensed" value="true" style="width:auto;" checked="@Model.IsLicensed" /> پروانهدار
|
||||
</label>
|
||||
</div>
|
||||
<div class="filter-group" style="display:flex; gap:8px;">
|
||||
<div style="flex:1;"><label>دستمزد مدنظر (تومان)</label><input type="number" name="PayAmount" value="@Model.PayAmount" dir="ltr" /></div>
|
||||
<div style="flex:1;"><label>یا سهم درآمد (٪)</label><input type="number" name="SharePercent" value="@Model.SharePercent" min="1" max="100" dir="ltr" /></div>
|
||||
</div>
|
||||
<p class="muted" style="font-size:11px; margin:4px 0 0;">برای «آماده به کار» نیازی به مرکز نیست؛ شماره تماس مهمترین فیلد است.</p>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:600;">
|
||||
<input type="checkbox" name="Negotiable" value="true" style="width:auto;" checked="@Model.Negotiable" /> توافقی
|
||||
@@ -136,10 +192,20 @@
|
||||
@section Scripts {
|
||||
<script>
|
||||
var kind = document.getElementById('kindSelect');
|
||||
var facilityGroup = document.getElementById('facilityGroup');
|
||||
// Show one section and DISABLE the hidden ones so duplicate-named inputs
|
||||
// (PayAmount/SharePercent appear in both shift and talent) aren't submitted.
|
||||
function setSection(el, on) {
|
||||
if (!el) return;
|
||||
el.style.display = on ? 'block' : 'none';
|
||||
el.querySelectorAll('input,select,textarea').forEach(function (i) { i.disabled = !on; });
|
||||
}
|
||||
function toggleKind() {
|
||||
var isJob = kind.value === '1';
|
||||
document.getElementById('jobFields').style.display = isJob ? 'block' : 'none';
|
||||
document.getElementById('shiftFields').style.display = isJob ? 'none' : 'block';
|
||||
var v = kind.value;
|
||||
setSection(document.getElementById('shiftFields'), v === '0');
|
||||
setSection(document.getElementById('jobFields'), v === '1');
|
||||
setSection(document.getElementById('talentFields'), v === '2');
|
||||
setSection(facilityGroup, v !== '2'); // facility only for shift/job
|
||||
}
|
||||
kind.addEventListener('change', toggleKind);
|
||||
toggleKind();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using JobsMedical.Web.Data;
|
||||
using JobsMedical.Web.Models;
|
||||
using JobsMedical.Web.Services;
|
||||
using JobsMedical.Web.Services.Scraping;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
@@ -26,11 +27,16 @@ public class ReviewModel : PageModel
|
||||
public ParsedListing? Parsed { get; private set; }
|
||||
public List<Facility> Facilities { get; private set; } = new();
|
||||
public List<Role> Roles { get; private set; } = new();
|
||||
public List<City> Cities { get; private set; } = new();
|
||||
|
||||
[TempData] public string? Error { get; set; }
|
||||
|
||||
// The editable form (prefilled from the parser, admin can override everything).
|
||||
[BindProperty] public ListingKind Kind { get; set; }
|
||||
[BindProperty] public int FacilityId { get; set; }
|
||||
[BindProperty] public int RoleId { get; set; }
|
||||
[BindProperty] public string? NewFacilityName { get; set; } // create a facility on the fly if none picked
|
||||
/// <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; }
|
||||
// Shift fields
|
||||
[BindProperty] public DateOnly ShiftDate { get; set; }
|
||||
@@ -46,6 +52,13 @@ public class ReviewModel : PageModel
|
||||
[BindProperty] public EmploymentType EmploymentType { get; set; }
|
||||
[BindProperty] public long? SalaryMin { get; set; }
|
||||
[BindProperty] public long? SalaryMax { get; set; }
|
||||
// Talent («آماده به کار») fields — no facility; contact phone is key.
|
||||
[BindProperty] public int TalentCityId { get; set; }
|
||||
[BindProperty] public string? PersonName { get; set; }
|
||||
[BindProperty] public int? YearsExperience { get; set; }
|
||||
[BindProperty] public bool IsLicensed { get; set; }
|
||||
[BindProperty] public string? AreaNote { get; set; }
|
||||
[BindProperty] public string? Phone { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(int id)
|
||||
{
|
||||
@@ -58,7 +71,8 @@ public class ReviewModel : PageModel
|
||||
|
||||
// Prefill the form from the parser's best guess.
|
||||
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;
|
||||
EmploymentType = Parsed.EmploymentType ?? EmploymentType.FullTime;
|
||||
(StartTime, EndTime) = DefaultTimes(ShiftType);
|
||||
@@ -69,6 +83,34 @@ public class ReviewModel : PageModel
|
||||
if (Parsed.PayAmount is not null) { PayAmount = Parsed.PayAmount; SalaryMin = Parsed.PayAmount; }
|
||||
Description = Raw.RawText;
|
||||
Title = Parsed.RoleName is not null ? $"استخدام {Parsed.RoleName}" : "موقعیت استخدامی";
|
||||
|
||||
// Talent prefill.
|
||||
Phone = Parsed.Phone;
|
||||
PersonName = Parsed.PersonName;
|
||||
YearsExperience = Parsed.YearsExperience;
|
||||
IsLicensed = Parsed.IsLicensed;
|
||||
AreaNote = Parsed.AreaNote;
|
||||
TalentCityId = Cities.FirstOrDefault(c => c.Name == Parsed.CityName)?.Id
|
||||
?? Cities.FirstOrDefault()?.Id ?? 0;
|
||||
|
||||
// Facility: try to match the listing's facility to one we already have; otherwise
|
||||
// prefill the "new facility" box so publishing creates it.
|
||||
if (!string.IsNullOrWhiteSpace(Parsed.FacilityName))
|
||||
{
|
||||
var cityId = await _db.Cities.Where(c => c.Name == Parsed.CityName)
|
||||
.Select(c => (int?)c.Id).FirstOrDefaultAsync();
|
||||
var match = FacilityMatcher.FindBest(Facilities, Parsed.FacilityName, cityId);
|
||||
if (match is not null)
|
||||
{
|
||||
FacilityId = match.Id;
|
||||
Parsed.Notes.Add($"مرکز منطبق در سیستم: «{match.Name}» — همین انتخاب شد.");
|
||||
}
|
||||
else
|
||||
{
|
||||
NewFacilityName = Parsed.FacilityName;
|
||||
Parsed.Notes.Add($"مرکز جدید پیشنهادی: «{Parsed.FacilityName}» — هنگام انتشار ساخته میشود.");
|
||||
}
|
||||
}
|
||||
return Page();
|
||||
}
|
||||
|
||||
@@ -77,59 +119,130 @@ public class ReviewModel : PageModel
|
||||
Raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (Raw is null) return NotFound();
|
||||
|
||||
Shift? createdShift = null;
|
||||
JobOpening? createdJob = null;
|
||||
if (Kind == ListingKind.Shift)
|
||||
// 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)
|
||||
{
|
||||
var role = await _db.Roles.FindAsync(RoleId);
|
||||
var shift = new Shift
|
||||
Error = "حداقل یک نقش معتبر انتخاب کن.";
|
||||
return RedirectToPage(new { id });
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
FacilityId = FacilityId,
|
||||
RoleId = RoleId,
|
||||
Date = ShiftDate,
|
||||
StartTime = StartTime,
|
||||
EndTime = EndTime,
|
||||
ShiftType = ShiftType,
|
||||
SpecialtyRequired = role?.Name ?? "",
|
||||
Description = Description,
|
||||
PayType = Negotiable ? PayType.Negotiable
|
||||
: (PayAmount is null && SharePercent is not null ? PayType.Percentage : PayType.PerShift),
|
||||
PayAmount = Negotiable ? null : PayAmount,
|
||||
SharePercent = Negotiable ? null : SharePercent,
|
||||
GenderRequirement = GenderRequirement,
|
||||
Status = ShiftStatus.Open,
|
||||
Source = ShiftSource.Aggregated,
|
||||
SourceUrl = Raw.SourceUrl,
|
||||
var cityId = TalentCityId > 0 && await _db.Cities.AnyAsync(c => c.Id == TalentCityId)
|
||||
? TalentCityId
|
||||
: await _db.Cities.OrderByDescending(c => c.IsActive).Select(c => (int?)c.Id).FirstOrDefaultAsync();
|
||||
if (cityId is null)
|
||||
{
|
||||
Error = "شهری برای انتشار آگهی «آماده به کار» موجود نیست.";
|
||||
return RedirectToPage(new { id });
|
||||
}
|
||||
var roleNames = await _db.Roles.Select(r => r.Name).ToListAsync();
|
||||
var reparsed = _parser.Parse(Raw.RawText, roleNames, await CityNamesAsync(), await DistrictNamesAsync());
|
||||
var contactSpecs = reparsed.Contacts.Select((c, i) => (c.Type, c.Value, Order: i)).ToList();
|
||||
var adminPhone = string.IsNullOrWhiteSpace(Phone) ? null : Phone.Trim();
|
||||
var tags = string.Join(" ", reparsed.Tags.Distinct());
|
||||
|
||||
// Fresh ContactMethod instances per listing (EF can't share children across parents).
|
||||
List<ContactMethod> FreshContacts()
|
||||
{
|
||||
var list = contactSpecs.Select(s => new ContactMethod { Type = s.Type, Value = s.Value, SortOrder = s.Order }).ToList();
|
||||
if (adminPhone is not null)
|
||||
{
|
||||
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 });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
TalentListing? firstTalent = null;
|
||||
foreach (var role in validRoles)
|
||||
{
|
||||
var t = new TalentListing
|
||||
{
|
||||
RoleId = role.Id, CityId = cityId.Value,
|
||||
PersonName = string.IsNullOrWhiteSpace(PersonName) ? null : PersonName.Trim(),
|
||||
YearsExperience = YearsExperience, IsLicensed = IsLicensed,
|
||||
AreaNote = string.IsNullOrWhiteSpace(AreaNote) ? null : AreaNote.Trim(),
|
||||
Availability = EmploymentType, Gender = GenderRequirement,
|
||||
PayType = payType, PayAmount = payAmt, SharePercent = sharePct,
|
||||
Phone = adminPhone, Description = Description,
|
||||
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl,
|
||||
Contacts = FreshContacts(),
|
||||
Tags = string.Join(" ", new[] { tags, role.Name }.Where(x => !string.IsNullOrWhiteSpace(x))),
|
||||
};
|
||||
_db.Shifts.Add(shift);
|
||||
_db.TalentListings.Add(t);
|
||||
firstTalent ??= t;
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
Raw.Status = RawListingStatus.Normalized;
|
||||
Raw.LinkedShiftId = shift.Id;
|
||||
createdShift = shift;
|
||||
Raw.LinkedTalentId = firstTalent!.Id;
|
||||
await _db.SaveChangesAsync();
|
||||
return RedirectToPage("/Admin/Index");
|
||||
}
|
||||
|
||||
// ---- Shift / Job: need a facility (falls back to «نامشخص / ثبت نشده») ----
|
||||
var facilityId = await ResolveFacilityIdAsync();
|
||||
if (facilityId is null)
|
||||
{
|
||||
Error = "شهری برای ساخت مرکز موجود نیست؛ ابتدا یک شهر اضافه کن.";
|
||||
return RedirectToPage(new { id });
|
||||
}
|
||||
var many = validRoles.Count > 1;
|
||||
|
||||
if (Kind == ListingKind.Shift)
|
||||
{
|
||||
var created = new List<Shift>();
|
||||
foreach (var role in validRoles)
|
||||
{
|
||||
var shift = new Shift
|
||||
{
|
||||
FacilityId = facilityId.Value, RoleId = role.Id,
|
||||
Date = ShiftDate, StartTime = StartTime, EndTime = EndTime, ShiftType = ShiftType,
|
||||
SpecialtyRequired = role.Name, Description = Description,
|
||||
PayType = payType, PayAmount = payAmt, SharePercent = sharePct,
|
||||
GenderRequirement = GenderRequirement, Status = ShiftStatus.Open,
|
||||
Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl,
|
||||
};
|
||||
_db.Shifts.Add(shift);
|
||||
created.Add(shift);
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
Raw.Status = RawListingStatus.Normalized;
|
||||
Raw.LinkedShiftId = created[0].Id;
|
||||
await _db.SaveChangesAsync();
|
||||
foreach (var s in created) await _notify.NotifyNewShiftAsync(s.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
var created = new List<JobOpening>();
|
||||
foreach (var role in validRoles)
|
||||
{
|
||||
var job = new JobOpening
|
||||
{
|
||||
FacilityId = FacilityId,
|
||||
RoleId = RoleId,
|
||||
Title = string.IsNullOrWhiteSpace(Title) ? "موقعیت استخدامی" : Title.Trim(),
|
||||
FacilityId = facilityId.Value, RoleId = role.Id,
|
||||
// With several roles, give each a role-specific title; with one, honor the typed title.
|
||||
Title = many || string.IsNullOrWhiteSpace(Title) ? $"استخدام {role.Name}" : Title.Trim(),
|
||||
EmploymentType = EmploymentType,
|
||||
SalaryMin = Negotiable ? null : SalaryMin,
|
||||
SalaryMax = Negotiable ? null : SalaryMax,
|
||||
GenderRequirement = GenderRequirement,
|
||||
Description = Description,
|
||||
Status = ShiftStatus.Open,
|
||||
Source = ShiftSource.Aggregated,
|
||||
SourceUrl = Raw.SourceUrl,
|
||||
SalaryMin = Negotiable ? null : SalaryMin, SalaryMax = Negotiable ? null : SalaryMax,
|
||||
GenderRequirement = GenderRequirement, Description = Description,
|
||||
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl,
|
||||
};
|
||||
_db.JobOpenings.Add(job);
|
||||
Raw.Status = RawListingStatus.Normalized;
|
||||
createdJob = job;
|
||||
created.Add(job);
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
if (createdShift is not null) await _notify.NotifyNewShiftAsync(createdShift.Id);
|
||||
if (createdJob is not null) await _notify.NotifyNewJobAsync(createdJob.Id);
|
||||
Raw.Status = RawListingStatus.Normalized;
|
||||
await _db.SaveChangesAsync();
|
||||
foreach (var j in created) await _notify.NotifyNewJobAsync(j.Id);
|
||||
}
|
||||
return RedirectToPage("/Admin/Index");
|
||||
}
|
||||
|
||||
@@ -150,10 +263,66 @@ public class ReviewModel : PageModel
|
||||
_ => (new TimeOnly(8, 0), new TimeOnly(8, 0)),
|
||||
};
|
||||
|
||||
/// <summary>Placeholder facility name used when an ad doesn't name a real one.</summary>
|
||||
private const string UnknownFacilityName = "نامشخص / ثبت نشده";
|
||||
|
||||
/// <summary>
|
||||
/// Returns a valid FacilityId. Prefers the picked facility, then the typed/parsed name
|
||||
/// (reusing a fuzzy match before creating), and finally falls back to a single shared
|
||||
/// «نامشخص / ثبت نشده» record so publishing never fails for a missing facility.
|
||||
/// Returns null only when there are no cities at all.
|
||||
/// </summary>
|
||||
private async Task<int?> ResolveFacilityIdAsync()
|
||||
{
|
||||
if (FacilityId > 0 && await _db.Facilities.AnyAsync(f => f.Id == FacilityId))
|
||||
return FacilityId;
|
||||
|
||||
var cityId = await _db.Cities.OrderByDescending(c => c.IsActive)
|
||||
.Select(c => (int?)c.Id).FirstOrDefaultAsync();
|
||||
if (cityId is null) return null; // no cities seeded — cannot create a facility
|
||||
|
||||
// No facility named in the ad → use/create the shared placeholder.
|
||||
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
|
||||
// match), so we don't create duplicates like «بیمارستان میلاد» vs «میلاد».
|
||||
var all = await _db.Facilities.ToListAsync();
|
||||
var match = FacilityMatcher.FindBest(all, name, cityId);
|
||||
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
|
||||
{
|
||||
Name = name,
|
||||
CityId = cityId.Value,
|
||||
Type = FacilityType.Hospital,
|
||||
Verification = VerificationStatus.Unverified,
|
||||
IsVerified = false,
|
||||
Lat = HasGeo() ? Raw!.Lat : null,
|
||||
Lng = HasGeo() ? Raw!.Lng : null,
|
||||
};
|
||||
_db.Facilities.Add(facility);
|
||||
await _db.SaveChangesAsync();
|
||||
return facility.Id;
|
||||
}
|
||||
|
||||
private async Task LoadListsAsync()
|
||||
{
|
||||
Facilities = await _db.Facilities.Include(f => f.City).OrderBy(f => f.Name).ToListAsync();
|
||||
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();
|
||||
}
|
||||
|
||||
private Task<List<string>> CityNamesAsync() => _db.Cities.Select(c => c.Name).ToListAsync();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,12 @@
|
||||
@if (Model.Saved is not null) { <div class="alert alert-success">✓ @Model.Saved</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.ProxyTest is not null) { <div class="alert alert-success">@Model.ProxyTest</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">
|
||||
<div class="settings-layout">
|
||||
@@ -65,14 +71,22 @@
|
||||
<div style="flex:1;"><label>نام مدل</label><input type="text" name="AiModel" value="@Model.AiModel" dir="ltr" /></div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>دستور و چارچوب هوش مصنوعی (Prompt / Framework)</label>
|
||||
<textarea name="AiSystemPrompt" rows="8" dir="rtl">@Model.AiSystemPrompt</textarea>
|
||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">به مدل بگو چطور تأیید/رد کند و چه فیلدهایی را استخراج کند. خروجی باید JSON باشد.</p>
|
||||
<label>دستور و چارچوب هوش مصنوعی (Prompt / Framework) — ثابت 🔒</label>
|
||||
<textarea rows="14" dir="rtl" readonly
|
||||
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>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="AiAutoApprove" value="true" checked="@Model.AiAutoApprove" />
|
||||
<span class="t-body"><span>در حالت خودکار، آگهیهایی که AI تأیید میکند مستقیم منتشر شوند</span></span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="AiUseProxy" value="true" checked="@Model.AiUseProxy" />
|
||||
<span class="t-body"><span>ارسال درخواست هوش مصنوعی از طریق پروکسی</span>
|
||||
<span class="t-hint">برای دسترسی به سرویسهایی مثل OpenAI از داخل ایران؛ از همان آدرس پروکسی تب «منابع جمعآوری» استفاده میکند.</span></span>
|
||||
</label>
|
||||
<button type="submit" asp-page-handler="TestAi" class="btn btn-outline" style="margin-top:6px;">🤖 تست هوش مصنوعی (روی یک آگهی نمونه)</button>
|
||||
<p class="muted" style="font-size:11px; margin:4px 0 0;">یک آگهی نمونه را به مدل میفرستد و تصمیم/استخراج آن را نشان میدهد. (ابتدا کلید و آدرس را ذخیره کن.)</p>
|
||||
</section>
|
||||
|
||||
<!-- SOURCES -->
|
||||
@@ -87,58 +101,93 @@
|
||||
<input type="number" name="IngestIntervalMinutes" min="1" value="@Model.IngestIntervalMinutes" dir="ltr" />
|
||||
</div>
|
||||
|
||||
<p class="muted" style="font-size:12px; margin:0 0 4px;">هر منبع را جداگانه روشن/خاموش و تنظیم کن.</p>
|
||||
|
||||
<div class="source-box">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="TelegramEnabled" value="true" checked="@Model.TelegramEnabled" />
|
||||
<span class="t-body"><span>تلگرام (کانالهای عمومی — بدون توکن)</span></span>
|
||||
<span class="t-body"><span>📨 تلگرام</span><span class="t-hint">کانالهای عمومی — بدون توکن.</span></span>
|
||||
</label>
|
||||
<div class="filter-group">
|
||||
<label>یوزرنیم کانالها (هر خط یک کانال)</label>
|
||||
<textarea name="TelegramChannels" rows="3" dir="ltr" placeholder="shift_channel another_channel">@Model.TelegramChannels</textarea>
|
||||
<label class="proxy-toggle"><input type="checkbox" name="TelegramUseProxy" value="true" checked="@Model.TelegramUseProxy" /> از پروکسی استفاده شود</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="source-box">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="BaleEnabled" value="true" checked="@Model.BaleEnabled" />
|
||||
<span class="t-body"><span>بله (بات باید عضو کانال باشد)</span></span>
|
||||
<span class="t-body"><span>💬 بله</span><span class="t-hint">بات باید عضو کانال باشد.</span></span>
|
||||
</label>
|
||||
<div class="filter-group"><label>توکن بات بله</label><input type="password" name="BaleBotToken" value="@Model.BaleBotToken" dir="ltr" />
|
||||
<label class="proxy-toggle"><input type="checkbox" name="BaleUseProxy" value="true" checked="@Model.BaleUseProxy" /> از پروکسی استفاده شود</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="source-box">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="DivarEnabled" value="true" checked="@Model.DivarEnabled" />
|
||||
<span class="t-body"><span>دیوار</span></span>
|
||||
<span class="t-body"><span>🟥 دیوار</span></span>
|
||||
</label>
|
||||
<div class="filter-group" style="display:flex; gap:8px;">
|
||||
<div style="flex:0 0 120px;"><label>شهر (slug)</label><input type="text" name="DivarCity" value="@Model.DivarCity" dir="ltr" placeholder="tehran" /></div>
|
||||
<div style="flex:1;"><label>عبارتهای جستجو (هر خط یکی)</label><textarea name="DivarQueries" rows="3">@Model.DivarQueries</textarea></div>
|
||||
</div>
|
||||
<label class="proxy-toggle"><input type="checkbox" name="DivarUseProxy" value="true" checked="@Model.DivarUseProxy" /> از پروکسی استفاده شود</label>
|
||||
</div>
|
||||
|
||||
<div class="source-box">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="MedjobsEnabled" value="true" checked="@Model.MedjobsEnabled" />
|
||||
<span class="t-body"><span>مدجابز (medjobs.ir)</span><span class="t-hint">خواندن کامل آگهیها از سایتمپ.</span></span>
|
||||
<span class="t-body"><span>🩺 مدجابز (medjobs.ir)</span><span class="t-hint">خواندن کامل آگهیها از سایتمپ + استخراج شماره.</span></span>
|
||||
</label>
|
||||
<div class="filter-group"><label>حداکثر آگهی در هر اجرا</label><input type="number" name="MedjobsMaxAds" min="1" max="500" value="@Model.MedjobsMaxAds" dir="ltr" />
|
||||
<label class="proxy-toggle"><input type="checkbox" name="MedjobsUseProxy" value="true" checked="@Model.MedjobsUseProxy" /> از پروکسی استفاده شود</label>
|
||||
</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">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="WebsitesEnabled" value="true" checked="@Model.WebsitesEnabled" />
|
||||
<span class="t-body"><span>وبسایتها (آدرسهای دلخواه)</span></span>
|
||||
<span class="t-body"><span>🌐 وبسایتها</span><span class="t-hint">آدرسهای دلخواه.</span></span>
|
||||
</label>
|
||||
<div class="filter-group">
|
||||
<label>آدرس صفحهها (هر خط یک URL)</label>
|
||||
<textarea name="WebsiteUrls" rows="3" dir="ltr" placeholder="https://example.ir/jobs/123">@Model.WebsiteUrls</textarea>
|
||||
<label class="proxy-toggle"><input type="checkbox" name="WebsitesUseProxy" value="true" checked="@Model.WebsitesUseProxy" /> از پروکسی استفاده شود</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
|
||||
<h3 style="margin-top:0;">پروکسی (Xray/V2Ray)</h3>
|
||||
<div class="source-box">
|
||||
<h4 style="margin:0 0 8px;">🛡️ پروکسی (Xray/V2Ray)</h4>
|
||||
<div class="filter-group">
|
||||
<label>آدرس پروکسی محلی</label>
|
||||
<input type="text" name="IngestProxyUrl" value="@Model.IngestProxyUrl" dir="ltr" placeholder="socks5://xray:10808" />
|
||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">یک کلاینت Xray/V2Ray کانفیگ vmess/vless/trojan تو را به یک پروکسی محلی تبدیل میکند (socks5:// یا socks4:// یا http://). <strong>هر منبع جداگانه</strong> با تیکِ «از پروکسی استفاده شود» تعیین میکند که از این پروکسی عبور کند یا نه.</p>
|
||||
<button type="submit" asp-page-handler="TestProxy" class="btn btn-outline" style="margin-top:8px;">🔌 تست اتصال VPN/پروکسی</button>
|
||||
<p class="muted" style="font-size:11px; margin:4px 0 0;">از طریق پروکسی به یک سایت فیلترشده وصل میشود؛ موفقیت یعنی تونل برقرار است. (ابتدا آدرس را ذخیره کن.)</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -14,11 +14,16 @@ public class SettingsModel : PageModel
|
||||
private readonly SettingsService _settings;
|
||||
private readonly ISmsSender _sms;
|
||||
private readonly AppDbContext _db;
|
||||
public SettingsModel(SettingsService settings, ISmsSender sms, AppDbContext db)
|
||||
private readonly ScrapeHttpClients _clients;
|
||||
private readonly IAiAuditor _ai;
|
||||
public SettingsModel(SettingsService settings, ISmsSender sms, AppDbContext db,
|
||||
ScrapeHttpClients clients, IAiAuditor ai)
|
||||
{
|
||||
_settings = settings;
|
||||
_sms = sms;
|
||||
_db = db;
|
||||
_clients = clients;
|
||||
_ai = ai;
|
||||
}
|
||||
|
||||
[BindProperty] public IngestionMode Mode { get; set; }
|
||||
@@ -27,8 +32,9 @@ public class SettingsModel : PageModel
|
||||
[BindProperty] public string? AiEndpoint { get; set; }
|
||||
[BindProperty] public string? AiApiKey { 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 AiUseProxy { get; set; }
|
||||
// Channel scraping sources
|
||||
[BindProperty] public bool AutoIngestEnabled { get; set; }
|
||||
[BindProperty] public int IngestIntervalMinutes { get; set; } = 30;
|
||||
@@ -41,6 +47,12 @@ public class SettingsModel : PageModel
|
||||
[BindProperty] public string? DivarQueries { get; set; }
|
||||
[BindProperty] public bool MedjobsEnabled { get; set; }
|
||||
[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 string? SmsApiKey { get; set; }
|
||||
[BindProperty] public string? SmsTemplate { get; set; }
|
||||
@@ -64,6 +76,8 @@ public class SettingsModel : PageModel
|
||||
[TempData] public string? Saved { get; set; }
|
||||
[TempData] public string? SmsTest { get; set; }
|
||||
[TempData] public string? DemoMsg { get; set; }
|
||||
[TempData] public string? ProxyTest { get; set; }
|
||||
[TempData] public string? AiTest { get; set; }
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
@@ -74,8 +88,8 @@ public class SettingsModel : PageModel
|
||||
AiEndpoint = s.AiEndpoint;
|
||||
AiApiKey = s.AiApiKey;
|
||||
AiModel = s.AiModel;
|
||||
AiSystemPrompt = s.AiSystemPrompt;
|
||||
AiAutoApprove = s.AiAutoApprove;
|
||||
AiUseProxy = s.AiUseProxy;
|
||||
AutoIngestEnabled = s.AutoIngestEnabled;
|
||||
IngestIntervalMinutes = s.IngestIntervalMinutes;
|
||||
TelegramEnabled = s.TelegramEnabled;
|
||||
@@ -87,6 +101,12 @@ public class SettingsModel : PageModel
|
||||
DivarQueries = s.DivarQueries;
|
||||
MedjobsEnabled = s.MedjobsEnabled;
|
||||
MedjobsMaxAds = s.MedjobsMaxAds;
|
||||
IranEstekhdamEnabled = s.IranEstekhdamEnabled;
|
||||
IranEstekhdamMaxAds = s.IranEstekhdamMaxAds;
|
||||
IranEstekhdamUseProxy = s.IranEstekhdamUseProxy;
|
||||
MedboomEnabled = s.MedboomEnabled;
|
||||
MedboomMaxAds = s.MedboomMaxAds;
|
||||
MedboomUseProxy = s.MedboomUseProxy;
|
||||
SmsEnabled = s.SmsEnabled;
|
||||
SmsApiKey = s.SmsApiKey;
|
||||
SmsTemplate = s.SmsTemplate;
|
||||
@@ -118,8 +138,9 @@ public class SettingsModel : PageModel
|
||||
AiEndpoint = AiEndpoint,
|
||||
AiApiKey = AiApiKey,
|
||||
AiModel = AiModel,
|
||||
AiSystemPrompt = AiSystemPrompt,
|
||||
// AiSystemPrompt intentionally omitted — AppSetting defaults it to DefaultPrompt (hardcoded).
|
||||
AiAutoApprove = AiAutoApprove,
|
||||
AiUseProxy = AiUseProxy,
|
||||
AutoIngestEnabled = AutoIngestEnabled,
|
||||
IngestIntervalMinutes = IngestIntervalMinutes,
|
||||
TelegramEnabled = TelegramEnabled,
|
||||
@@ -131,6 +152,12 @@ public class SettingsModel : PageModel
|
||||
DivarQueries = DivarQueries,
|
||||
MedjobsEnabled = MedjobsEnabled,
|
||||
MedjobsMaxAds = MedjobsMaxAds,
|
||||
IranEstekhdamEnabled = IranEstekhdamEnabled,
|
||||
IranEstekhdamMaxAds = IranEstekhdamMaxAds,
|
||||
IranEstekhdamUseProxy = IranEstekhdamUseProxy,
|
||||
MedboomEnabled = MedboomEnabled,
|
||||
MedboomMaxAds = MedboomMaxAds,
|
||||
MedboomUseProxy = MedboomUseProxy,
|
||||
SmsEnabled = SmsEnabled,
|
||||
SmsApiKey = SmsApiKey,
|
||||
SmsTemplate = SmsTemplate,
|
||||
@@ -169,6 +196,45 @@ public class SettingsModel : PageModel
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
/// <summary>Check the VPN/proxy is connected by reaching a normally-blocked site through it.</summary>
|
||||
public async Task<IActionResult> OnPostTestProxyAsync()
|
||||
{
|
||||
var s = await _settings.GetAsync();
|
||||
if (string.IsNullOrWhiteSpace(s.IngestProxyUrl))
|
||||
{ ProxyTest = "ابتدا آدرس پروکسی را وارد و ذخیره کن."; return RedirectToPage(); }
|
||||
|
||||
var client = _clients.For(s, useProxy: true);
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
// api.telegram.org is filtered in Iran — a reply means the tunnel reaches the open internet.
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
using var resp = await client.GetAsync("https://api.telegram.org",
|
||||
HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
||||
sw.Stop();
|
||||
ProxyTest = $"✅ پروکسی وصل است — به اینترنت آزاد دسترسی دارد (HTTP {(int)resp.StatusCode}، {sw.ElapsedMilliseconds} میلیثانیه).";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ProxyTest = "❌ اتصال از طریق پروکسی ناموفق بود. مطمئن شو سرویس Xray اجراست و کانفیگ معتبر است. خطا: " + ex.Message;
|
||||
}
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
/// <summary>Send a sample post to the AI endpoint and show the verdict (validates key/endpoint/proxy).</summary>
|
||||
public async Task<IActionResult> OnPostTestAiAsync()
|
||||
{
|
||||
var s = await _settings.GetAsync();
|
||||
if (!s.AiEnabled || string.IsNullOrWhiteSpace(s.AiEndpoint))
|
||||
{ AiTest = "ابتدا «فعالسازی هوش مصنوعی» را بزن و آدرس/کلید را ذخیره کن."; return RedirectToPage(); }
|
||||
|
||||
const string sample = "استخدام پرستار خانم برای بخش اورژانس بیمارستان میلاد تهران، شیفت شب، حقوق توافقی، تماس ۰۹۱۲۱۲۳۴۵۶۷";
|
||||
// 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.
|
||||
AiTest = await _ai.TestAsync(sample, s);
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostTestSmsAsync()
|
||||
{
|
||||
var s = await _settings.GetAsync();
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
@page
|
||||
@model JobsMedical.Web.Pages.Admin.SocialModel
|
||||
@{
|
||||
ViewData["Title"] = "شبکههای اجتماعی";
|
||||
}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="container">
|
||||
<h1>شبکههای اجتماعی</h1>
|
||||
<p class="muted">انتشار خودکار «کادر آمادهبهکار امروز» در تلگرام و بله (متن) و بستهی کپشن/هشتگ برای اینستاگرام.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container section">
|
||||
@if (Model.Message is not null) { <div class="alert alert-success">✓ @Model.Message</div> }
|
||||
@if (Model.Error is not null) { <div class="alert alert-error">⚠ @Model.Error</div> }
|
||||
|
||||
<div class="layout-2">
|
||||
<div>
|
||||
<form method="post" class="card card-pad">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="SocialEnabled" value="true" checked="@Model.SocialEnabled" />
|
||||
<span class="t-body"><span>انتشار خودکار روشن باشد</span><span class="t-hint">روزانه چند بار، بهصورت زمانبندیشده ارسال میشود.</span></span>
|
||||
</label>
|
||||
<div class="filter-group" style="display:flex; gap:8px;">
|
||||
<div style="flex:0 0 160px;"><label>تعداد پست در روز</label><input type="number" name="SocialPostsPerDay" min="1" max="24" value="@Model.SocialPostsPerDay" dir="ltr" /></div>
|
||||
<label class="proxy-toggle" style="align-self:end;"><input type="checkbox" name="SocialUseProxy" value="true" checked="@Model.SocialUseProxy" /> ارسال از طریق پروکسی (برای تلگرام)</label>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>سرتیتر پیام (Header)</label>
|
||||
<textarea name="SocialHeader" rows="2" placeholder="مثلاً: 🩺 همکادر | مرجع شیفت و استخدام کادر درمان">@Model.SocialHeader</textarea>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>پاورقی پیام (Footer)</label>
|
||||
<textarea name="SocialFooter" rows="2" placeholder="مثلاً: ثبت رایگان آگهی در hamkadr.ir | @@hamkadr">@Model.SocialFooter</textarea>
|
||||
</div>
|
||||
|
||||
<div class="source-box">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="SocialTelegramEnabled" value="true" checked="@Model.SocialTelegramEnabled" />
|
||||
<span class="t-body"><span>📨 تلگرام (متن)</span><span class="t-hint">با بات تلگرام در کانال شما پست میشود.</span></span>
|
||||
</label>
|
||||
<div class="filter-group"><label>توکن بات تلگرام</label><input type="password" name="SocialTelegramBotToken" value="@Model.SocialTelegramBotToken" dir="ltr" placeholder="123456:ABC-..." /></div>
|
||||
<div class="filter-group"><label>شناسه کانال/چت</label><input type="text" name="SocialTelegramChatId" value="@Model.SocialTelegramChatId" dir="ltr" placeholder="@@your_channel یا -100..." />
|
||||
<p class="muted" style="font-size:11px; margin:4px 0 0;">بات باید ادمینِ کانال باشد.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="source-box">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="SocialBaleEnabled" value="true" checked="@Model.SocialBaleEnabled" />
|
||||
<span class="t-body"><span>💬 بله (متن)</span></span>
|
||||
</label>
|
||||
<div class="filter-group"><label>توکن بات بله</label><input type="password" name="SocialBaleBotToken" value="@Model.SocialBaleBotToken" dir="ltr" /></div>
|
||||
<div class="filter-group"><label>شناسه کانال/چت بله</label><input type="text" name="SocialBaleChatId" value="@Model.SocialBaleChatId" dir="ltr" placeholder="@@your_channel یا عدد" /></div>
|
||||
</div>
|
||||
|
||||
<div class="source-box">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="SocialInstagramEnabled" value="true" checked="@Model.SocialInstagramEnabled" />
|
||||
<span class="t-body"><span>📷 اینستاگرام (نیمهخودکار)</span><span class="t-hint">کپشن و هشتگ آماده میشود؛ تصویر و انتشار را دستی انجام میدهی.</span></span>
|
||||
</label>
|
||||
<div class="filter-group"><label>هشتگهای اضافه (با فاصله یا خط جدید)</label>
|
||||
<textarea name="InstagramHashtags" rows="2" dir="ltr" placeholder="#استخدام_پرستار #شیفت_تهران">@Model.InstagramHashtags</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-save">
|
||||
<button type="submit" asp-page-handler="Save" class="btn btn-accent btn-block btn-lg">ذخیره تنظیمات</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" style="margin-top:12px;">
|
||||
<button type="submit" asp-page-handler="SendNow" class="btn btn-outline btn-block">📤 ارسال اکنون (تلگرام/بله)</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<aside>
|
||||
<div class="card card-pad">
|
||||
<h3 style="margin-top:0;">پیشنمایش پیام امروز</h3>
|
||||
@if (Model.Preview is null || Model.Preview.Count == 0)
|
||||
{
|
||||
<p class="muted">امروز هنوز موردِ «آماده به کار» تازهای ثبت نشده است.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted" style="font-size:12px;">@JalaliDate.ToPersianDigits(Model.Preview.Count.ToString()) مورد — همین متن به تلگرام/بله میرود.</p>
|
||||
<pre style="white-space:pre-wrap; font-family:inherit; background:var(--bg); border:1px solid var(--line); border-radius:10px; padding:12px; font-size:13px; margin:0;">@Model.Preview.TelegramText</pre>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (Model.SocialInstagramEnabled && Model.Preview is not null && Model.Preview.Count > 0)
|
||||
{
|
||||
<div class="card card-pad" style="margin-top:12px;">
|
||||
<h3 style="margin-top:0;">📷 بستهی اینستاگرام</h3>
|
||||
<label style="font-size:12px; font-weight:700;">کپشن (با هشتگ):</label>
|
||||
<textarea id="igCaption" rows="8" style="width:100%; font-size:12.5px;">@Model.Preview.InstagramCaption</textarea>
|
||||
<button type="button" class="btn btn-outline btn-block" style="margin-top:6px;" onclick="navigator.clipboard.writeText(document.getElementById('igCaption').value); this.textContent='کپی شد ✓';">کپی کپشن</button>
|
||||
<p class="muted" style="font-size:11px; margin:8px 0 0;">تصویر کارت با فونت وزیر در نسخهی بعدی اضافه میشود؛ فعلاً کپشن/هشتگ را کپی کن و در اینستاگرام پست کن.</p>
|
||||
</div>
|
||||
}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,102 @@
|
||||
using JobsMedical.Web.Data;
|
||||
using JobsMedical.Web.Models;
|
||||
using JobsMedical.Web.Services;
|
||||
using JobsMedical.Web.Services.Scraping;
|
||||
using JobsMedical.Web.Services.Social;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobsMedical.Web.Pages.Admin;
|
||||
|
||||
[Authorize(Roles = "Admin")]
|
||||
public class SocialModel : PageModel
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly SocialPostService _social;
|
||||
|
||||
public SocialModel(AppDbContext db, SettingsService settings, SocialPostService social)
|
||||
{
|
||||
_db = db; _settings = settings; _social = social;
|
||||
}
|
||||
|
||||
[TempData] public string? Message { get; set; }
|
||||
[TempData] public string? Error { get; set; }
|
||||
|
||||
public SocialDigest? Preview { get; private set; }
|
||||
|
||||
[BindProperty] public bool SocialEnabled { get; set; }
|
||||
[BindProperty] public int SocialPostsPerDay { get; set; }
|
||||
[BindProperty] public string? SocialHeader { get; set; }
|
||||
[BindProperty] public string? SocialFooter { get; set; }
|
||||
[BindProperty] public bool SocialUseProxy { get; set; }
|
||||
[BindProperty] public bool SocialTelegramEnabled { get; set; }
|
||||
[BindProperty] public string? SocialTelegramBotToken { get; set; }
|
||||
[BindProperty] public string? SocialTelegramChatId { get; set; }
|
||||
[BindProperty] public bool SocialBaleEnabled { get; set; }
|
||||
[BindProperty] public string? SocialBaleBotToken { get; set; }
|
||||
[BindProperty] public string? SocialBaleChatId { get; set; }
|
||||
[BindProperty] public bool SocialInstagramEnabled { get; set; }
|
||||
[BindProperty] public string? InstagramHashtags { get; set; }
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
var s = await _settings.GetAsync();
|
||||
SocialEnabled = s.SocialEnabled;
|
||||
SocialPostsPerDay = s.SocialPostsPerDay;
|
||||
SocialHeader = s.SocialHeader;
|
||||
SocialFooter = s.SocialFooter;
|
||||
SocialUseProxy = s.SocialUseProxy;
|
||||
SocialTelegramEnabled = s.SocialTelegramEnabled;
|
||||
SocialTelegramBotToken = s.SocialTelegramBotToken;
|
||||
SocialTelegramChatId = s.SocialTelegramChatId;
|
||||
SocialBaleEnabled = s.SocialBaleEnabled;
|
||||
SocialBaleBotToken = s.SocialBaleBotToken;
|
||||
SocialBaleChatId = s.SocialBaleChatId;
|
||||
SocialInstagramEnabled = s.SocialInstagramEnabled;
|
||||
InstagramHashtags = s.InstagramHashtags;
|
||||
|
||||
Preview = await _social.BuildDigestAsync(s);
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostSaveAsync()
|
||||
{
|
||||
var s = await _settings.GetAsync();
|
||||
s.SocialEnabled = SocialEnabled;
|
||||
s.SocialPostsPerDay = Math.Clamp(SocialPostsPerDay, 1, 24);
|
||||
s.SocialHeader = SocialHeader?.Trim();
|
||||
s.SocialFooter = SocialFooter?.Trim();
|
||||
s.SocialUseProxy = SocialUseProxy;
|
||||
s.SocialTelegramEnabled = SocialTelegramEnabled;
|
||||
s.SocialTelegramBotToken = SocialTelegramBotToken?.Trim();
|
||||
s.SocialTelegramChatId = SocialTelegramChatId?.Trim();
|
||||
s.SocialBaleEnabled = SocialBaleEnabled;
|
||||
s.SocialBaleBotToken = SocialBaleBotToken?.Trim();
|
||||
s.SocialBaleChatId = SocialBaleChatId?.Trim();
|
||||
s.SocialInstagramEnabled = SocialInstagramEnabled;
|
||||
s.InstagramHashtags = InstagramHashtags?.Trim();
|
||||
s.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
Message = "تنظیمات شبکههای اجتماعی ذخیره شد.";
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostSendNowAsync()
|
||||
{
|
||||
var r = await _social.PostAsync();
|
||||
if (r.Count == 0) Error = r.Error ?? "موردی برای انتشار نبود.";
|
||||
else
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (r.TelegramOk) parts.Add("تلگرام ✓");
|
||||
if (r.BaleOk) parts.Add("بله ✓");
|
||||
Message = parts.Count > 0
|
||||
? $"ارسال شد ({string.Join("، ", parts)}) — {JalaliDate.ToPersianDigits(r.Count.ToString())} مورد."
|
||||
: "هیچ کانالی ارسال نشد؛ توکن/شناسه و فعالبودن را بررسی کن.";
|
||||
if (r.Error is not null && parts.Count == 0) Error = r.Error;
|
||||
}
|
||||
return RedirectToPage();
|
||||
}
|
||||
}
|
||||
@@ -88,8 +88,8 @@
|
||||
@section Scripts {
|
||||
@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" />
|
||||
<script src="https://static.neshan.org/sdk/leaflet/1.4.0/neshan-sdk/v1.0.8/index.js"></script>
|
||||
<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/leaflet.js"></script>
|
||||
}
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
</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>
|
||||
<p>
|
||||
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
@@ -12,9 +13,24 @@ public class ErrorModel : PageModel
|
||||
|
||||
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()
|
||||
{
|
||||
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">
|
||||
@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>
|
||||
@if (Model.Shifts.Count == 0 && Model.Jobs.Count == 0)
|
||||
{
|
||||
@@ -147,3 +149,12 @@
|
||||
{
|
||||
<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>
|
||||
<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;">
|
||||
@JalaliDate.ToPersianDigits(row.OpenShifts.ToString()) شیفت باز
|
||||
@JalaliDate.ToPersianDigits(row.OpenListings.ToString()) آگهی فعال
|
||||
</span>
|
||||
<span class="btn btn-outline" style="padding:6px 14px;">مشاهده مرکز</span>
|
||||
</div>
|
||||
|
||||
@@ -10,21 +10,36 @@ public class IndexModel : PageModel
|
||||
private readonly AppDbContext _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();
|
||||
|
||||
// The shared placeholder for unnamed aggregated ads is not a real, browseable facility.
|
||||
private const string PlaceholderName = "نامشخص / ثبت نشده";
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var facilities = await _db.Facilities.Include(f => f.City).OrderBy(f => f.Name).ToListAsync();
|
||||
var counts = await _db.Shifts
|
||||
var jobCutoff = JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc;
|
||||
|
||||
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)
|
||||
.GroupBy(s => s.FacilityId)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||
.GroupBy(s => s.FacilityId).Select(g => new { g.Key, C = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.C);
|
||||
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
|
||||
.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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@model IndexModel
|
||||
@{
|
||||
ViewData["Title"] = null; // use default site title for the home page (best for SEO)
|
||||
ViewData["Description"] = "همکادر؛ سریعترین راه برای کادر درمان (پزشک، پرستار، ماما، تکنسین) جهت یافتن شیفت و موقعیت استخدامی در بیمارستانها و کلینیکهای تهران. بهجای گشتن در کانالهای تلگرام و بله، همه فرصتها یکجا.";
|
||||
ViewData["Description"] = "یافتن شیفت و موقعیت استخدامی کادر درمان (پزشک، پرستار، ماما، تکنسین) در بیمارستانها و کلینیکهای تهران — همهٔ فرصتها یکجا در همکادر.";
|
||||
}
|
||||
|
||||
<section class="hero">
|
||||
@@ -14,106 +14,64 @@
|
||||
مرکز درمانی، محل و تقویم هفتگی — یکجا.
|
||||
</p>
|
||||
|
||||
<form class="search-card" method="get" asp-page="/Shifts/Index">
|
||||
<div class="field">
|
||||
<label>شهر</label>
|
||||
<select name="cityId">
|
||||
<option value="">همه شهرها</option>
|
||||
@foreach (var c in Model.Cities)
|
||||
{
|
||||
<option value="@c.Id">@c.Name</option>
|
||||
}
|
||||
</select>
|
||||
<form class="hero-search" method="get" action="/Search" role="search" data-suggest>
|
||||
<div class="hero-search-pill">
|
||||
<span class="hs-ico">🔎</span>
|
||||
<input type="search" name="Q" autocomplete="off"
|
||||
placeholder="جستجو: پرستار، mmt، دندانپزشک…" />
|
||||
<button type="submit" class="btn btn-accent btn-lg hs-submit" aria-label="جستجو">
|
||||
<span class="hs-submit-txt">جستجو</span>
|
||||
<span class="hs-submit-ico" aria-hidden="true">🔎</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>نقش</label>
|
||||
<select name="roleId">
|
||||
<option value="">همه نقشها</option>
|
||||
@foreach (var r in Model.Roles)
|
||||
{
|
||||
<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 class="hero-chips">
|
||||
<span class="hc-label">جستجوهای پرطرفدار:</span>
|
||||
<a href="/Search?Q=%D9%BE%D8%B1%D8%B3%D8%AA%D8%A7%D8%B1">پرستار</a>
|
||||
<a href="/Search?Q=%D9%BE%D8%B2%D8%B4%DA%A9">پزشک</a>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<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.CityCount.ToString())</span><span class="l">شهر فعال</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (Model.Recommendations.Count > 0)
|
||||
{
|
||||
<section class="section" style="padding-bottom:0;">
|
||||
<section class="section" style="padding-bottom:0;">
|
||||
<div class="container">
|
||||
@if (Model.HasPersonalization)
|
||||
{
|
||||
<div class="rec-banner">
|
||||
<a asp-page="/Recommendations/Index" class="rec-banner" style="text-decoration:none; color:#fff;">
|
||||
<div>
|
||||
<h2 style="margin:0 0 4px;">✨ پیشنهادهای ویژه شما</h2>
|
||||
<span style="opacity:.9; font-size:14px;">بر اساس علاقهمندیها و فعالیت شما انتخاب شدهاند</span>
|
||||
<span style="opacity:.9; font-size:14px;">فرصتهای متناسب با نقش، شهر و فعالیت شما — همه یکجا</span>
|
||||
</div>
|
||||
<a class="btn btn-outline" asp-page="/Preferences/Index">ویرایش علاقهمندیها</a>
|
||||
<span class="btn btn-outline">مشاهده پیشنهادها ←</span>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
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>
|
||||
|
||||
<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="section-head">
|
||||
<h2>جدیدترین شیفتها</h2>
|
||||
<a asp-page="/Shifts/Index">مشاهده همه ←</a>
|
||||
<a href="/Shifts">مشاهده همه ←</a>
|
||||
</div>
|
||||
@if (Model.LatestShifts.Count == 0)
|
||||
{
|
||||
<div class="empty-state">فعلاً شیفت بازی ثبت نشده است.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="grid grid-3">
|
||||
@foreach (var s in Model.LatestShifts)
|
||||
{
|
||||
<partial name="_ShiftCard" model="s" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (Model.LatestJobs.Count > 0)
|
||||
{
|
||||
@@ -121,7 +79,7 @@
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<h2>فرصتهای استخدامی</h2>
|
||||
<a asp-page="/Jobs/Index">مشاهده همه ←</a>
|
||||
<a href="/Jobs">مشاهده همه ←</a>
|
||||
</div>
|
||||
<div class="grid grid-3">
|
||||
@foreach (var j in Model.LatestJobs)
|
||||
@@ -133,6 +91,24 @@
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (Model.LatestTalent.Count > 0)
|
||||
{
|
||||
<section class="section" style="padding-top:0;">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<h2>کادر درمان آماده به کار</h2>
|
||||
<a asp-page="/Talent/Index">مشاهده همه ←</a>
|
||||
</div>
|
||||
<div class="grid grid-3">
|
||||
@foreach (var t in Model.LatestTalent)
|
||||
{
|
||||
<partial name="_TalentCard" model="t" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="section" style="background: var(--surface); border-top: 1px solid var(--line);">
|
||||
<div class="container">
|
||||
<div class="section-head"><h2>چطور کار میکند؟</h2></div>
|
||||
@@ -152,3 +128,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@section Head {
|
||||
@{ var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
|
||||
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.Organization(bu) + "</script>")
|
||||
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.WebSite(bu) + "</script>")
|
||||
}
|
||||
|
||||
@@ -9,23 +9,19 @@ namespace JobsMedical.Web.Pages;
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
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;
|
||||
_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<JobOpening> LatestJobs { get; private set; } = new();
|
||||
public List<TalentListing> LatestTalent { get; private set; } = new();
|
||||
public List<City> Cities { get; private set; } = new();
|
||||
public List<Role> Roles { get; private set; } = new();
|
||||
public int OpenShiftCount { get; private set; }
|
||||
public int OpenJobCount { get; private set; }
|
||||
public int FacilityCount { get; private set; }
|
||||
public int CityCount { get; private set; }
|
||||
|
||||
@@ -33,11 +29,6 @@ public class IndexModel : PageModel
|
||||
{
|
||||
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
|
||||
.Include(s => s.Facility).ThenInclude(f => f.City)
|
||||
.Include(s => s.Role)
|
||||
@@ -56,9 +47,19 @@ public class IndexModel : PageModel
|
||||
.Take(3)
|
||||
.ToListAsync();
|
||||
|
||||
LatestTalent = await _db.TalentListings
|
||||
.Include(t => t.City).Include(t => t.District).Include(t => t.Role)
|
||||
.Where(t => t.Status == ShiftStatus.Open
|
||||
&& t.CreatedAt >= JobsMedical.Web.Services.Scraping.ListingPolicy.TalentCutoffUtc)
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.Take(6) // two rows of the grid-3 «آماده به کار» section
|
||||
.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();
|
||||
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();
|
||||
CityCount = await _db.Cities.CountAsync(c => c.IsActive);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,18 @@
|
||||
@{
|
||||
var j = Model.Job!;
|
||||
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["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).
|
||||
if (j.Status != JobsMedical.Web.Models.ShiftStatus.Open) ViewData["NoIndex"] = true;
|
||||
string empLabel = j.EmploymentType switch
|
||||
{
|
||||
EmploymentType.FullTime => "تماموقت",
|
||||
@@ -15,18 +25,23 @@
|
||||
string 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.SalaryMax is null) salary = "از " + JalaliDate.Toman(j.SalaryMin) + " ماهانه"; // min only — avoid «تا توافقی»
|
||||
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="container">
|
||||
<partial name="_Breadcrumbs" model="crumbs" />
|
||||
<div class="row" style="display:flex; gap:10px; align-items:center;">
|
||||
<span class="badge badge-job">@empLabel</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> }
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -35,10 +50,34 @@
|
||||
<div>
|
||||
@if (Model.ShowContact)
|
||||
{
|
||||
<div class="alert alert-success">
|
||||
✓ تمایل شما ثبت شد. برای پیگیری استخدام با مرکز تماس بگیرید:
|
||||
<strong>@(f.Phone ?? "شماره ثبت نشده")</strong>
|
||||
@if (!string.IsNullOrEmpty(f.BaleId)) { <text> — بله: @f.BaleId</text> }
|
||||
<div class="contact-reveal" style="margin-bottom:16px;">
|
||||
<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))
|
||||
{
|
||||
<div class="contact-row">
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(f.BaleId))
|
||||
{
|
||||
<div class="contact-row">
|
||||
<span class="c-meta"><span class="c-type">💬 بله</span><span class="c-val" dir="ltr">@f.BaleId</span></span>
|
||||
<a class="btn btn-outline" href="https://ble.ir/@f.BaleId" target="_blank" rel="noopener">باز کردن</a>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted" style="margin:0;">شمارهای ثبت نشده است.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (Model.Saved)
|
||||
@@ -78,19 +117,14 @@
|
||||
<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>
|
||||
<div class="aside-apply">
|
||||
<form method="post">
|
||||
<button type="submit" asp-page-handler="Interest" asp-route-id="@j.Id"
|
||||
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>
|
||||
<button type="button" class="btn btn-accent btn-block btn-lg contact-trigger"
|
||||
data-contact-type="job" data-contact-id="@j.Id">📞 اعلام تمایل و مشاهده راه ارتباطی</button>
|
||||
</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)
|
||||
{
|
||||
<p class="muted" style="font-size:12px; margin:8px 0 0;">✓ گزارش شما ثبت شد. متشکریم.</p>
|
||||
@@ -111,7 +145,7 @@
|
||||
@if (j.Facility is not null)
|
||||
{
|
||||
<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;">
|
||||
<input type="hidden" name="targetType" value="Facility" />
|
||||
<input type="hidden" name="targetId" value="@j.Facility.Id" />
|
||||
@@ -125,15 +159,15 @@
|
||||
}
|
||||
</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;">
|
||||
<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))
|
||||
{
|
||||
<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
|
||||
{
|
||||
@@ -141,39 +175,44 @@
|
||||
🗺️<br /><small class="muted" dir="ltr">@latS، @lngS</small>
|
||||
</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"
|
||||
href="https://neshan.org/maps/@(latS),@(lngS),16z">مسیریابی در نشان</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted" style="margin:0;">مختصات این آگهی ثبت نشده است.</p>
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Sticky bottom action bar — mobile only. *@
|
||||
<div class="mobile-action-bar">
|
||||
@if (Model.ShowContact)
|
||||
{
|
||||
@if (!string.IsNullOrEmpty(f.Phone))
|
||||
{
|
||||
<a class="btn btn-accent btn-lg cta-main" href="tel:@f.Phone">📞 تماس با مرکز</a>
|
||||
}
|
||||
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>
|
||||
}
|
||||
<button type="button" class="btn btn-accent btn-lg cta-main contact-trigger"
|
||||
data-contact-type="job" data-contact-id="@j.Id">📞 اعلام تمایل و مشاهده تماس</button>
|
||||
<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")">
|
||||
<span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> <span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
|
||||
</button>
|
||||
</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" />
|
||||
}
|
||||
|
||||
@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}"; }
|
||||
@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>")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ public class DetailsModel : PageModel
|
||||
|
||||
public JobOpening? Job { 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 Saved { get; private set; }
|
||||
public bool Reported { get; private set; }
|
||||
@@ -31,7 +33,13 @@ public class DetailsModel : PageModel
|
||||
{
|
||||
await LoadAsync(id);
|
||||
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;
|
||||
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";
|
||||
await _interest.LogJobAsync(InterestEventType.View, id);
|
||||
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.District)
|
||||
.Include(j => j.Role)
|
||||
.Include(j => j.Contacts)
|
||||
.FirstOrDefaultAsync(j => j.Id == id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
@page
|
||||
@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="container">
|
||||
<h1>موقعیتهای استخدامی</h1>
|
||||
<partial name="_Breadcrumbs" model="Model.Breadcrumbs" />
|
||||
<h1>@Model.PageHeading</h1>
|
||||
<p class="muted">
|
||||
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) موقعیت شغلی پیدا شد
|
||||
@JalaliDate.ToPersianDigits(Model.TotalCount.ToString()) موقعیت شغلی پیدا شد
|
||||
@if (Model.NearMeActive)
|
||||
{
|
||||
<span> — مرتبشده بر اساس نزدیکترین به شما 📍</span>
|
||||
}
|
||||
</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 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">
|
||||
<aside class="card card-pad filter-card">
|
||||
<h3>فیلترها</h3>
|
||||
@@ -100,6 +117,7 @@
|
||||
<partial name="_JobCard" model="j" />
|
||||
}
|
||||
</div>
|
||||
<partial name="_Pager" model="(Model.CurrentPage, Model.TotalPages)" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,3 +142,12 @@
|
||||
}
|
||||
</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? 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 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<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();
|
||||
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
|
||||
.Where(d => d.IsActive && (CityId == null || d.CityId == CityId))
|
||||
.OrderBy(d => d.Name).ToListAsync();
|
||||
@@ -49,19 +84,50 @@ public class IndexModel : PageModel
|
||||
if (GenderFilter is Gender g && g != Gender.Any)
|
||||
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)
|
||||
{
|
||||
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)
|
||||
j.DistanceKm = Geo.DistanceKm(Lat!.Value, Lng!.Value, flat, flng);
|
||||
Results = results.OrderBy(j => j.DistanceKm ?? double.MaxValue)
|
||||
.ThenByDescending(j => j.CreatedAt).ToList();
|
||||
Results = all.OrderBy(j => j.DistanceKm ?? double.MaxValue)
|
||||
.ThenByDescending(j => j.CreatedAt).Skip(skip).Take(PageSize).ToList();
|
||||
}
|
||||
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 async Task OnGetAsync()
|
||||
{
|
||||
await LoadListsAsync();
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Preferences have moved onto the «پیشنهادهای ویژه شما» page (settings next to their result).
|
||||
// Keep this route working by redirecting any old link/bookmark there.
|
||||
public IActionResult OnGet() => RedirectToPage("/Recommendations/Index");
|
||||
|
||||
public async Task<IActionResult> OnPostAsync()
|
||||
{
|
||||
await _interest.SavePreferencesAsync(RoleId, CityId, PreferredShiftType, MinPay, Gender);
|
||||
// Back to home so the personalized feed is the immediate payoff.
|
||||
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();
|
||||
return RedirectToPage("/Recommendations/Index");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,56 @@
|
||||
@page
|
||||
@model JobsMedical.Web.Pages.SearchModel
|
||||
@{
|
||||
ViewData["Title"] = Model.HasQuery ? $"جستجو: {Model.Q}" : "جستجو";
|
||||
ViewData["q"] = Model.Q; // drives highlighting in the cards
|
||||
ViewData["NoIndex"] = true;
|
||||
}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="container">
|
||||
<h1>جستجو</h1>
|
||||
<form method="get" class="search-hero">
|
||||
<input type="search" name="Q" value="@Model.Q" placeholder="مثلاً: پرستار شب تهران، mmt، دندانپزشک پروانهدار…" autofocus />
|
||||
<button type="submit" class="btn btn-accent">🔎 جستجو</button>
|
||||
</form>
|
||||
@if (Model.HasQuery)
|
||||
{
|
||||
<p class="muted">@JalaliDate.ToPersianDigits(Model.Total.ToString()) نتیجه برای «@Model.Q»</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container section">
|
||||
@if (!Model.HasQuery)
|
||||
{
|
||||
<div class="card empty-state">یک عبارت بنویس تا در شیفتها، استخدامها و آمادهبهکارها جستجو شود. هر کلمه باید جایی پیدا شود.</div>
|
||||
}
|
||||
else if (Model.Total == 0)
|
||||
{
|
||||
<div class="card empty-state">نتیجهای پیدا نشد. عبارت دیگری امتحان کن.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (Model.Shifts.Count > 0)
|
||||
{
|
||||
<div class="section-head"><h2>شیفتها (@JalaliDate.ToPersianDigits(Model.Shifts.Count.ToString()))</h2><a asp-page="/Shifts/Index">همه شیفتها ←</a></div>
|
||||
<div class="grid grid-3">
|
||||
@foreach (var s in Model.Shifts) { <partial name="_ShiftCard" model="s" /> }
|
||||
</div>
|
||||
}
|
||||
@if (Model.Jobs.Count > 0)
|
||||
{
|
||||
<div class="section-head" style="margin-top:24px;"><h2>استخدامها (@JalaliDate.ToPersianDigits(Model.Jobs.Count.ToString()))</h2><a asp-page="/Jobs/Index">همه استخدامها ←</a></div>
|
||||
<div class="grid grid-3">
|
||||
@foreach (var j in Model.Jobs) { <partial name="_JobCard" model="j" /> }
|
||||
</div>
|
||||
}
|
||||
@if (Model.Talent.Count > 0)
|
||||
{
|
||||
<div class="section-head" style="margin-top:24px;"><h2>آماده به کار (@JalaliDate.ToPersianDigits(Model.Talent.Count.ToString()))</h2><a asp-page="/Talent/Index">همه ←</a></div>
|
||||
<div class="grid grid-3">
|
||||
@foreach (var t in Model.Talent) { <partial name="_TalentCard" model="t" /> }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,91 @@
|
||||
using JobsMedical.Web.Data;
|
||||
using JobsMedical.Web.Models;
|
||||
using JobsMedical.Web.Services.Scraping;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobsMedical.Web.Pages;
|
||||
|
||||
/// <summary>Site-wide rich search across shifts, hiring openings, and applicants with keyword
|
||||
/// highlighting. Every query term must match somewhere (Postgres ILIKE over the relevant fields).</summary>
|
||||
public class SearchModel : PageModel
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
public SearchModel(AppDbContext db) => _db = db;
|
||||
|
||||
[BindProperty(SupportsGet = true)] public string? Q { get; set; }
|
||||
|
||||
public List<Shift> Shifts { get; private set; } = new();
|
||||
public List<JobOpening> Jobs { get; private set; } = new();
|
||||
public List<TalentListing> Talent { get; private set; } = new();
|
||||
public int Total => Shifts.Count + Jobs.Count + Talent.Count;
|
||||
public bool HasQuery => !string.IsNullOrWhiteSpace(Q);
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
if (!HasQuery) return;
|
||||
var terms = Q!.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var jobCut = ListingPolicy.JobCutoffUtc;
|
||||
var talentCut = ListingPolicy.TalentCutoffUtc;
|
||||
|
||||
var sq = _db.Shifts.Include(s => s.Facility).ThenInclude(f => f.City)
|
||||
.Include(s => s.Facility).ThenInclude(f => f.District).Include(s => s.Role)
|
||||
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today);
|
||||
foreach (var t in terms)
|
||||
{
|
||||
var like = $"%{t}%";
|
||||
sq = sq.Where(s => EF.Functions.ILike(s.Facility.Name, like) || EF.Functions.ILike(s.Facility.City.Name, like)
|
||||
|| EF.Functions.ILike(s.Role.Name, like) || EF.Functions.ILike(s.SpecialtyRequired, like)
|
||||
|| EF.Functions.ILike(s.Description ?? "", like));
|
||||
}
|
||||
var shiftPool = await sq.OrderByDescending(s => s.CreatedAt).Take(60).ToListAsync();
|
||||
Shifts = shiftPool
|
||||
.OrderByDescending(s => Rank(terms, 3, s.Role?.Name, s.Facility?.Name, s.Facility?.City?.Name, s.SpecialtyRequired)
|
||||
+ Rank(terms, 1, s.Description))
|
||||
.ThenByDescending(s => s.CreatedAt).Take(30).ToList();
|
||||
|
||||
var jq = _db.JobOpenings.Include(j => j.Facility).ThenInclude(f => f.City)
|
||||
.Include(j => j.Facility).ThenInclude(f => f.District).Include(j => j.Role)
|
||||
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCut);
|
||||
foreach (var t in terms)
|
||||
{
|
||||
var like = $"%{t}%";
|
||||
jq = jq.Where(j => EF.Functions.ILike(j.Title, like) || EF.Functions.ILike(j.Facility.Name, like)
|
||||
|| EF.Functions.ILike(j.Facility.City.Name, like) || EF.Functions.ILike(j.Role.Name, like)
|
||||
|| EF.Functions.ILike(j.Description ?? "", like));
|
||||
}
|
||||
var jobPool = await jq.OrderByDescending(j => j.CreatedAt).Take(60).ToListAsync();
|
||||
Jobs = jobPool
|
||||
.OrderByDescending(j => Rank(terms, 3, j.Title, j.Role?.Name, j.Facility?.Name, j.Facility?.City?.Name)
|
||||
+ Rank(terms, 1, j.Description))
|
||||
.ThenByDescending(j => j.CreatedAt).Take(30).ToList();
|
||||
|
||||
var tq = _db.TalentListings.Include(t => t.City).Include(t => t.District).Include(t => t.Role)
|
||||
.Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= talentCut);
|
||||
foreach (var t in terms)
|
||||
{
|
||||
var like = $"%{t}%";
|
||||
tq = tq.Where(x => EF.Functions.ILike(x.Tags ?? "", like) || EF.Functions.ILike(x.Description ?? "", like)
|
||||
|| EF.Functions.ILike(x.PersonName ?? "", like) || EF.Functions.ILike(x.AreaNote ?? "", like)
|
||||
|| EF.Functions.ILike(x.Role.Name, like) || EF.Functions.ILike(x.City.Name, like));
|
||||
}
|
||||
var talentPool = await tq.OrderByDescending(x => x.CreatedAt).Take(60).ToListAsync();
|
||||
Talent = talentPool
|
||||
.OrderByDescending(x => Rank(terms, 3, x.Role?.Name, x.City?.Name, x.PersonName, x.Tags)
|
||||
+ Rank(terms, 1, x.Description, x.AreaNote))
|
||||
.ThenByDescending(x => x.CreatedAt).Take(30).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Relevance score: +weight per term found in any of the given fields.</summary>
|
||||
private static int Rank(string[] terms, int weight, params string?[] fields)
|
||||
{
|
||||
var score = 0;
|
||||
foreach (var term in terms)
|
||||
foreach (var f in fields)
|
||||
if (!string.IsNullOrEmpty(f) && f.Contains(term, StringComparison.OrdinalIgnoreCase))
|
||||
{ score += weight; break; }
|
||||
return score;
|
||||
}
|
||||
}
|
||||
@@ -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,29 +10,40 @@
|
||||
string 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.SalaryMax is null) salary = "از " + JalaliDate.Toman(Model.SalaryMin) + " ماهانه"; // min only — avoid «تا توافقی»
|
||||
else salary = $"از {JalaliDate.ToPersianDigits((Model.SalaryMin ?? 0).ToString("#,0"))} تا {JalaliDate.Toman(Model.SalaryMax)} ماهانه";
|
||||
var q = ViewData["q"] as string;
|
||||
}
|
||||
<a class="card card-pad shift-card" asp-page="/Jobs/Details" asp-route-id="@Model.Id">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<span class="facility">@Model.Title</span>
|
||||
<span class="facility">@JobsMedical.Web.Services.SearchHighlight.Mark(Model.Title, q)</span>
|
||||
<span class="badge badge-job">@empLabel</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
@if (Model.Role is not null)
|
||||
{
|
||||
<span class="badge badge-type">@Model.Role.Name</span>
|
||||
<span class="badge badge-type">@JobsMedical.Web.Services.SearchHighlight.Mark(Model.Role.Name, q)</span>
|
||||
}
|
||||
@if (Model.GenderRequirement != Gender.Any)
|
||||
{
|
||||
<span class="badge badge-gender">@JalaliDate.GenderLabel(Model.GenderRequirement)</span>
|
||||
}
|
||||
<span>🏥 @Model.Facility?.Name</span>
|
||||
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(Model.Facility))
|
||||
{
|
||||
<span>🏥 @JobsMedical.Web.Services.SearchHighlight.Mark(Model.Facility?.Name, q)</span>
|
||||
}
|
||||
</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)
|
||||
{
|
||||
<div class="row"><span class="badge badge-distance">📍 @JalaliDate.ToPersianDigits(km.ToString("0.#")) کیلومتر از شما</span></div>
|
||||
}
|
||||
@{ var snip = JobsMedical.Web.Services.SearchHighlight.Snippet(Model.Description, q); }
|
||||
@if (snip.Value.Length > 0)
|
||||
{
|
||||
<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">
|
||||
<span class="pay">@salary</span>
|
||||
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
|
||||
|
||||
@@ -6,25 +6,71 @@
|
||||
var title = ViewData["Title"] as string;
|
||||
int unreadCount = 0;
|
||||
int meId = 0;
|
||||
string? meName = null;
|
||||
string? meFullName = null;
|
||||
string? mePhone = null;
|
||||
bool meHasAvatar = false;
|
||||
if (User.Identity?.IsAuthenticated == true && int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out meId))
|
||||
{
|
||||
unreadCount = await Notifications.UnreadCountAsync(meId);
|
||||
var info = await Db.Users.Where(u => u.Id == meId)
|
||||
.Select(u => new { u.FullName, u.Phone, HasAvatar = u.Avatar != null }).FirstOrDefaultAsync();
|
||||
meName = string.IsNullOrWhiteSpace(info?.FullName) ? info?.Phone : info!.FullName;
|
||||
meFullName = string.IsNullOrWhiteSpace(info?.FullName) ? null : info!.FullName!.Trim();
|
||||
mePhone = info?.Phone;
|
||||
meHasAvatar = info?.HasAvatar ?? false;
|
||||
}
|
||||
var meInitial = string.IsNullOrWhiteSpace(meName) ? "؟" : meName!.Trim().Substring(0, 1);
|
||||
// Avatar glyph/label: prefer a real name; never show a bare phone digit like "0".
|
||||
var meInitial = meFullName is not null ? meFullName.Substring(0, 1) : "👤";
|
||||
var meLabel = meFullName ?? "حساب من";
|
||||
|
||||
// Single, role-aware dashboard entry — the full menu lives in the panel sub-nav (_PanelNav).
|
||||
var dashUrl = "/Me/Index"; var dashLabel = "داشبورد من"; var dashIcon = "🗂️";
|
||||
if (User.IsInRole("Admin")) { dashUrl = "/Admin/Overview"; dashLabel = "پنل مدیریت"; dashIcon = "🛠️"; }
|
||||
else if (User.IsInRole("FacilityAdmin")) { dashUrl = "/Employer/Index"; dashLabel = "پنل کارفرما"; dashIcon = "🏥"; }
|
||||
|
||||
// --- SEO context ---
|
||||
var baseUrl = $"{Context.Request.Scheme}://{Context.Request.Host}";
|
||||
var path = Context.Request.Path.Value ?? "/";
|
||||
var canonical = baseUrl + (path == "/" ? "" : path); // canonical ignores query string
|
||||
var pageDesc = ViewData["Description"] as string
|
||||
?? "همکادر؛ سامانه یافتن شیفت و موقعیت استخدامی برای کادر درمان (پزشک، پرستار، ماما و تکنسین) در بیمارستانها و کلینیکهای تهران.";
|
||||
var pageTitle = title is null ? "همکادر | شیفت و استخدام کادر درمان" : title + " | همکادر";
|
||||
var ogImage = ViewData["OgImage"] as string ?? baseUrl + "/icons/icon-512.png";
|
||||
// Private/applicant areas must never be indexed.
|
||||
string[] noindexPrefixes = { "/Admin", "/Me", "/Employer", "/Account", "/Preferences" };
|
||||
var noIndex = (ViewData["NoIndex"] as bool? ?? false)
|
||||
|| noindexPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Show the centralized dashboard sub-nav on any logged-in panel page.
|
||||
string[] panelPrefixes = { "/Admin", "/Me", "/Employer", "/Preferences" };
|
||||
var showPanelNav = User.Identity?.IsAuthenticated == true
|
||||
&& panelPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
<!DOCTYPE html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@(title is null ? "همکادر | شیفت و استخدام کادر درمان" : title + " | همکادر")</title>
|
||||
<meta name="description" content="@(ViewData["Description"] as string ?? "همکادر؛ سامانه یافتن شیفت و موقعیت استخدامی برای کادر درمان (پزشک، پرستار، ماما و تکنسین) در بیمارستانها و کلینیکهای تهران.")" />
|
||||
<title>@pageTitle</title>
|
||||
<meta name="description" content="@pageDesc" />
|
||||
@if (noIndex)
|
||||
{
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<link rel="canonical" href="@canonical" />
|
||||
}
|
||||
@* Open Graph / Twitter — rich previews when shared in Telegram/Bale/etc. *@
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="همکادر" />
|
||||
<meta property="og:title" content="@pageTitle" />
|
||||
<meta property="og:description" content="@pageDesc" />
|
||||
<meta property="og:url" content="@canonical" />
|
||||
<meta property="og:image" content="@ogImage" />
|
||||
<meta property="og:locale" content="fa_IR" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="@pageTitle" />
|
||||
<meta name="twitter:description" content="@pageDesc" />
|
||||
@* Preload the body-weight font so the swap from Tahoma happens fast. Vazirmatn is
|
||||
self-hosted under wwwroot/fonts (@@font-face in site.css) — no external CDN. *@
|
||||
<link rel="preload" href="~/fonts/Vazirmatn-Regular.woff2" as="font" type="font/woff2" crossorigin />
|
||||
@@ -33,11 +79,15 @@
|
||||
@* PWA: installable app (Web/Windows/Android via this manifest; iOS via apple-* tags) *@
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#0e8f8a" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="همکادر" />
|
||||
@await RenderSectionAsync("Head", required: false)
|
||||
</head>
|
||||
<body data-unread="@unreadCount" data-authed="@(User.Identity?.IsAuthenticated == true ? "1" : "0")">
|
||||
<header class="site-header">
|
||||
@@ -59,17 +109,27 @@
|
||||
</label>
|
||||
|
||||
<div class="nav-collapse">
|
||||
@* Browse items only — personal ones (پیشنهادها/پسندیدهها) live in the profile menu. *@
|
||||
<nav class="main-nav">
|
||||
<a asp-page="/Index">خانه</a>
|
||||
<a asp-page="/Shifts/Index" data-tour="shifts">شیفتها</a>
|
||||
<a asp-page="/Jobs/Index" data-tour="jobs">استخدام</a>
|
||||
<a asp-page="/Calendar/Index">تقویم هفتگی</a>
|
||||
<a asp-page="/Download">دریافت اپ</a>
|
||||
<a asp-page="/Facilities/Index">مراکز درمانی</a>
|
||||
<a asp-page="/Preferences/Index" data-tour="prefs">علاقهمندیها</a>
|
||||
<a asp-page="/Help" data-tour="help">راهنما</a>
|
||||
<a asp-page="/Index" class="@(path == "/" ? "active" : null)">خانه</a>
|
||||
<a href="/Jobs" 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>
|
||||
@if (User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
<a asp-page="/Recommendations/Index" class="@(path.StartsWith("/Recommendations") ? "active" : null)">✨ پیشنهادها</a>
|
||||
}
|
||||
<details class="nav-more">
|
||||
<summary class="@(path.StartsWith("/Facilities") || path.StartsWith("/Calendar") ? "active" : null)">بیشتر ▾</summary>
|
||||
<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>
|
||||
</details>
|
||||
<a asp-page="/Search" class="nav-search-link @(path.StartsWith("/Search") ? "active" : null)">🔎 جستجو</a>
|
||||
</nav>
|
||||
<div class="header-actions">
|
||||
<a class="btn btn-accent btn-sm cta-post" asp-page="/Employer/Index" data-tour="post">+ ثبت آگهی</a>
|
||||
@if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
<a class="nav-action bell-inline js-bell" asp-page="/Me/Notifications" title="اعلانها" data-tour="bell"><span class="bell-ico">🔔</span><span class="bell-label">اعلانها</span>@if (unreadCount > 0) {<span class="bell-badge">@JalaliDate.ToPersianDigits(unreadCount > 99 ? "99+" : unreadCount.ToString())</span>}</a>
|
||||
@@ -85,25 +145,33 @@
|
||||
{
|
||||
<span class="avatar-fallback">@meInitial</span>
|
||||
}
|
||||
<span class="avatar-name">@meLabel</span>
|
||||
<span class="avatar-caret">▾</span>
|
||||
</label>
|
||||
<nav class="profile-dropdown">
|
||||
<div class="pd-head">@meName</div>
|
||||
<a asp-page="/Me/Profile">👤 ویرایش پروفایل</a>
|
||||
<a asp-page="/Me/Index" data-tour="panel">🗂️ پنل کارجو</a>
|
||||
<a asp-page="/Me/Alerts">🔎 هشدارهای شغلی</a>
|
||||
<a asp-page="/Preferences/Index">⭐ علاقهمندیها</a>
|
||||
<a asp-page="/Me/Notifications">🔔 اعلانها@if (unreadCount > 0) {<span class="bell-badge" style="position:static; margin-inline-start:6px;">@JalaliDate.ToPersianDigits(unreadCount > 99 ? "99+" : unreadCount.ToString())</span>}</a>
|
||||
@if (User.IsInRole("FacilityAdmin"))
|
||||
<div class="pd-id">
|
||||
@if (meHasAvatar)
|
||||
{
|
||||
<a asp-page="/Employer/Index">🏥 پنل کارفرما</a>
|
||||
<img class="avatar-img" src="/avatar/@meId" alt="" />
|
||||
}
|
||||
@if (User.IsInRole("Admin"))
|
||||
else
|
||||
{
|
||||
<span class="avatar-fallback">@meInitial</span>
|
||||
}
|
||||
<div class="pd-id-text">
|
||||
<strong>@(meFullName ?? "کاربر همکادر")</strong>
|
||||
@if (mePhone is not null)
|
||||
{
|
||||
<span class="muted" dir="ltr">@mePhone</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pd-sep"></div>
|
||||
<a asp-page="/Admin/Overview">🛠️ پنل مدیریت</a>
|
||||
<a asp-page="/Admin/Settings">⚙️ تنظیمات</a>
|
||||
}
|
||||
<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 asp-page="/Me/Profile">👤 ویرایش پروفایل</a>
|
||||
<div class="pd-sep"></div>
|
||||
<form method="post" asp-page="/Account/Logout">
|
||||
<button type="submit" class="pd-logout">🚪 خروج</button>
|
||||
@@ -121,6 +189,10 @@
|
||||
</header>
|
||||
|
||||
<main role="main">
|
||||
@if (showPanelNav)
|
||||
{
|
||||
<partial name="_PanelNav" />
|
||||
}
|
||||
@RenderBody()
|
||||
</main>
|
||||
|
||||
@@ -149,7 +221,16 @@
|
||||
@* Register the PWA service worker (offline + push notifications). *@
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () { navigator.serviceWorker.register('/sw.js').catch(function () {}); });
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/sw.js').then(function (reg) {
|
||||
reg.update(); // always check for a newer worker so fixes reach clients fast
|
||||
// When a new worker takes control, reload once so stale cached pages are dropped.
|
||||
var refreshed = false;
|
||||
navigator.serviceWorker.addEventListener('controllerchange', function () {
|
||||
if (refreshed) return; refreshed = true; location.reload();
|
||||
});
|
||||
}).catch(function () {});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -161,7 +242,147 @@
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = document.getElementById('profile-toggle');
|
||||
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>
|
||||
|
||||
@* Instant search suggestions (typeahead) — attaches to every form[data-suggest]
|
||||
(header pill + homepage hero). *@
|
||||
<script>
|
||||
(function () {
|
||||
function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
|
||||
function hi(text, q) {
|
||||
var safe = esc(text);
|
||||
var terms = q.split(/\s+/).filter(function (t) { return t.length >= 2; })
|
||||
.map(function (t) { return t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); });
|
||||
if (!terms.length) return safe;
|
||||
try { return safe.replace(new RegExp('(' + terms.join('|') + ')', 'gi'), '<mark>$1</mark>'); }
|
||||
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 = ''; }
|
||||
input.addEventListener('input', function () {
|
||||
var q = input.value.trim();
|
||||
clearTimeout(timer);
|
||||
if (q.length < 2) { hide(); return; }
|
||||
timer = setTimeout(function () {
|
||||
fetch('/search/suggest?q=' + encodeURIComponent(q))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
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 sub = it.sub ? '<span class="ns-sub">' + hi(it.sub, q) + '</span>' : '';
|
||||
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>' + sub + '</span></a>';
|
||||
}).join('');
|
||||
html += '<a class="ns-all" href="/Search?Q=' + encodeURIComponent(q) + '">مشاهده همه ' + fa(total) + ' نتیجه برای «' + esc(q) + '» ←</a>';
|
||||
box.innerHTML = html;
|
||||
box.style.display = 'block';
|
||||
}).catch(function () { hide(); });
|
||||
}, 200);
|
||||
});
|
||||
document.addEventListener('click', function (e) { if (!form.contains(e.target)) 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>
|
||||
|
||||
@* Live in-app notifications over SSE (our own origin — works in Iran, no Google push).
|
||||
|
||||
@@ -4,18 +4,25 @@
|
||||
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.
|
||||
*@
|
||||
<link rel="stylesheet" href="https://static.neshan.org/sdk/leaflet/1.4.0/neshan-sdk/v1.0.8/index.css" />
|
||||
<script src="https://static.neshan.org/sdk/leaflet/1.4.0/neshan-sdk/v1.0.8/index.js"></script>
|
||||
<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/leaflet.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var el = document.getElementById('facmap');
|
||||
if (!el || !window.L) return;
|
||||
var lat = parseFloat(el.dataset.lat), lng = parseFloat(el.dataset.lng);
|
||||
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', {
|
||||
key: '@Model', maptype: 'neshan', poi: true, traffic: false,
|
||||
center: [lat, lng], zoom: 15
|
||||
key: '@Model', maptype: 'neshan', poi: !approx, traffic: false,
|
||||
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);
|
||||
}
|
||||
})();
|
||||
</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>
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
@using System.Security.Claims
|
||||
@*
|
||||
Dashboard sub-menu for logged-in panels (admin / employer / job-seeker).
|
||||
Render right under the page header on panel pages: <partial name="_PanelNav" />
|
||||
*@
|
||||
@{
|
||||
var p = ViewContext.HttpContext.Request.Path.Value ?? "";
|
||||
bool On(string path) => p.Equals(path, StringComparison.OrdinalIgnoreCase)
|
||||
|| p.StartsWith(path + "/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
<nav class="panel-nav">
|
||||
<div class="container panel-nav-inner">
|
||||
@if (User.IsInRole("Admin"))
|
||||
{
|
||||
<a class="@(On("/Admin/Overview") ? "active" : null)" asp-page="/Admin/Overview">📊 داشبورد</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/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/Reports") ? "active" : null)" asp-page="/Admin/Reports">🛡️ گزارشها</a>
|
||||
<a class="@(On("/Admin/Broadcast") ? "active" : null)" asp-page="/Admin/Broadcast">📣 اعلان همگانی</a>
|
||||
<a class="@(On("/Admin/Social") ? "active" : null)" asp-page="/Admin/Social">📡 شبکههای اجتماعی</a>
|
||||
<a class="@(On("/Admin/Settings") ? "active" : null)" asp-page="/Admin/Settings">⚙️ تنظیمات</a>
|
||||
}
|
||||
else if (User.IsInRole("FacilityAdmin"))
|
||||
{
|
||||
<a class="@(On("/Employer/Index") ? "active" : null)" asp-page="/Employer/Index">🏥 داشبورد مرکز</a>
|
||||
<a class="@(On("/Employer/PostShift") ? "active" : null)" asp-page="/Employer/PostShift">+ ثبت شیفت</a>
|
||||
<a class="@(On("/Employer/PostJob") ? "active" : null)" asp-page="/Employer/PostJob">+ ثبت استخدام</a>
|
||||
<a class="@(On("/Me/Index") ? "active" : null)" asp-page="/Me/Index">🗂️ پنل کارجو</a>
|
||||
<a class="@(On("/Me/Profile") ? "active" : null)" asp-page="/Me/Profile">👤 پروفایل</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a class="@(On("/Me/Index") ? "active" : null)" asp-page="/Me/Index">🏠 داشبورد</a>
|
||||
<a class="@(On("/Me/Alerts") ? "active" : null)" asp-page="/Me/Alerts">🔎 هشدارهای شغلی</a>
|
||||
<a class="@(On("/Preferences/Index") ? "active" : null)" asp-page="/Preferences/Index">⭐ علاقهمندیها</a>
|
||||
<a class="@(On("/Me/Notifications") ? "active" : null)" asp-page="/Me/Notifications">🔔 اعلانها</a>
|
||||
<a class="@(On("/Me/Profile") ? "active" : null)" asp-page="/Me/Profile">👤 پروفایل</a>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
@@ -8,7 +8,7 @@
|
||||
<strong>@Model.SourceChannel</strong>
|
||||
<span style="display:flex; gap:8px; align-items:center;">
|
||||
<span class="badge @confClass">اطمینان @JalaliDate.ToPersianDigits(c.ToString())٪</span>
|
||||
<span class="muted" style="font-size:12px;">@JalaliDate.ToLongDate(DateOnly.FromDateTime(Model.FetchedAt))</span>
|
||||
<span class="muted" style="font-size:12px;">@JalaliDate.DateTimeLabel(Model.FetchedAt)</span>
|
||||
</span>
|
||||
</div>
|
||||
<p style="margin:10px 0; white-space:pre-wrap;">@Model.RawText</p>
|
||||
@@ -16,5 +16,11 @@
|
||||
{
|
||||
<p class="muted" style="font-size:12.5px; margin:0 0 10px;">⚠ @Model.ValidationNotes</p>
|
||||
}
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<a class="btn btn-accent" asp-page="/Admin/Review" asp-route-id="@Model.Id">بررسی و انتشار ←</a>
|
||||
<form method="post" asp-page="/Admin/Index" asp-page-handler="QuickDiscard" asp-route-id="@Model.Id"
|
||||
onsubmit="return confirm('این آگهی رد و کنار گذاشته شود؟');">
|
||||
<button type="submit" class="btn btn-outline" style="color:var(--danger); border-color:var(--danger);">✕ رد</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
@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
|
||||
{
|
||||
ShiftType.Day => ("badge-day", "صبح"),
|
||||
@@ -8,25 +30,31 @@
|
||||
ShiftType.Night => ("badge-night", "شب"),
|
||||
_ => ("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>
|
||||
}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@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>
|
||||
<partial name="_HourBar" model="s" />
|
||||
}
|
||||
|
||||
@* The "why" — what makes a pattern engine trustworthy: every pick is explained. *@
|
||||
<div class="rec-reasons">
|
||||
@@ -37,7 +65,17 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -7,17 +7,14 @@
|
||||
ShiftType.Night => ("badge-night", "شب"),
|
||||
_ => ("badge-oncall", "آنکال"),
|
||||
};
|
||||
var q = ViewData["q"] as string;
|
||||
}
|
||||
<a class="card card-pad shift-card" asp-page="/Shifts/Details" asp-route-id="@Model.Id">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<span class="facility">@Model.Facility?.Name</span>
|
||||
<span class="facility">@JobsMedical.Web.Services.SearchHighlight.Mark(Model.Role?.Name ?? "شیفت", q)</span>
|
||||
<span class="badge @badgeClass">@typeLabel</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
@if (Model.Role is not null)
|
||||
{
|
||||
<span class="badge badge-type">@Model.Role.Name</span>
|
||||
}
|
||||
@if (Model.GenderRequirement != Gender.Any)
|
||||
{
|
||||
<span class="badge badge-gender">@JalaliDate.GenderLabel(Model.GenderRequirement)</span>
|
||||
@@ -26,6 +23,10 @@
|
||||
{
|
||||
<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 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)
|
||||
@@ -34,7 +35,13 @@
|
||||
}
|
||||
<div class="row">📅 @JalaliDate.WeekDayName(Model.Date)، @JalaliDate.ToLongDate(Model.Date)</div>
|
||||
<div class="row">🕐 @JalaliDate.Time(Model.StartTime) تا @JalaliDate.Time(Model.EndTime)</div>
|
||||
@{ var snip = JobsMedical.Web.Services.SearchHighlight.Snippet(Model.Description, q); }
|
||||
@if (snip.Value.Length > 0)
|
||||
{
|
||||
<div class="search-snippet">@snip</div>
|
||||
}
|
||||
<partial name="_HourBar" model="Model" />
|
||||
<div class="row muted" style="font-size:12px; margin-top:6px;">🕒 @JalaliDate.TimeAgo(Model.CreatedAt)</div>
|
||||
<div class="foot">
|
||||
<span class="pay">@JalaliDate.PayLabel(Model.PayType, Model.PayAmount, Model.SharePercent)</span>
|
||||
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
@model JobsMedical.Web.Models.TalentListing
|
||||
@{
|
||||
string comp;
|
||||
if (Model.PayType == JobsMedical.Web.Models.PayType.Percentage && Model.SharePercent is int sp)
|
||||
comp = $"{JalaliDate.ToPersianDigits(sp.ToString())}٪ سهم درآمد";
|
||||
else if (Model.PayAmount is long pa && pa > 0)
|
||||
comp = JalaliDate.Toman(pa) + " مدنظر";
|
||||
else
|
||||
comp = "توافقی";
|
||||
|
||||
var heading = string.IsNullOrWhiteSpace(Model.PersonName)
|
||||
? (Model.Role?.Name ?? "آماده به کار")
|
||||
: Model.PersonName!;
|
||||
var area = Model.District?.Name ?? Model.AreaNote;
|
||||
var q = ViewData["q"] as string;
|
||||
var tags = (Model.Tags ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(t => t != Model.Role?.Name && t != Model.City?.Name).Distinct().Take(6).ToList();
|
||||
}
|
||||
<a class="card card-pad shift-card" asp-page="/Talent/Details" asp-route-id="@Model.Id">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<span class="facility">@JobsMedical.Web.Services.SearchHighlight.Mark(heading, q)</span>
|
||||
<span class="badge badge-talent">آماده به کار</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
@if (Model.Role is not null)
|
||||
{
|
||||
<span class="badge badge-type">@Model.Role.Name</span>
|
||||
}
|
||||
@if (Model.YearsExperience is int yrs && yrs > 0)
|
||||
{
|
||||
<span class="badge badge-day">@JalaliDate.ToPersianDigits(yrs.ToString()) سال سابقه</span>
|
||||
}
|
||||
@if (Model.IsLicensed)
|
||||
{
|
||||
<span class="badge badge-verified">پروانهدار</span>
|
||||
}
|
||||
@if (Model.Gender != JobsMedical.Web.Models.Gender.Any)
|
||||
{
|
||||
<span class="badge badge-gender">@JalaliDate.GenderLabel(Model.Gender)</span>
|
||||
}
|
||||
</div>
|
||||
<div class="row">📍 @Model.City?.Name@(area is not null ? "، " + area : "")</div>
|
||||
@{ var snip = JobsMedical.Web.Services.SearchHighlight.Snippet(Model.Description ?? Model.Tags, q); }
|
||||
@if (snip.Value.Length > 0)
|
||||
{
|
||||
<div class="search-snippet">@snip</div>
|
||||
}
|
||||
@if (tags.Count > 0)
|
||||
{
|
||||
<div class="tag-chips">
|
||||
@foreach (var tg in tags)
|
||||
{
|
||||
<span class="tag-chip">@JobsMedical.Web.Services.SearchHighlight.Mark(tg, q)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="row muted" style="font-size:12px; margin-top:6px;">🕒 @JalaliDate.TimeAgo(Model.CreatedAt)</div>
|
||||
<div class="foot">
|
||||
<span class="pay">@comp</span>
|
||||
<span class="btn btn-outline" style="padding: 6px 14px;">مشاهده و تماس</span>
|
||||
</div>
|
||||
</a>
|
||||
@@ -3,8 +3,20 @@
|
||||
@{
|
||||
var s = Model.Shift!;
|
||||
var f = s.Facility!;
|
||||
ViewData["Title"] = $"شیفت {s.SpecialtyRequired} - {f.Name}";
|
||||
ViewData["Description"] = $"شیفت {s.SpecialtyRequired} در {f.Name}، {f.City?.Name}، تاریخ {JalaliDate.ToLongDate(s.Date)} از ساعت {JalaliDate.Time(s.StartTime)}.";
|
||||
var hasFac = JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f); // false for the «نامشخص» placeholder
|
||||
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.
|
||||
if (s.Status != JobsMedical.Web.Models.ShiftStatus.Open || s.Date < DateOnly.FromDateTime(DateTime.UtcNow))
|
||||
ViewData["NoIndex"] = true;
|
||||
var (badgeClass, typeLabel) = s.ShiftType switch
|
||||
{
|
||||
ShiftType.Day => ("badge-day", "شیفت صبح"),
|
||||
@@ -12,10 +24,14 @@
|
||||
ShiftType.Night => ("badge-night", "شیفت شب"),
|
||||
_ => ("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="container">
|
||||
<partial name="_Breadcrumbs" model="crumbs" />
|
||||
<div class="row" style="display:flex; gap:10px; align-items:center;">
|
||||
<span class="badge @badgeClass">@typeLabel</span>
|
||||
@if (f.IsVerified)
|
||||
@@ -23,7 +39,7 @@
|
||||
<span class="badge badge-verified">✓ مرکز تأیید شده</span>
|
||||
}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,12 +49,33 @@
|
||||
<div>
|
||||
@if (Model.ShowContact)
|
||||
{
|
||||
<div class="alert alert-success">
|
||||
✓ تمایل شما ثبت شد. برای هماهنگی شیفت با مرکز درمانی تماس بگیرید:
|
||||
<strong>@(f.Phone ?? "شماره ثبت نشده")</strong>
|
||||
<div class="contact-reveal" style="margin-bottom:16px;">
|
||||
<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))
|
||||
{
|
||||
<div class="contact-row">
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(f.BaleId))
|
||||
{
|
||||
<text> — بله: @f.BaleId</text>
|
||||
<div class="contact-row">
|
||||
<span class="c-meta"><span class="c-type">💬 بله</span><span class="c-val" dir="ltr">@f.BaleId</span></span>
|
||||
<a class="btn btn-outline" href="https://ble.ir/@f.BaleId" target="_blank" rel="noopener">باز کردن</a>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted" style="margin:0;">شمارهای ثبت نشده است.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -68,7 +105,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.MoreAtFacility.Count > 0)
|
||||
@if (hasFac && Model.MoreAtFacility.Count > 0)
|
||||
{
|
||||
<h3 style="margin:26px 0 14px;">شیفتهای دیگر این مرکز</h3>
|
||||
<div class="grid grid-3">
|
||||
@@ -94,22 +131,15 @@
|
||||
<div class="alert alert-success" style="margin-bottom:12px;">✓ این فرصت ذخیره شد و در پیشنهادهای شما لحاظ میشود.</div>
|
||||
}
|
||||
<div class="aside-apply">
|
||||
<form method="post">
|
||||
<button type="submit" asp-page-handler="Interest" asp-route-id="@s.Id"
|
||||
class="btn btn-accent btn-block btn-lg">اعلام تمایل و مشاهده راه ارتباطی</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-accent btn-block btn-lg contact-trigger"
|
||||
data-contact-type="shift" data-contact-id="@s.Id">📞 اعلام تمایل و مشاهده راه ارتباطی</button>
|
||||
<p class="muted center" style="font-size:12px; margin:8px 0;">با اعلام تمایل، اطلاعات تماس مرکز نمایش داده میشود.</p>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<form method="post" style="flex:1;">
|
||||
<button type="submit" asp-page-handler="Save" asp-route-id="@s.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="@s.Id"
|
||||
class="btn btn-outline btn-block">✕ علاقهمند نیستم</button>
|
||||
</form>
|
||||
</div>
|
||||
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-block like-trigger"
|
||||
data-like-type="shift" data-like-id="@s.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)
|
||||
{
|
||||
<p class="muted" style="font-size:12px; margin:8px 0 0;">✓ گزارش شما ثبت شد. متشکریم.</p>
|
||||
@@ -128,7 +158,7 @@
|
||||
</form>
|
||||
</details>
|
||||
<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;">
|
||||
<input type="hidden" name="targetType" value="Facility" />
|
||||
<input type="hidden" name="targetId" value="@f.Id" />
|
||||
@@ -143,13 +173,13 @@
|
||||
|
||||
<div class="card card-pad" style="margin-top:16px;">
|
||||
<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 lngS = f.Lng.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var latS = mapLat.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var lngS = mapLng.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
@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
|
||||
{
|
||||
@@ -157,12 +187,16 @@
|
||||
🗺️<br /><small class="muted" dir="ltr">@latS، @lngS</small>
|
||||
</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"
|
||||
href="https://neshan.org/maps/@(latS),@(lngS),16z">مسیریابی در نشان</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted" style="margin:0;">مختصات این مرکز هنوز ثبت نشده است.</p>
|
||||
<p class="muted" style="margin:0;">مختصات این آگهی ثبت نشده است.</p>
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
@@ -171,29 +205,25 @@
|
||||
|
||||
@* Sticky bottom action bar — mobile only. Always-reachable primary action (native-app feel). *@
|
||||
<div class="mobile-action-bar">
|
||||
@if (Model.ShowContact)
|
||||
{
|
||||
@if (!string.IsNullOrEmpty(f.Phone))
|
||||
{
|
||||
<a class="btn btn-accent btn-lg cta-main" href="tel:@f.Phone">📞 تماس با مرکز</a>
|
||||
}
|
||||
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>
|
||||
}
|
||||
<button type="button" class="btn btn-accent btn-lg cta-main contact-trigger"
|
||||
data-contact-type="shift" data-contact-id="@s.Id">📞 اعلام تمایل و مشاهده تماس</button>
|
||||
<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")">
|
||||
<span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> <span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
|
||||
</button>
|
||||
</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" />
|
||||
}
|
||||
|
||||
@section Head {
|
||||
@{ 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>")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ public class DetailsModel : PageModel
|
||||
public Shift? Shift { get; private set; }
|
||||
public List<Shift> MoreAtFacility { get; private set; } = new();
|
||||
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).
|
||||
public bool ShowContact { get; private set; }
|
||||
@@ -34,7 +36,13 @@ public class DetailsModel : PageModel
|
||||
{
|
||||
await LoadAsync(id);
|
||||
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;
|
||||
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";
|
||||
await _interest.LogAsync(InterestEventType.View, id); // behavioral signal for recommendations
|
||||
return Page();
|
||||
@@ -69,6 +77,7 @@ public class DetailsModel : PageModel
|
||||
Shift = await _db.Shifts
|
||||
.Include(s => s.Facility).ThenInclude(f => f.City)
|
||||
.Include(s => s.Role)
|
||||
.Include(s => s.Contacts)
|
||||
.FirstOrDefaultAsync(s => s.Id == id);
|
||||
|
||||
if (Shift is not null)
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
@page
|
||||
@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="container">
|
||||
<h1>شیفتهای موجود</h1>
|
||||
<partial name="_Breadcrumbs" model="Model.Breadcrumbs" />
|
||||
<h1>@Model.PageHeading</h1>
|
||||
<p class="muted">
|
||||
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) شیفت باز پیدا شد
|
||||
@JalaliDate.ToPersianDigits(Model.TotalCount.ToString()) شیفت باز پیدا شد
|
||||
@if (Model.NearMeActive)
|
||||
{
|
||||
<span> — مرتبشده بر اساس نزدیکترین به شما 📍</span>
|
||||
}
|
||||
</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 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">
|
||||
<aside class="card card-pad filter-card">
|
||||
<h3>فیلترها</h3>
|
||||
@@ -129,6 +145,7 @@
|
||||
<partial name="_ShiftCard" model="s" />
|
||||
}
|
||||
</div>
|
||||
<partial name="_Pager" model="(Model.CurrentPage, Model.TotalPages)" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,3 +175,12 @@
|
||||
}
|
||||
</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? 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 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<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);
|
||||
|
||||
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();
|
||||
|
||||
// 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
|
||||
.Where(d => d.IsActive && (CityId == null || d.CityId == CityId))
|
||||
.OrderBy(d => d.Name).ToListAsync();
|
||||
@@ -63,24 +97,53 @@ public class IndexModel : PageModel
|
||||
if (GenderFilter is Gender g && g != Gender.Any)
|
||||
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)
|
||||
{
|
||||
// Compute distance to each facility, then nearest-first (shifts without coords last).
|
||||
foreach (var s in results)
|
||||
{
|
||||
// Distance sort needs all rows in memory; paginate after sorting (shifts without coords last).
|
||||
var all = await q.ToListAsync();
|
||||
foreach (var s in all)
|
||||
if (s.Facility.Lat is double flat && s.Facility.Lng is double flng)
|
||||
s.DistanceKm = Geo.DistanceKm(Lat!.Value, Lng!.Value, flat, flng);
|
||||
}
|
||||
Results = results
|
||||
Results = all
|
||||
.OrderBy(s => s.DistanceKm ?? double.MaxValue)
|
||||
.ThenBy(s => s.Date).ThenBy(s => s.StartTime)
|
||||
.ToList();
|
||||
.Skip(skip).Take(PageSize).ToList();
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
@page "{id:int}"
|
||||
@model JobsMedical.Web.Pages.Talent.DetailsModel
|
||||
@{
|
||||
var t = Model.Item!;
|
||||
var heading = string.IsNullOrWhiteSpace(t.PersonName) ? (t.Role?.Name ?? "آماده به کار") : t.PersonName!;
|
||||
ViewData["Title"] = $"{heading} — آماده به کار";
|
||||
// Personal contact info: keep this page out of search indexes.
|
||||
ViewData["NoIndex"] = true;
|
||||
string comp;
|
||||
if (t.PayType == JobsMedical.Web.Models.PayType.Percentage && t.SharePercent is int sp)
|
||||
comp = $"{JalaliDate.ToPersianDigits(sp.ToString())}٪ سهم درآمد";
|
||||
else if (t.PayAmount is long pa && pa > 0)
|
||||
comp = JalaliDate.Toman(pa) + " مدنظر";
|
||||
else
|
||||
comp = "توافقی";
|
||||
}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="container">
|
||||
<h1>@heading</h1>
|
||||
<p class="muted">آماده همکاری @(t.Role is not null ? "— " + t.Role.Name : "") · 📍 @t.City?.Name@(t.District?.Name is not null ? "، " + t.District.Name : (t.AreaNote is not null ? "، " + t.AreaNote : ""))</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container section">
|
||||
<div class="detail-grid">
|
||||
<div>
|
||||
<div class="card card-pad">
|
||||
<div class="row" style="gap:8px; flex-wrap:wrap;">
|
||||
@if (t.Role is not null) { <span class="badge badge-type">@t.Role.Name</span> }
|
||||
<span class="badge badge-talent">آماده به کار</span>
|
||||
@if (t.YearsExperience is int yrs && yrs > 0) { <span class="badge badge-day">@JalaliDate.ToPersianDigits(yrs.ToString()) سال سابقه</span> }
|
||||
@if (t.IsLicensed) { <span class="badge badge-verified">پروانهدار</span> }
|
||||
@if (t.Gender != JobsMedical.Web.Models.Gender.Any) { <span class="badge badge-gender">@JalaliDate.GenderLabel(t.Gender)</span> }
|
||||
@if (t.Availability is JobsMedical.Web.Models.EmploymentType emp)
|
||||
{
|
||||
<span class="badge badge-job">@(emp switch {
|
||||
JobsMedical.Web.Models.EmploymentType.FullTime => "تماموقت",
|
||||
JobsMedical.Web.Models.EmploymentType.PartTime => "پارهوقت",
|
||||
JobsMedical.Web.Models.EmploymentType.Contract => "قراردادی",
|
||||
_ => "طرح" })</span>
|
||||
}
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(t.AreaNote))
|
||||
{
|
||||
<p style="margin:12px 0 0;"><strong>محدوده کاری:</strong> @t.AreaNote</p>
|
||||
}
|
||||
<p style="margin:12px 0 0;"><strong>دستمزد مدنظر:</strong> @comp</p>
|
||||
@if (!string.IsNullOrWhiteSpace(t.Description))
|
||||
{
|
||||
<hr style="border:none; border-top:1px solid var(--line); margin:16px 0;" />
|
||||
<p style="white-space:pre-wrap; margin:0;">@t.Description</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside>
|
||||
<div class="card card-pad">
|
||||
<h3 style="margin-top:0;">راههای ارتباطی</h3>
|
||||
<button type="button" class="btn btn-accent btn-block btn-lg contact-trigger"
|
||||
data-contact-type="talent" data-contact-id="@t.Id">📞 مشاهده راههای ارتباطی</button>
|
||||
<p class="muted" style="font-size:12px; margin:10px 0 0;">با کلیک، شماره تماس و راههای ارتباطی نمایش داده میشود.</p>
|
||||
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-block like-trigger" style="margin-top:10px;"
|
||||
data-like-type="talent" data-like-id="@t.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>
|
||||
</div>
|
||||
|
||||
@if (t.Lat is not null && t.Lng is not null)
|
||||
{
|
||||
var latS = t.Lat.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var lngS = t.Lng.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
<div class="card card-pad" style="margin-top:16px;">
|
||||
<h3 style="margin-top:0;">موقعیت تقریبی</h3>
|
||||
@if (!string.IsNullOrEmpty(Model.MapKey))
|
||||
{
|
||||
<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>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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>
|
||||
}
|
||||
<p class="muted" style="font-size:12px; margin:8px 0 0;">📍 محدودهٔ تقریبیِ فعالیت (برگرفته از آگهی منبع)؛ موقعیت دقیق نیست.</p>
|
||||
</div>
|
||||
}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.MapKey) && t.Lat is not null)
|
||||
{
|
||||
<partial name="_NeshanMap" model="Model.MapKey" />
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using JobsMedical.Web.Data;
|
||||
using JobsMedical.Web.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobsMedical.Web.Pages.Talent;
|
||||
|
||||
public class DetailsModel : PageModel
|
||||
{
|
||||
private readonly AppDbContext _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 string? MapKey { get; private set; }
|
||||
public int LikeCount { get; private set; }
|
||||
public bool IsLiked { get; private set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(int id)
|
||||
{
|
||||
Item = await _db.TalentListings
|
||||
.Include(t => t.City)
|
||||
.Include(t => t.District)
|
||||
.Include(t => t.Role)
|
||||
.Include(t => t.Contacts)
|
||||
.FirstOrDefaultAsync(t => t.Id == id);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
@page
|
||||
@model JobsMedical.Web.Pages.Talent.IndexModel
|
||||
@{
|
||||
// Title/description are set in the page model (from the active role/city).
|
||||
ViewData["q"] = Model.Q; // drives result highlighting in cards
|
||||
}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="container">
|
||||
<h1>@Model.PageHeading</h1>
|
||||
<p class="muted">
|
||||
@JalaliDate.ToPersianDigits(Model.TotalCount.ToString()) نیروی کادر درمان آمادهی همکاری —
|
||||
مراکز درمانی میتوانند مستقیم تماس بگیرند.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container section">
|
||||
<div class="layout-2">
|
||||
<aside class="card card-pad filter-card">
|
||||
<h3>فیلترها</h3>
|
||||
<form method="get" id="filterForm">
|
||||
<div class="filter-group">
|
||||
<label>جستجوی عمیق</label>
|
||||
<div style="display:flex; gap:6px;">
|
||||
<input type="search" name="Q" value="@Model.Q" placeholder="مثلاً mmt پروانهدار تهران" style="flex:1;" />
|
||||
<button type="submit" class="btn btn-accent" style="padding:0 14px;">🔎</button>
|
||||
</div>
|
||||
<p class="muted" style="font-size:11px; margin:4px 0 0;">روی متن، تگها، نقش، شهر و نام جستجو میکند.</p>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>شهر</label>
|
||||
<select name="CityId" onchange="this.form.submit()">
|
||||
<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="DistrictId" onchange="this.form.submit()">
|
||||
<option value="">همه محلهها</option>
|
||||
@foreach (var d in Model.Districts)
|
||||
{
|
||||
<option value="@d.Id" selected="@(Model.DistrictId == d.Id)">@d.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>نقش / رشته</label>
|
||||
<select name="RoleId" onchange="this.form.submit()">
|
||||
<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="GenderFilter" onchange="this.form.submit()">
|
||||
<option value="">فرقی نمیکند</option>
|
||||
<option value="1" selected="@(Model.GenderFilter == JobsMedical.Web.Models.Gender.Male)">آقا</option>
|
||||
<option value="2" selected="@(Model.GenderFilter == JobsMedical.Web.Models.Gender.Female)">خانم</option>
|
||||
</select>
|
||||
</div>
|
||||
<a asp-page="/Talent/Index" class="btn btn-outline btn-block">حذف فیلترها</a>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<div>
|
||||
@if (Model.Results.Count == 0)
|
||||
{
|
||||
<div class="card empty-state">نیرویی با این فیلترها پیدا نشد.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="grid grid-3">
|
||||
@foreach (var t in Model.Results)
|
||||
{
|
||||
<partial name="_TalentCard" model="t" />
|
||||
}
|
||||
</div>
|
||||
<partial name="_Pager" model="(Model.CurrentPage, Model.TotalPages)" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,84 @@
|
||||
using JobsMedical.Web.Data;
|
||||
using JobsMedical.Web.Models;
|
||||
using JobsMedical.Web.Services.Scraping;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobsMedical.Web.Pages.Talent;
|
||||
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
public IndexModel(AppDbContext db) => _db = db;
|
||||
|
||||
[BindProperty(SupportsGet = true)] public int? CityId { get; set; }
|
||||
[BindProperty(SupportsGet = true)] public int? DistrictId { get; set; }
|
||||
[BindProperty(SupportsGet = true)] public int? RoleId { get; set; }
|
||||
[BindProperty(SupportsGet = true)] public Gender? GenderFilter { get; set; }
|
||||
[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<City> Cities { get; private set; } = new();
|
||||
public List<District> Districts { 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()
|
||||
{
|
||||
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();
|
||||
Districts = await _db.Districts
|
||||
.Where(d => d.IsActive && (CityId == null || d.CityId == CityId))
|
||||
.OrderBy(d => d.Name).ToListAsync();
|
||||
|
||||
var q = _db.TalentListings
|
||||
.Include(t => t.City)
|
||||
.Include(t => t.District)
|
||||
.Include(t => t.Role)
|
||||
.Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= ListingPolicy.TalentCutoffUtc);
|
||||
|
||||
if (CityId is not null) q = q.Where(t => t.CityId == CityId);
|
||||
if (DistrictId is not null) q = q.Where(t => t.DistrictId == DistrictId);
|
||||
if (RoleId is not null) q = q.Where(t => t.RoleId == RoleId);
|
||||
if (GenderFilter is Gender g && g != Gender.Any)
|
||||
q = q.Where(t => t.Gender == Gender.Any || t.Gender == g);
|
||||
|
||||
// Deep search: every term must match somewhere (tags, role, city, person, area, description).
|
||||
if (!string.IsNullOrWhiteSpace(Q))
|
||||
foreach (var term in Q.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var like = $"%{term}%";
|
||||
q = q.Where(t =>
|
||||
EF.Functions.ILike(t.Tags ?? "", like) ||
|
||||
EF.Functions.ILike(t.Description ?? "", like) ||
|
||||
EF.Functions.ILike(t.PersonName ?? "", like) ||
|
||||
EF.Functions.ILike(t.AreaNote ?? "", like) ||
|
||||
EF.Functions.ILike(t.Role.Name, like) ||
|
||||
EF.Functions.ILike(t.City.Name, like));
|
||||
}
|
||||
|
||||
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 : "")} — همکادر؛ مشاهده و تماس مستقیم.";
|
||||
}
|
||||
}
|
||||
+265
-10
@@ -12,7 +12,16 @@ using Microsoft.EntityFrameworkCore;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// 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.
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
@@ -54,11 +63,17 @@ builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
|
||||
JobsMedical.Web.Services.Scraping.DivarListingSource>();
|
||||
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
|
||||
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,
|
||||
JobsMedical.Web.Services.Scraping.WebsiteListingSource>();
|
||||
builder.Services.AddScoped<JobsMedical.Web.Services.Scraping.ListingArchiver>();
|
||||
builder.Services.AddScoped<JobsMedical.Web.Services.Scraping.IngestionService>();
|
||||
builder.Services.AddHostedService<JobsMedical.Web.Services.Scraping.IngestionWorker>();
|
||||
builder.Services.AddScoped<JobsMedical.Web.Services.Social.SocialPostService>();
|
||||
builder.Services.AddHostedService<JobsMedical.Web.Services.Social.SocialPostWorker>();
|
||||
|
||||
// Phone-OTP cookie auth.
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
@@ -92,7 +107,8 @@ using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
db.Database.Migrate();
|
||||
await SeedData.SeedReferenceAsync(db); // cities/roles/districts always
|
||||
await SeedData.SeedReferenceAsync(db); // cities/districts on first run
|
||||
await SeedData.EnsureRolesAsync(db); // add any missing roles (idempotent, existing DBs too)
|
||||
// Demo board in Development, or whenever the admin has turned Demo Mode on.
|
||||
var st = await scope.ServiceProvider
|
||||
.GetRequiredService<JobsMedical.Web.Services.Scraping.SettingsService>().GetAsync();
|
||||
@@ -131,6 +147,28 @@ app.UseMiddleware<VisitorCookieMiddleware>();
|
||||
app.UseAuthentication();
|
||||
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.MapRazorPages()
|
||||
.WithStaticAssets();
|
||||
@@ -279,15 +317,51 @@ app.MapPost("/report", async (HttpContext ctx, AppDbContext db, VisitorContext v
|
||||
return Results.Redirect((string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl) + "?reported=1");
|
||||
}).DisableAntiforgery();
|
||||
|
||||
app.MapGet("/sw.js", () => Results.Content("""
|
||||
const CACHE = 'hamkadr-v1';
|
||||
self.addEventListener('install', e => { self.skipWaiting(); e.waitUntil(caches.open(CACHE).then(c => c.addAll(['/']))); });
|
||||
// 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", (HttpContext ctx) =>
|
||||
{
|
||||
// The worker file must NEVER be cached — otherwise an old worker keeps serving stale pages and
|
||||
// browsers never pick up a new version (the fix can't reach clients because the file is cached).
|
||||
ctx.Response.Headers.CacheControl = "no-cache, no-store, must-revalidate";
|
||||
return Results.Content("""
|
||||
const CACHE = 'hamkadr-v2';
|
||||
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('fetch', e => {
|
||||
const req = e.request;
|
||||
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; })
|
||||
.catch(() => caches.match(req).then(m => m || caches.match('/'))));
|
||||
// Page navigations ALWAYS go to the network so listings are fresh (never a stale/archived card).
|
||||
// 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 => {
|
||||
let d = { title: 'همکادر', body: 'فرصت جدید برای شما', url: '/' };
|
||||
@@ -299,13 +373,24 @@ self.addEventListener('notificationclick', e => {
|
||||
const url = (e.notification.data && e.notification.data.url) || '/';
|
||||
e.waitUntil(clients.matchAll({ type: 'window' }).then(cl => { for (const c of cl) { if ('focus' in c) { c.navigate(url); return c.focus(); } } return clients.openWindow(url); }));
|
||||
});
|
||||
""", "text/javascript"));
|
||||
""", "text/javascript");
|
||||
});
|
||||
|
||||
// ---- SEO: robots.txt + dynamic sitemap.xml (so Google indexes every live shift/job page) ----
|
||||
app.MapGet("/robots.txt", (HttpContext ctx) =>
|
||||
{
|
||||
var b = $"{ctx.Request.Scheme}://{ctx.Request.Host}";
|
||||
return Results.Text($"User-agent: *\nAllow: /\nDisallow: /Admin\nDisallow: /Employer\nSitemap: {b}/sitemap.xml\n", "text/plain");
|
||||
var rules = string.Join('\n',
|
||||
"User-agent: *",
|
||||
"Allow: /",
|
||||
// Private / applicant areas — never index.
|
||||
"Disallow: /Admin", "Disallow: /Employer", "Disallow: /Me", "Disallow: /Account",
|
||||
"Disallow: /Preferences", "Disallow: /resume/", "Disallow: /avatar/",
|
||||
"Disallow: /report", "Disallow: /push/", "Disallow: /notifications/",
|
||||
"Disallow: /Talent/Details", // personal contact info — list page stays indexable
|
||||
|
||||
$"Sitemap: {b}/sitemap.xml", "");
|
||||
return Results.Text(rules, "text/plain");
|
||||
});
|
||||
|
||||
app.MapGet("/sitemap.xml", async (HttpContext ctx, AppDbContext db) =>
|
||||
@@ -324,8 +409,11 @@ app.MapGet("/sitemap.xml", async (HttpContext ctx, AppDbContext db) =>
|
||||
sb.Append("<changefreq>").Append(freq).Append("</changefreq></url>\n");
|
||||
}
|
||||
|
||||
foreach (var p in new[] { "", "/Shifts", "/Jobs", "/Calendar", "/Facilities" })
|
||||
foreach (var p in new[] { "", "/Shifts", "/Jobs", "/Talent", "/Calendar", "/Facilities" })
|
||||
Url($"{b}{p}", DateTime.UtcNow, p == "" ? "daily" : "hourly");
|
||||
// Static content pages (rarely change).
|
||||
foreach (var p in new[] { "/Download", "/Help", "/Privacy", "/Rules", "/Terms" })
|
||||
Url($"{b}{p}", null, "monthly");
|
||||
|
||||
foreach (var s in await db.Shifts.Where(s => s.Status == ShiftStatus.Open && s.Date >= today)
|
||||
.Select(s => new { s.Id, s.CreatedAt }).ToListAsync())
|
||||
@@ -335,8 +423,175 @@ app.MapGet("/sitemap.xml", async (HttpContext ctx, AppDbContext db) =>
|
||||
.Select(j => new { j.Id, j.CreatedAt }).ToListAsync())
|
||||
Url($"{b}/Jobs/Details/{j.Id}", j.CreatedAt, "weekly");
|
||||
|
||||
// Public facility pages.
|
||||
foreach (var fId in await db.Facilities.Select(f => f.Id).ToListAsync())
|
||||
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>");
|
||||
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) ----
|
||||
app.MapGet("/search/suggest", async (string? q, AppDbContext db) =>
|
||||
{
|
||||
var term = (q ?? "").Trim();
|
||||
if (term.Length < 2) return Results.Json(Array.Empty<SuggestItem>());
|
||||
var like = $"%{term}%";
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var jobCut = JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc;
|
||||
var talentCut = JobsMedical.Web.Services.Scraping.ListingPolicy.TalentCutoffUtc;
|
||||
|
||||
// Plain (un-marked) snippet around the first occurrence of the term — the client highlights it.
|
||||
static string? Snip(string? text, string term, string? fallback)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
var flat = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ").Trim();
|
||||
var i = flat.IndexOf(term, StringComparison.OrdinalIgnoreCase);
|
||||
if (i >= 0)
|
||||
{
|
||||
var start = Math.Max(0, i - 40);
|
||||
var end = Math.Min(flat.Length, i + term.Length + 40);
|
||||
return (start > 0 ? "…" : "") + flat.Substring(start, end - start) + (end < flat.Length ? "…" : "");
|
||||
}
|
||||
}
|
||||
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
|
||||
var merged = new List<SuggestItem>();
|
||||
for (var i = 0; i < 5 && merged.Count < 5; i++)
|
||||
{
|
||||
if (i < shifts.Count) merged.Add(shifts[i]);
|
||||
if (merged.Count < 5 && i < jobs.Count) merged.Add(jobs[i]);
|
||||
if (merged.Count < 5 && i < talent.Count) merged.Add(talent[i]);
|
||||
}
|
||||
return Results.Json(new { items = merged.Take(5), total });
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>One typeahead suggestion row (lowercase props → camelCase JSON for the client).
|
||||
/// <c>sub</c> is the matched-context line (tags/city/specialty) shown highlighted under the label.</summary>
|
||||
public record SuggestItem(string type, string label, string url, string? sub = null);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Linq;
|
||||
using JobsMedical.Web.Models;
|
||||
|
||||
namespace JobsMedical.Web.Services;
|
||||
|
||||
/// <summary>Presentation helpers for <see cref="ContactType"/> — label, icon, and a clickable href.</summary>
|
||||
public static class ContactInfo
|
||||
{
|
||||
public static string Label(ContactType t) => t switch
|
||||
{
|
||||
ContactType.Mobile => "موبایل",
|
||||
ContactType.Phone => "تلفن ثابت",
|
||||
ContactType.Email => "ایمیل",
|
||||
ContactType.Telegram => "تلگرام",
|
||||
ContactType.Bale => "بله",
|
||||
ContactType.WhatsApp => "واتساپ",
|
||||
ContactType.Instagram => "اینستاگرام",
|
||||
ContactType.Website => "وبسایت",
|
||||
_ => "تماس",
|
||||
};
|
||||
|
||||
public static string Icon(ContactType t) => t switch
|
||||
{
|
||||
ContactType.Mobile or ContactType.Phone => "📞",
|
||||
ContactType.Email => "✉️",
|
||||
ContactType.Telegram => "📨",
|
||||
ContactType.Bale => "💬",
|
||||
ContactType.WhatsApp => "🟢",
|
||||
ContactType.Instagram => "📷",
|
||||
ContactType.Website => "🌐",
|
||||
_ => "🔗",
|
||||
};
|
||||
|
||||
/// <summary>A clickable URL for the contact (tel:/mailto:/t.me/…), or null when not linkable.</summary>
|
||||
public static string? Href(ContactType t, string value)
|
||||
{
|
||||
var v = value.Trim();
|
||||
var handle = v.TrimStart('@');
|
||||
string Digits() => new(v.Where(char.IsDigit).ToArray());
|
||||
return t switch
|
||||
{
|
||||
ContactType.Mobile or ContactType.Phone => "tel:" + Digits(),
|
||||
ContactType.Email => "mailto:" + v,
|
||||
ContactType.Telegram => "https://t.me/" + handle,
|
||||
ContactType.Bale => "https://ble.ir/" + handle,
|
||||
ContactType.WhatsApp => "https://wa.me/" + Digits(),
|
||||
ContactType.Instagram => "https://instagram.com/" + handle,
|
||||
ContactType.Website => v.StartsWith("http") ? v : "https://" + v,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,36 @@ public static class JalaliDate
|
||||
|
||||
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>
|
||||
public static string ToPersianDigits(string input)
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user