Project_Branch_Management #155

Merged
ashutosh.nehete merged 17 commits from Project_Branch_Management into main 2025-11-25 06:49:19 +00:00
61 changed files with 64873 additions and 734 deletions

View File

@ -213,9 +213,15 @@ namespace Marco.Pms.DataAccess.Data
public DbSet<ServiceProjectServiceMapping> ServiceProjectServiceMapping { get; set; }
public DbSet<TeamRoleMaster> TeamRoleMasters { get; set; }
public DbSet<ServiceProjectTag> ServiceProjectTags { get; set; }
//public DbSet<TalkingPoint> TalkingPoints { get; set; }
//public DbSet<TalkingPointAttachment> TalkingPointAttachments { get; set; }
public DbSet<ServiceProjectTagMapping> ServiceProjectTagMappings { get; set; }
public DbSet<ServiceProjectAllocation> ServiceProjectAllocations { get; set; }
#region ======================================================= Project Branch =======================================================
public DbSet<ProjectBranch> ProjectBranches { get; set; }
#endregion
#region ======================================================= Job =======================================================
public DbSet<JobTicket> JobTickets { get; set; }
public DbSet<JobStatus> JobStatus { get; set; }
@ -1280,8 +1286,8 @@ namespace Marco.Pms.DataAccess.Data
new JobStatus { Id = Guid.Parse("32d76a02-8f44-4aa0-9b66-c3716c45a918"), Name = "New", DisplayName = "New", Level = 1 },
new JobStatus { Id = Guid.Parse("cfa1886d-055f-4ded-84c6-42a2a8a14a66"), Name = "Assigned", DisplayName = "Assigned", Level = 2 },
new JobStatus { Id = Guid.Parse("5a6873a5-fed7-4745-a52f-8f61bf3bd72d"), Name = "In Progress", DisplayName = "In Progress", Level = 3 },
new JobStatus { Id = Guid.Parse("aab71020-2fb8-44d9-9430-c9a7e9bf33b0"), Name = "Review", DisplayName = "Review", Level = 4 },
new JobStatus { Id = Guid.Parse("ed10ab57-dbaa-4ca5-8ecd-56745dcbdbd7"), Name = "Done", DisplayName = "Done", Level = 5 },
new JobStatus { Id = Guid.Parse("aab71020-2fb8-44d9-9430-c9a7e9bf33b0"), Name = "Work Done", DisplayName = "Work Done", Level = 4 },
new JobStatus { Id = Guid.Parse("ed10ab57-dbaa-4ca5-8ecd-56745dcbdbd7"), Name = "Review Done", DisplayName = "Review Done", Level = 5 },
new JobStatus { Id = Guid.Parse("3ddeefb5-ae3c-4e10-a922-35e0a452bb69"), Name = "Closed", DisplayName = "Closed", Level = 6 },
new JobStatus { Id = Guid.Parse("75a0c8b8-9c6a-41af-80bf-b35bab722eb2"), Name = "On Hold", DisplayName = "On Hold", Level = 7 }
);

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,122 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marco.Pms.DataAccess.Migrations
{
/// <inheritdoc />
public partial class Added_ProjectBranches_Table : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "Projects",
keyColumn: "ContactPerson",
keyValue: null,
column: "ContactPerson",
value: "");
migrationBuilder.AlterColumn<string>(
name: "ContactPerson",
table: "Projects",
type: "longtext",
nullable: false,
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "ProjectBranches",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
BranchName = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ProjectId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ContactInformation = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Address = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
BranchType = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
GoogleMapUrl = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CreatedById = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
UpdatedById = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
TenantId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci")
},
constraints: table =>
{
table.PrimaryKey("PK_ProjectBranches", x => x.Id);
table.ForeignKey(
name: "FK_ProjectBranches_Employees_CreatedById",
column: x => x.CreatedById,
principalTable: "Employees",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ProjectBranches_Employees_UpdatedById",
column: x => x.UpdatedById,
principalTable: "Employees",
principalColumn: "Id");
table.ForeignKey(
name: "FK_ProjectBranches_ServiceProjects_ProjectId",
column: x => x.ProjectId,
principalTable: "ServiceProjects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ProjectBranches_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ProjectBranches_CreatedById",
table: "ProjectBranches",
column: "CreatedById");
migrationBuilder.CreateIndex(
name: "IX_ProjectBranches_ProjectId",
table: "ProjectBranches",
column: "ProjectId");
migrationBuilder.CreateIndex(
name: "IX_ProjectBranches_TenantId",
table: "ProjectBranches",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_ProjectBranches_UpdatedById",
table: "ProjectBranches",
column: "UpdatedById");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ProjectBranches");
migrationBuilder.AlterColumn<string>(
name: "ContactPerson",
table: "Projects",
type: "longtext",
nullable: true,
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
}
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,78 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marco.Pms.DataAccess.Migrations
{
/// <inheritdoc />
public partial class Added_ProjectBranch_As_ForignKey_In_JobTickets_Table : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ProjectBranchId",
table: "JobTickets",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.UpdateData(
table: "JobStatus",
keyColumn: "Id",
keyValue: new Guid("aab71020-2fb8-44d9-9430-c9a7e9bf33b0"),
columns: new[] { "DisplayName", "Name" },
values: new object[] { "Work Done", "Work Done" });
migrationBuilder.UpdateData(
table: "JobStatus",
keyColumn: "Id",
keyValue: new Guid("ed10ab57-dbaa-4ca5-8ecd-56745dcbdbd7"),
columns: new[] { "DisplayName", "Name" },
values: new object[] { "Review Done", "Review Done" });
migrationBuilder.CreateIndex(
name: "IX_JobTickets_ProjectBranchId",
table: "JobTickets",
column: "ProjectBranchId");
migrationBuilder.AddForeignKey(
name: "FK_JobTickets_ProjectBranches_ProjectBranchId",
table: "JobTickets",
column: "ProjectBranchId",
principalTable: "ProjectBranches",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_JobTickets_ProjectBranches_ProjectBranchId",
table: "JobTickets");
migrationBuilder.DropIndex(
name: "IX_JobTickets_ProjectBranchId",
table: "JobTickets");
migrationBuilder.DropColumn(
name: "ProjectBranchId",
table: "JobTickets");
migrationBuilder.UpdateData(
table: "JobStatus",
keyColumn: "Id",
keyValue: new Guid("aab71020-2fb8-44d9-9430-c9a7e9bf33b0"),
columns: new[] { "DisplayName", "Name" },
values: new object[] { "Review", "Review" });
migrationBuilder.UpdateData(
table: "JobStatus",
keyColumn: "Id",
keyValue: new Guid("ed10ab57-dbaa-4ca5-8ecd-56745dcbdbd7"),
columns: new[] { "DisplayName", "Name" },
values: new object[] { "Done", "Done" });
}
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marco.Pms.DataAccess.Migrations
{
/// <inheritdoc />
public partial class Added_IsArchive_In_JobTicket_Table : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsArchive",
table: "JobTickets",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsArchive",
table: "JobTickets");
}
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marco.Pms.DataAccess.Migrations
{
/// <inheritdoc />
public partial class Removed_Project_ForignKey_From_Expenses_Table : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Expenses_Projects_ProjectId",
table: "Expenses");
migrationBuilder.DropIndex(
name: "IX_Expenses_ProjectId",
table: "Expenses");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Expenses_ProjectId",
table: "Expenses",
column: "ProjectId");
migrationBuilder.AddForeignKey(
name: "FK_Expenses_Projects_ProjectId",
table: "Expenses",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marco.Pms.DataAccess.Migrations
{
/// <inheritdoc />
public partial class Removed_Project_ForignKey_From_PaymentRequest_Table : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_PaymentRequests_Projects_ProjectId",
table: "PaymentRequests");
migrationBuilder.DropIndex(
name: "IX_PaymentRequests_ProjectId",
table: "PaymentRequests");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_PaymentRequests_ProjectId",
table: "PaymentRequests",
column: "ProjectId");
migrationBuilder.AddForeignKey(
name: "FK_PaymentRequests_Projects_ProjectId",
table: "PaymentRequests",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id");
}
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marco.Pms.DataAccess.Migrations
{
/// <inheritdoc />
public partial class Removed_Project_ForignKey_From_RecurringPayment_Table : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_RecurringPayments_Projects_ProjectId",
table: "RecurringPayments");
migrationBuilder.DropIndex(
name: "IX_RecurringPayments_ProjectId",
table: "RecurringPayments");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_RecurringPayments_ProjectId",
table: "RecurringPayments",
column: "ProjectId");
migrationBuilder.AddForeignKey(
name: "FK_RecurringPayments_Projects_ProjectId",
table: "RecurringPayments",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id");
}
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marco.Pms.DataAccess.Migrations
{
/// <inheritdoc />
public partial class Removed_Project_ForignKey_From_ProjectContactMapping_Table : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ContactProjectMappings_Projects_ProjectId",
table: "ContactProjectMappings");
migrationBuilder.DropIndex(
name: "IX_ContactProjectMappings_ProjectId",
table: "ContactProjectMappings");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_ContactProjectMappings_ProjectId",
table: "ContactProjectMappings",
column: "ProjectId");
migrationBuilder.AddForeignKey(
name: "FK_ContactProjectMappings_Projects_ProjectId",
table: "ContactProjectMappings",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}

View File

@ -898,8 +898,6 @@ namespace Marco.Pms.DataAccess.Migrations
b.HasIndex("ContactId");
b.HasIndex("ProjectId");
b.HasIndex("TenantId");
b.ToTable("ContactProjectMappings");
@ -2329,8 +2327,6 @@ namespace Marco.Pms.DataAccess.Migrations
b.HasIndex("ProcessedById");
b.HasIndex("ProjectId");
b.HasIndex("ReviewedById");
b.HasIndex("StatusId");
@ -2798,8 +2794,6 @@ namespace Marco.Pms.DataAccess.Migrations
b.HasIndex("PaidById");
b.HasIndex("ProjectId");
b.HasIndex("RecurringPaymentId");
b.HasIndex("TenantId");
@ -2926,8 +2920,6 @@ namespace Marco.Pms.DataAccess.Migrations
b.HasIndex("ExpenseCategoryId");
b.HasIndex("ProjectId");
b.HasIndex("StatusId");
b.HasIndex("TenantId");
@ -4799,6 +4791,7 @@ namespace Marco.Pms.DataAccess.Migrations
.HasColumnType("char(36)");
b.Property<string>("ContactPerson")
.IsRequired()
.HasColumnType("longtext");
b.Property<DateTime?>("EndDate")
@ -5260,16 +5253,16 @@ namespace Marco.Pms.DataAccess.Migrations
new
{
Id = new Guid("aab71020-2fb8-44d9-9430-c9a7e9bf33b0"),
DisplayName = "Review",
DisplayName = "Work Done",
Level = 4,
Name = "Review"
Name = "Work Done"
},
new
{
Id = new Guid("ed10ab57-dbaa-4ca5-8ecd-56745dcbdbd7"),
DisplayName = "Done",
DisplayName = "Review Done",
Level = 5,
Name = "Done"
Name = "Review Done"
},
new
{
@ -5493,6 +5486,12 @@ namespace Marco.Pms.DataAccess.Migrations
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsArchive")
.HasColumnType("tinyint(1)");
b.Property<Guid?>("ProjectBranchId")
.HasColumnType("char(36)");
b.Property<Guid>("ProjectId")
.HasColumnType("char(36)");
@ -5526,6 +5525,8 @@ namespace Marco.Pms.DataAccess.Migrations
b.HasIndex("CreatedById");
b.HasIndex("ProjectBranchId");
b.HasIndex("ProjectId");
b.HasIndex("StatusId");
@ -5537,6 +5538,65 @@ namespace Marco.Pms.DataAccess.Migrations
b.ToTable("JobTickets");
});
modelBuilder.Entity("Marco.Pms.Model.ServiceProject.ProjectBranch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Address")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("BranchType")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("ContactInformation")
.IsRequired()
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("CreatedById")
.HasColumnType("char(36)");
b.Property<string>("GoogleMapUrl")
.HasColumnType("longtext");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<Guid>("ProjectId")
.HasColumnType("char(36)");
b.Property<Guid>("TenantId")
.HasColumnType("char(36)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("UpdatedById")
.HasColumnType("char(36)");
b.HasKey("Id");
b.HasIndex("CreatedById");
b.HasIndex("ProjectId");
b.HasIndex("TenantId");
b.HasIndex("UpdatedById");
b.ToTable("ProjectBranches");
});
modelBuilder.Entity("Marco.Pms.Model.ServiceProject.ServiceProject", b =>
{
b.Property<Guid>("Id")
@ -6803,12 +6863,6 @@ namespace Marco.Pms.DataAccess.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Marco.Pms.Model.Projects.Project", "Project")
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Marco.Pms.Model.TenantModels.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId")
@ -6817,8 +6871,6 @@ namespace Marco.Pms.DataAccess.Migrations
b.Navigation("Contact");
b.Navigation("Project");
b.Navigation("Tenant");
});
@ -7310,12 +7362,6 @@ namespace Marco.Pms.DataAccess.Migrations
.WithMany()
.HasForeignKey("ProcessedById");
b.HasOne("Marco.Pms.Model.Projects.Project", "Project")
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Marco.Pms.Model.Employees.Employee", "ReviewedBy")
.WithMany()
.HasForeignKey("ReviewedById");
@ -7348,8 +7394,6 @@ namespace Marco.Pms.DataAccess.Migrations
b.Navigation("ProcessedBy");
b.Navigation("Project");
b.Navigation("ReviewedBy");
b.Navigation("Status");
@ -7480,10 +7524,6 @@ namespace Marco.Pms.DataAccess.Migrations
.WithMany()
.HasForeignKey("PaidById");
b.HasOne("Marco.Pms.Model.Projects.Project", "Project")
.WithMany()
.HasForeignKey("ProjectId");
b.HasOne("Marco.Pms.Model.Expenses.RecurringPayment", "RecurringPayment")
.WithMany()
.HasForeignKey("RecurringPaymentId");
@ -7508,8 +7548,6 @@ namespace Marco.Pms.DataAccess.Migrations
b.Navigation("PaidBy");
b.Navigation("Project");
b.Navigation("RecurringPayment");
b.Navigation("Tenant");
@ -7562,10 +7600,6 @@ namespace Marco.Pms.DataAccess.Migrations
.WithMany()
.HasForeignKey("ExpenseCategoryId");
b.HasOne("Marco.Pms.Model.Projects.Project", "Project")
.WithMany()
.HasForeignKey("ProjectId");
b.HasOne("Marco.Pms.Model.Expenses.Masters.RecurringPaymentStatus", "Status")
.WithMany()
.HasForeignKey("StatusId")
@ -7588,8 +7622,6 @@ namespace Marco.Pms.DataAccess.Migrations
b.Navigation("ExpenseCategory");
b.Navigation("Project");
b.Navigation("Status");
b.Navigation("Tenant");
@ -8418,6 +8450,10 @@ namespace Marco.Pms.DataAccess.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Marco.Pms.Model.ServiceProject.ProjectBranch", "ProjectBranch")
.WithMany()
.HasForeignKey("ProjectBranchId");
b.HasOne("Marco.Pms.Model.ServiceProject.ServiceProject", "Project")
.WithMany()
.HasForeignKey("ProjectId")
@ -8444,6 +8480,8 @@ namespace Marco.Pms.DataAccess.Migrations
b.Navigation("Project");
b.Navigation("ProjectBranch");
b.Navigation("Status");
b.Navigation("Tenant");
@ -8451,6 +8489,39 @@ namespace Marco.Pms.DataAccess.Migrations
b.Navigation("UpdatedBy");
});
modelBuilder.Entity("Marco.Pms.Model.ServiceProject.ProjectBranch", b =>
{
b.HasOne("Marco.Pms.Model.Employees.Employee", "CreatedBy")
.WithMany()
.HasForeignKey("CreatedById")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Marco.Pms.Model.ServiceProject.ServiceProject", "Project")
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Marco.Pms.Model.TenantModels.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Marco.Pms.Model.Employees.Employee", "UpdatedBy")
.WithMany()
.HasForeignKey("UpdatedById");
b.Navigation("CreatedBy");
b.Navigation("Project");
b.Navigation("Tenant");
b.Navigation("UpdatedBy");
});
modelBuilder.Entity("Marco.Pms.Model.ServiceProject.ServiceProject", b =>
{
b.HasOne("Marco.Pms.Model.OrganizationModel.Organization", "Client")

View File

@ -1,7 +1,6 @@
using System.ComponentModel.DataAnnotations.Schema;
using Marco.Pms.Model.Projects;
using Marco.Pms.Model.Utilities;
using Marco.Pms.Model.Utilities;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using System.ComponentModel.DataAnnotations.Schema;
namespace Marco.Pms.Model.Directory
{
@ -9,9 +8,6 @@ namespace Marco.Pms.Model.Directory
{
public Guid Id { get; set; }
public Guid ProjectId { get; set; }
[ValidateNever]
[ForeignKey("ProjectId")]
public Project? Project { get; set; }
public Guid ContactId { get; set; }
[ValidateNever]
[ForeignKey("ContactId")]

View File

@ -8,6 +8,7 @@ namespace Marco.Pms.Model.Dtos.ServiceProject
public required string Title { get; set; }
public required string Description { get; set; }
public required Guid ProjectId { get; set; }
public Guid? ProjectBranchId { get; set; }
public List<BasicEmployeeDto>? Assignees { get; set; }
public required DateTime StartDate { get; set; }
public required DateTime DueDate { get; set; }

View File

@ -0,0 +1,13 @@
namespace Marco.Pms.Model.Dtos.ServiceProject
{
public class ProjectBranchDto
{
public Guid? Id { get; set; }
public required string BranchName { get; set; }
public required Guid ProjectId { get; set; }
public required string ContactInformation { get; set; }
public required string Address { get; set; }
public required string BranchType { get; set; } // HQ, ATMs, Bank Branches, Overcounter desk
public string? GoogleMapUrl { get; set; }
}
}

View File

@ -0,0 +1,12 @@
using Marco.Pms.Model.Utilities;
namespace Marco.Pms.Model.Dtos.ServiceProject
{
public class TalkingPointDto
{
public Guid? Id { get; set; }
public required Guid ServiceProjectId { get; set; }
public required string Comment { get; set; }
public List<FileUploadModel>? Attachments { get; set; }
}
}

View File

@ -7,11 +7,11 @@ namespace Marco.Pms.Model.Dtos.ServiceProject
{
public string? Title { get; set; }
public string? Description { get; set; }
public Guid ProjectId { get; set; }
public Guid StatusId { get; set; }
public List<BasicEmployeeDto>? Assignees { get; set; }
public DateTime StartDate { get; set; }
public DateTime DueDate { get; set; }
public List<TagDto>? Tags { get; set; }
public bool IsArchive { get; set; } = false;
}
}

View File

@ -1,7 +1,6 @@
using Marco.Pms.Model.Employees;
using Marco.Pms.Model.Expenses.Masters;
using Marco.Pms.Model.Master;
using Marco.Pms.Model.Projects;
using Marco.Pms.Model.Utilities;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using System.ComponentModel.DataAnnotations.Schema;
@ -14,10 +13,6 @@ namespace Marco.Pms.Model.Expenses
public string UIDPrefix { get; set; } = default!;
public int UIDPostfix { get; set; }
public Guid ProjectId { get; set; }
[ValidateNever]
[ForeignKey("ProjectId")]
public Project? Project { get; set; }
public Guid ExpensesTypeId { get; set; }
//[ValidateNever]

View File

@ -1,7 +1,6 @@
using Marco.Pms.Model.Employees;
using Marco.Pms.Model.Expenses.Masters;
using Marco.Pms.Model.Master;
using Marco.Pms.Model.Projects;
using Marco.Pms.Model.Utilities;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using System.ComponentModel.DataAnnotations.Schema;
@ -28,10 +27,6 @@ namespace Marco.Pms.Model.Expenses
public double? TDSPercentage { get; set; }
public DateTime DueDate { get; set; }
public Guid? ProjectId { get; set; }
[ValidateNever]
[ForeignKey("ProjectId")]
public Project? Project { get; set; }
public Guid? RecurringPaymentId { get; set; }
[ValidateNever]

View File

@ -1,7 +1,6 @@
using Marco.Pms.Model.Employees;
using Marco.Pms.Model.Expenses.Masters;
using Marco.Pms.Model.Master;
using Marco.Pms.Model.Projects;
using Marco.Pms.Model.TenantModels;
using Marco.Pms.Model.Utilities;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
@ -29,10 +28,6 @@ namespace Marco.Pms.Model.Expenses
public DateTime? LatestPRGeneratedAt { get; set; }
public DateTime? NextStrikeDate { get; set; }
public Guid? ProjectId { get; set; }
[ValidateNever]
[ForeignKey("ProjectId")]
public Project? Project { get; set; }
public int PaymentBufferDays { get; set; }
public Guid? ExpenseCategoryId { get; set; }

View File

@ -0,0 +1,11 @@
namespace Marco.Pms.Model.Filters
{
public class AdvanceFilter
{
// The dynamic filters from your JSON
public List<SortItem>? SortFilters { get; set; }
public List<SearchItem>? SearchFilters { get; set; }
public List<AdvanceItem>? AdvanceFilters { get; set; }
public string GroupByColumn { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,9 @@
namespace Marco.Pms.Model.Filters
{
public class AdvanceItem
{
public string Column { get; set; } = string.Empty;
public string Opration { get; set; } = string.Empty; // "greater than", "equal to", etc.
public string Value { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,8 @@
namespace Marco.Pms.Model.Filters
{
public class SearchItem
{
public string Column { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,8 @@
namespace Marco.Pms.Model.Filters
{
public class SortItem
{
public string Column { get; set; } = string.Empty;
public bool SortDescending { get; set; }
}
}

View File

@ -14,7 +14,7 @@ namespace Marco.Pms.Model.Projects
public Guid Id { get; set; }
[Required]
[DisplayName("Project Name")]
public string? Name { get; set; }
public string Name { get; set; } = string.Empty;
public string? ShortName { get; set; }
[DisplayName("Project Address")]
@ -22,7 +22,7 @@ namespace Marco.Pms.Model.Projects
[DisplayName("Contact Person")]
public string? ContactPerson { get; set; }
public string ContactPerson { get; set; } = string.Empty;
public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; }

View File

@ -17,6 +17,11 @@ namespace Marco.Pms.Model.ServiceProject
[ValidateNever]
[ForeignKey("ProjectId")]
public ServiceProject? Project { get; set; }
public Guid? ProjectBranchId { get; set; }
[ValidateNever]
[ForeignKey("ProjectBranchId")]
public ProjectBranch? ProjectBranch { get; set; }
public Guid StatusId { get; set; }
[ValidateNever]
@ -25,6 +30,7 @@ namespace Marco.Pms.Model.ServiceProject
public DateTime StartDate { get; set; }
public DateTime DueDate { get; set; }
public bool IsActive { get; set; } = true;
public bool IsArchive { get; set; } = false;
public DateTime CreatedAt { get; set; }
public Guid CreatedById { get; set; }

View File

@ -0,0 +1,35 @@
using Marco.Pms.Model.Employees;
using Marco.Pms.Model.Utilities;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using System.ComponentModel.DataAnnotations.Schema;
namespace Marco.Pms.Model.ServiceProject
{
public class ProjectBranch : TenantRelation
{
public Guid Id { get; set; }
public string BranchName { get; set; } = string.Empty;
public Guid ProjectId { get; set; }
[ValidateNever]
[ForeignKey("ProjectId")]
public ServiceProject? Project { get; set; }
public string ContactInformation { get; set; } = string.Empty; // Json string
public string Address { get; set; } = string.Empty;
public string BranchType { get; set; } = string.Empty; // HQ, ATMs, Bank Branches, Overcounter desk
public string? GoogleMapUrl { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; }
public Guid CreatedById { get; set; }
[ValidateNever]
[ForeignKey("CreatedById")]
public Employee? CreatedBy { get; set; }
public DateTime? UpdatedAt { get; set; }
public Guid? UpdatedById { get; set; }
[ValidateNever]
[ForeignKey("UpdatedById")]
public Employee? UpdatedBy { get; set; }
}
}

View File

@ -0,0 +1,31 @@
using Marco.Pms.Model.Employees;
using Marco.Pms.Model.Utilities;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using System.ComponentModel.DataAnnotations.Schema;
namespace Marco.Pms.Model.ServiceProject
{
public class TalkingPoint : TenantRelation
{
public Guid Id { get; set; }
public Guid ServiceProjectId { get; set; }
[ValidateNever]
[ForeignKey("ServiceProjectId")]
public ServiceProject? ServiceProject { get; set; }
public string Comment { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; }
public Guid CreatedById { get; set; }
[ValidateNever]
[ForeignKey("CreatedById")]
public Employee? CreatedBy { get; set; }
public DateTime? UpdatedAt { get; set; }
public Guid? UpdatedById { get; set; }
[ValidateNever]
[ForeignKey("UpdatedById")]
public Employee? UpdatedBy { get; set; }
}
}

View File

@ -0,0 +1,22 @@
using Marco.Pms.Model.DocumentManager;
using Marco.Pms.Model.Utilities;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using System.ComponentModel.DataAnnotations.Schema;
namespace Marco.Pms.Model.ServiceProject
{
public class TalkingPointAttachment : TenantRelation
{
public Guid Id { get; set; }
public Guid DocumentId { get; set; }
[ValidateNever]
[ForeignKey("DocumentId")]
public Document? Document { get; set; }
public Guid? TalkingPointId { get; set; }
[ValidateNever]
[ForeignKey("TalkingPointId")]
public TalkingPoint? TalkingPoint { get; set; }
}
}

View File

@ -10,7 +10,7 @@ namespace Marco.Pms.Model.ViewModels.Expenses
public class ExpenseDetailsVM
{
public Guid Id { get; set; }
public ProjectInfoVM? Project { get; set; }
public BasicProjectVM? Project { get; set; }
public ExpenseCategoryMasterVM? ExpenseCategory { get; set; }
public PaymentModeMatserVM? PaymentMode { get; set; }
public BasicEmployeeVM? PaidBy { get; set; }

View File

@ -9,7 +9,7 @@ namespace Marco.Pms.Model.ViewModels.Expanses
public class ExpenseList
{
public Guid Id { get; set; }
public ProjectInfoVM? Project { get; set; }
public BasicProjectVM? Project { get; set; }
public ExpenseCategoryMasterVM? ExpenseCategory { get; set; }
public PaymentModeMatserVM? PaymentMode { get; set; }
public BasicEmployeeVM? PaidBy { get; set; }

View File

@ -7,5 +7,6 @@
public string? Description { get; set; }
public string? JobTicketUId { get; set; }
public string? StatusName { get; set; }
public bool IsArchive { get; set; }
}
}

View File

@ -0,0 +1,9 @@
namespace Marco.Pms.Model.ViewModels.ServiceProject
{
public class BasicProjectBranchVM
{
public Guid Id { get; set; }
public string? BranchName { get; set; }
public string? BranchType { get; set; } // HQ, ATMs, Bank Branches, Overcounter desk
}
}

View File

@ -6,8 +6,5 @@
public string? Name { get; set; }
public string? ShortName { get; set; }
public DateTime AssignedDate { get; set; }
public string? ContactName { get; set; }
public string? ContactPhone { get; set; }
public string? ContactEmail { get; set; }
}
}

View File

@ -11,11 +11,13 @@ namespace Marco.Pms.Model.ViewModels.ServiceProject
public string? Description { get; set; }
public string? JobTicketUId { get; set; }
public BasicServiceProjectVM? Project { get; set; }
public BasicProjectBranchVM? ProjectBranch { get; set; }
public List<BasicEmployeeVM>? Assignees { get; set; }
public JobStatus? Status { get; set; }
public DateTime StartDate { get; set; }
public DateTime DueDate { get; set; }
public bool IsActive { get; set; }
public bool IsArchive { get; set; }
public Guid? AttendanceId { get; set; }
public TAGGING_MARK_TYPE? TaggingAction { get; set; }
public TAGGING_MARK_TYPE? NextTaggingAction { get; set; }

View File

@ -16,6 +16,7 @@ namespace Marco.Pms.Model.ViewModels.ServiceProject
public DateTime StartDate { get; set; }
public DateTime DueDate { get; set; }
public bool IsActive { get; set; }
public bool IsArchive { get; set; }
public DateTime CreatedAt { get; set; }
public BasicEmployeeVM? CreatedBy { get; set; }
public List<TagVM>? Tags { get; set; }

View File

@ -0,0 +1,20 @@
using Marco.Pms.Model.ViewModels.Activities;
namespace Marco.Pms.Model.ViewModels.ServiceProject
{
public class ProjectBranchDetailsVM
{
public Guid Id { get; set; }
public string? BranchName { get; set; }
public BasicServiceProjectVM? Project { get; set; }
public string? ContactInformation { get; set; } // Json string
public string? Address { get; set; }
public string? BranchType { get; set; } // HQ, ATMs, Bank Branches, Overcounter desk
public string? GoogleMapUrl { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; }
public BasicEmployeeVM? CreatedBy { get; set; }
public DateTime? UpdatedAt { get; set; }
public BasicEmployeeVM? UpdatedBy { get; set; }
}
}

View File

@ -0,0 +1,16 @@
using Marco.Pms.Model.ViewModels.Activities;
namespace Marco.Pms.Model.ViewModels.ServiceProject
{
public class ProjectBranchVM
{
public Guid Id { get; set; }
public string? BranchName { get; set; }
public BasicServiceProjectVM? Project { get; set; }
public string? ContactInformation { get; set; } // Json string
public string? Address { get; set; }
public string? BranchType { get; set; } // HQ, ATMs, Bank Branches, Overcounter desk
public DateTime CreatedAt { get; set; }
public BasicEmployeeVM? CreatedBy { get; set; }
}
}

View File

@ -0,0 +1,18 @@
using Marco.Pms.Model.ViewModels.Activities;
using Marco.Pms.Model.ViewModels.DocumentManager;
namespace Marco.Pms.Model.ViewModels.ServiceProject
{
public class TalkingPointVM
{
public Guid Id { get; set; }
public BasicServiceProjectVM? ServiceProject { get; set; }
public string? Comment { get; set; }
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
public BasicEmployeeVM? CreatedBy { get; set; }
public DateTime? UpdatedAt { get; set; }
public BasicEmployeeVM? UpdatedBy { get; set; }
public List<DocumentVM>? Attachments { get; set; }
}
}

View File

@ -474,7 +474,7 @@ namespace MarcoBMS.Services.Controllers
var _employeeHelper = scope.ServiceProvider.GetRequiredService<EmployeeHelper>();
var _firebase = scope.ServiceProvider.GetRequiredService<IFirebaseService>();
var currentEmployee = await _userHelper.GetCurrentEmployeeAsync();
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
using var transaction = await _context.Database.BeginTransactionAsync();
try
@ -517,7 +517,7 @@ namespace MarcoBMS.Services.Controllers
{
attendance.OutTime = finalDateTime;
attendance.Activity = ATTENDANCE_MARK_TYPE.REQUEST_REGULARIZE;
attendance.RequestedById = currentEmployee.Id;
attendance.RequestedById = loggedInEmployee.Id;
attendance.RequestedAt = DateTime.UtcNow;
}
else
@ -531,7 +531,7 @@ namespace MarcoBMS.Services.Controllers
{
attendance.IsApproved = true;
attendance.Activity = ATTENDANCE_MARK_TYPE.REGULARIZE;
attendance.ApprovedById = currentEmployee.Id;
attendance.ApprovedById = loggedInEmployee.Id;
attendance.ApprovedAt = DateTime.UtcNow;
// do nothing
}
@ -539,7 +539,7 @@ namespace MarcoBMS.Services.Controllers
{
attendance.IsApproved = false;
attendance.Activity = ATTENDANCE_MARK_TYPE.REGULARIZE_REJECT;
attendance.ApprovedById = currentEmployee.Id;
attendance.ApprovedById = loggedInEmployee.Id;
attendance.ApprovedAt = DateTime.UtcNow;
// do nothing
}
@ -584,7 +584,7 @@ namespace MarcoBMS.Services.Controllers
Longitude = recordAttendanceDot.Longitude,
TenantId = tenantId,
UpdatedBy = currentEmployee.Id,
UpdatedBy = loggedInEmployee.Id,
UpdatedOn = recordAttendanceDot.Date
};
//if (recordAttendanceDot.Image != null && recordAttendanceDot.Image.Count > 0)
@ -619,7 +619,7 @@ namespace MarcoBMS.Services.Controllers
{
sendActivity = 1;
}
var notification = new { LoggedInUserId = currentEmployee.Id, Keyword = "Attendance", Activity = sendActivity, ProjectId = attendance.ProjectID, Response = vm };
var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Attendance", Activity = sendActivity, ProjectId = attendance.ProjectID, Response = vm };
await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification);
_logger.LogInfo("Attendance for employee {FirstName} {LastName} has been marked", employee.FirstName ?? string.Empty, employee.LastName ?? string.Empty);
@ -628,10 +628,11 @@ namespace MarcoBMS.Services.Controllers
// --- Push Notification Section ---
// This section attempts to send a test push notification to the user's device.
// It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens.
var context = HttpContext;
string origin = context.Request.Headers["Origin"].FirstOrDefault() ?? "";
var name = $"{vm.FirstName} {vm.LastName}";
await _firebase.SendAttendanceMessageAsync(attendance.ProjectID, name, recordAttendanceDot.Action, attendance.EmployeeId, tenantId);
await _firebase.SendAttendanceMessageAsync(attendance.ProjectID, name, recordAttendanceDot.Action, attendance.EmployeeId, origin, loggedInEmployee.Id, tenantId);
});
@ -848,10 +849,12 @@ namespace MarcoBMS.Services.Controllers
// --- Push Notification Section ---
// This section attempts to send a test push notification to the user's device.
// It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens.
var context = HttpContext;
string origin = context.Request.Headers["Origin"].FirstOrDefault() ?? "";
var name = $"{vm.FirstName} {vm.LastName}";
await _firebase.SendAttendanceMessageAsync(attendance.ProjectID, name, recordAttendanceDot.Action, attendance.EmployeeId, tenantId);
await _firebase.SendAttendanceMessageAsync(attendance.ProjectID, name, recordAttendanceDot.Action, attendance.EmployeeId, origin, loggedInEmployee.Id, tenantId);
});

View File

@ -4,6 +4,7 @@ using Marco.Pms.Helpers.Utility;
using Marco.Pms.Model.Collection;
using Marco.Pms.Model.Dtos.Collection;
using Marco.Pms.Model.Entitlements;
using Marco.Pms.Model.Filters;
using Marco.Pms.Model.MongoDBModels.Utility;
using Marco.Pms.Model.OrganizationModel;
using Marco.Pms.Model.Projects;
@ -13,6 +14,7 @@ using Marco.Pms.Model.ViewModels.Activities;
using Marco.Pms.Model.ViewModels.Collection;
using Marco.Pms.Model.ViewModels.Organization;
using Marco.Pms.Model.ViewModels.Projects;
using Marco.Pms.Services.Extensions;
using Marco.Pms.Services.Service;
using MarcoBMS.Services.Helpers;
using MarcoBMS.Services.Service;
@ -20,7 +22,9 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MongoDB.Driver;
using System.Text.Json;
using Document = Marco.Pms.Model.DocumentManager.Document;
using Invoice = Marco.Pms.Model.Collection.Invoice;
namespace Marco.Pms.Services.Controllers
{
@ -59,8 +63,8 @@ namespace Marco.Pms.Services.Controllers
/// </summary>
[HttpGet("invoice/list")]
public async Task<IActionResult> GetInvoiceListAsync([FromQuery] Guid? projectId, [FromQuery] string? searchString, [FromQuery] DateTime? fromDate, [FromQuery] DateTime? toDate,
[FromQuery] int pageSize = 20, [FromQuery] int pageNumber = 1, [FromQuery] bool isActive = true, [FromQuery] bool isPending = false)
public async Task<IActionResult> GetInvoiceListAsync([FromQuery] Guid? projectId, [FromQuery] string? searchString, [FromQuery] string? filter, [FromQuery] DateTime? fromDate,
[FromQuery] DateTime? toDate, [FromQuery] int pageSize = 20, [FromQuery] int pageNumber = 1, [FromQuery] bool isActive = true, [FromQuery] bool isPending = false)
{
try
{
@ -94,16 +98,52 @@ namespace Marco.Pms.Services.Controllers
_logger.LogInfo("Permissions validated for EmployeeId {EmployeeId}.", employee.Id);
var advanceFilter = TryDeserializeFilter(filter);
await using var _context = await _dbContextFactory.CreateDbContextAsync();
await using var context = await _dbContextFactory.CreateDbContextAsync();
// Fetch related project data asynchronously and in parallel
var infraProjectsQuery = _context.Projects
.Where(p => p.TenantId == tenantId);
var serviceProjectsQuery = context.ServiceProjects
.Where(sp => sp.TenantId == tenantId);
if (advanceFilter?.SearchFilters != null && advanceFilter.SearchFilters.Any())
{
var projectSearchFilter = advanceFilter.SearchFilters
.Where(f => f.Column == "ProjectName")
.Select(f => new SearchItem { Column = "Name", Value = f.Value })
.ToList();
if (projectSearchFilter.Any())
{
infraProjectsQuery = infraProjectsQuery.ApplySearchFilters(projectSearchFilter);
serviceProjectsQuery = serviceProjectsQuery.ApplySearchFilters(projectSearchFilter);
}
}
var infraProjectsTask = infraProjectsQuery
.Select(p => _mapper.Map<BasicProjectVM>(p))
.ToListAsync();
var serviceProjectsTask = serviceProjectsQuery
.Select(sp => _mapper.Map<BasicProjectVM>(sp))
.ToListAsync();
await Task.WhenAll(infraProjectsTask, serviceProjectsTask);
var projects = infraProjectsTask.Result;
projects.AddRange(serviceProjectsTask.Result);
var projIds = projects.Select(p => p.Id).Distinct().ToList();
// Build invoice query efficiently - always use AsNoTracking for reads
var query = _context.Invoices
.AsNoTracking()
.Include(i => i.BilledTo)
.Include(i => i.CreatedBy).ThenInclude(e => e!.JobRole)
.Include(i => i.UpdatedBy).ThenInclude(e => e!.JobRole)
.Where(i => i.IsActive == isActive && i.TenantId == tenantId);
.Where(i => projIds.Contains(i.ProjectId) && i.IsActive == isActive && i.TenantId == tenantId);
// Filter by date, ensuring date boundaries are correct
if (fromDate.HasValue && toDate.HasValue)
@ -126,11 +166,22 @@ namespace Marco.Pms.Services.Controllers
_logger.LogDebug("Project filter applied: {ProjectId}", projectId.Value);
}
query = query.ApplyCustomFilters(advanceFilter, "InvoiceDate");
if (advanceFilter?.SearchFilters != null)
{
var invoiceSearchFilter = advanceFilter.SearchFilters.Where(f => f.Column != "ProjectName").ToList();
if (invoiceSearchFilter.Any())
{
query = query.ApplySearchFilters(invoiceSearchFilter);
}
}
var totalItems = await query.CountAsync();
var totalPages = (int)Math.Ceiling((double)totalItems / pageSize);
_logger.LogInfo("Total invoices found: {TotalItems}", totalItems);
var pagedInvoices = await query
.OrderByDescending(i => i.InvoiceDate)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
@ -155,20 +206,6 @@ namespace Marco.Pms.Services.Controllers
_logger.LogDebug("Received payment data for {Count} invoices.", paymentGroups.Count);
// Fetch related project data asynchronously and in parallel
var projIds = pagedInvoices.Select(i => i.ProjectId).Distinct().ToList();
var infraProjectsTask = _context.Projects
.Where(p => projIds.Contains(p.Id) && p.TenantId == tenantId)
.ToListAsync();
var serviceProjectsTask = context.ServiceProjects
.Where(sp => projIds.Contains(sp.Id) && sp.TenantId == tenantId)
.ToListAsync();
await Task.WhenAll(infraProjectsTask, serviceProjectsTask);
var infraProjects = infraProjectsTask.Result;
var serviceProjects = serviceProjectsTask.Result;
// Build results and compute balances in memory for tight control
var results = new List<InvoiceListVM>();
@ -185,16 +222,13 @@ namespace Marco.Pms.Services.Controllers
var vm = _mapper.Map<InvoiceListVM>(invoice);
// Project mapping logic - minimize nested object allocations
vm.Project = serviceProjects.Where(sp => sp.Id == invoice.ProjectId).Select(p => _mapper.Map<BasicProjectVM>(p)).FirstOrDefault()
?? infraProjects.Where(ip => ip.Id == invoice.ProjectId).Select(sp => _mapper.Map<BasicProjectVM>(sp)).FirstOrDefault();
vm.Project = projects.Where(sp => sp.Id == invoice.ProjectId).FirstOrDefault();
vm.BalanceAmount = balance;
results.Add(vm);
}
var totalPages = (int)Math.Ceiling((double)totalItems / pageSize);
_logger.LogInfo("Returning {Count} invoices (page {PageNumber} of {TotalPages}).", results.Count, pageNumber, totalPages);
return Ok(ApiResponse<object>.SuccessResponse(
@ -1155,6 +1189,45 @@ namespace Marco.Pms.Services.Controllers
#region =================================================================== Helper Functions ===================================================================
private AdvanceFilter? TryDeserializeFilter(string? filter)
{
if (string.IsNullOrWhiteSpace(filter))
{
return null;
}
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
AdvanceFilter? advanceFilter = null;
try
{
// First, try to deserialize directly. This is the expected case (e.g., from a web client).
advanceFilter = JsonSerializer.Deserialize<AdvanceFilter>(filter, options);
}
catch (JsonException ex)
{
_logger.LogError(ex, "[{MethodName}] Failed to directly deserialize filter. Attempting to unescape and re-parse. Filter: {Filter}", nameof(TryDeserializeFilter), filter);
// If direct deserialization fails, it might be an escaped string (common with tools like Postman or some mobile clients).
try
{
// Unescape the string first, then deserialize the result.
string unescapedJsonString = JsonSerializer.Deserialize<string>(filter, options) ?? "";
if (!string.IsNullOrWhiteSpace(unescapedJsonString))
{
advanceFilter = JsonSerializer.Deserialize<AdvanceFilter>(unescapedJsonString, options);
}
}
catch (JsonException ex1)
{
// If both attempts fail, log the final error and return null.
_logger.LogError(ex1, "[{MethodName}] All attempts to deserialize the filter failed. Filter will be ignored. Filter: {Filter}", nameof(TryDeserializeFilter), filter);
return null;
}
}
return advanceFilter;
}
/// <summary>
/// Async permission check helper with scoped DI lifetime
/// </summary>

View File

@ -206,6 +206,14 @@ namespace Marco.Pms.Services.Controllers
return Ok(ApiResponse<object>.SuccessResponse(dashboardVM, "Project counts fetched successfully.", 200));
}
[HttpGet("project-completion-status")]
public async Task<IActionResult> GetAllProjectsAsync()
{
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
var response = await _projectServices.GetAllProjectsAsync(string.Empty, 0, 0, loggedInEmployee, tenantId);
return StatusCode(response.StatusCode, response);
}
/// <summary>
/// Retrieves a dashboard summary of total employees and today's attendance.
/// If a projectId is provided, it returns totals for that project; otherwise, for all accessible active projects.

View File

@ -133,30 +133,13 @@ namespace Marco.Pms.Services.Controllers
}
[HttpGet("get/project/report/{projectId}")]
public async Task<IActionResult> GetProjectReport(Guid projectId)
{
using var scope = _serviceScopeFactory.CreateScope();
var _reportHelper = scope.ServiceProvider.GetRequiredService<ReportHelper>();
var _logger = scope.ServiceProvider.GetRequiredService<ILoggingService>();
var resonse = await _reportHelper.GetDailyProjectReportWithOutTenant(projectId);
if (resonse == null)
{
_logger.LogWarning("Project report not found");
return NotFound(ApiResponse<object>.ErrorResponse("Project report not found", "Project report not found", 404));
}
_logger.LogInfo("Report for the project fetched successfully");
return Ok(ApiResponse<object>.SuccessResponse(resonse, "Report for the project fetched successfully", 200));
}
/// <summary>
/// Retrieves a daily project report by its unique identifier.
/// </summary>
/// <param name="projectId">The GUID of the project for which to generate the report.</param>
/// <returns>An IActionResult containing the project report or an appropriate error response.</returns>
[HttpGet("{projectId}/report")]
public async Task<IActionResult> GetProjectReportAsync(Guid projectId)
public async Task<IActionResult> GetProjectReportAsync(Guid projectId, [FromQuery] DateTime? date)
{
using var scope = _serviceScopeFactory.CreateScope();
var _reportHelper = scope.ServiceProvider.GetRequiredService<ReportHelper>();
@ -167,8 +150,17 @@ namespace Marco.Pms.Services.Controllers
try
{
DateTime reportDate;
if (date.HasValue)
{
reportDate = date.Value;
}
else
{
reportDate = DateTime.UtcNow.AddDays(-1).Date;
}
// Call the helper service, which is now available as a class member.
var response = await _reportHelper.GetDailyProjectReportWithOutTenant(projectId);
var response = await _reportHelper.GetDailyProjectReportWithOutTenant(projectId, reportDate);
// Check if the report data was found.
if (response == null)

View File

@ -96,6 +96,75 @@ namespace Marco.Pms.Services.Controllers
}
#endregion
#region =================================================================== Project Branch Functions ===================================================================
[HttpGet("branch/list/{projectId}")]
public async Task<IActionResult> GetProjectBranchListByProject(Guid projectId, [FromQuery] string? searchString, [FromQuery] bool isActive = true,
[FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 20)
{
Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
var response = await _serviceProject.GetProjectBranchListByProjectAsync(projectId, isActive, searchString, pageNumber, pageSize, loggedInEmployee, tenantId);
return StatusCode(response.StatusCode, response);
}
[HttpGet("branch/details/{id}")]
public async Task<IActionResult> GetProjectBranchDetails(Guid id)
{
Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
var response = await _serviceProject.GetProjectBranchDetailsAsync(id, loggedInEmployee, tenantId);
return StatusCode(response.StatusCode, response);
}
[HttpGet("branch-type/list")]
public async Task<IActionResult> GetBranchTypeList([FromQuery] string? searchString)
{
Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
var response = await _serviceProject.GetBranchTypeListAsync(searchString, loggedInEmployee, tenantId);
return StatusCode(response.StatusCode, response);
}
[HttpPost("branch/create")]
public async Task<IActionResult> CreateProjectBranch([FromBody] ProjectBranchDto model)
{
Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
var response = await _serviceProject.CreateProjectBranchAsync(model, loggedInEmployee, tenantId);
if (response.Success)
{
var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Project_Branch", Response = response.Data };
await _signalR.SendNotificationAsync(notification);
}
return StatusCode(response.StatusCode, response);
}
[HttpPut("branch/edit/{id}")]
public async Task<IActionResult> UpdateProjectBranch(Guid id, ProjectBranchDto model)
{
Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
var response = await _serviceProject.UpdateProjectBranchAsync(id, model, loggedInEmployee, tenantId);
if (response.Success)
{
var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Project_Branch", Response = response.Data };
await _signalR.SendNotificationAsync(notification);
}
return StatusCode(response.StatusCode, response);
}
[HttpDelete("branch/delete/{id}")]
public async Task<IActionResult> DeleteProjectBranch(Guid id, [FromQuery] bool isActive = false)
{
Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
var response = await _serviceProject.DeleteProjectBranchAsync(id, isActive, loggedInEmployee, tenantId);
if (response.Success)
{
var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Project_Branch", Response = response.Data };
await _signalR.SendNotificationAsync(notification);
}
return StatusCode(response.StatusCode, response);
}
#endregion
#region =================================================================== Service Project Allocation Functions ===================================================================
[HttpGet("get/allocation/list")]
@ -127,10 +196,11 @@ namespace Marco.Pms.Services.Controllers
#region =================================================================== Job Tickets Functions ===================================================================
[HttpGet("job/list")]
public async Task<IActionResult> GetJobTicketsList([FromQuery] Guid? projectId, [FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 20, [FromQuery] bool isActive = true)
public async Task<IActionResult> GetJobTicketsList([FromQuery] Guid? projectId, [FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 20, [FromQuery] bool isActive = true,
[FromQuery] bool isArchive = false)
{
Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
var response = await _serviceProject.GetJobTicketsListAsync(projectId, pageNumber, pageSize, isActive, loggedInEmployee, tenantId);
var response = await _serviceProject.GetJobTicketsListAsync(projectId, pageNumber, pageSize, isActive, isArchive, loggedInEmployee, tenantId);
return StatusCode(response.StatusCode, response);
}

View File

@ -0,0 +1,96 @@
using Marco.Pms.Model.Filters;
using System.Linq.Dynamic.Core;
namespace Marco.Pms.Services.Extensions
{
/// <summary>
/// Enterprise-grade extension methods for applying dynamic filters and sorting to IQueryable sources.
/// </summary>
public static class QueryableExtensions
{
public static IQueryable<T> ApplyCustomFilters<T>(this IQueryable<T> query, AdvanceFilter? filter, string defaultSortColumn)
{
// 1. Apply Advanced Filters (Arithmetic/Logic)
if (filter?.AdvanceFilters != null && filter.AdvanceFilters.Any())
{
foreach (var advanceFilter in filter.AdvanceFilters)
{
if (string.IsNullOrWhiteSpace(advanceFilter.Column)) continue;
string op = advanceFilter.Opration.ToLower().Trim();
string predicate = "";
// Map your custom strings to Dynamic LINQ operators
switch (op)
{
case "greater than": predicate = $"{advanceFilter.Column} > @0"; break;
case "less than": predicate = $"{advanceFilter.Column} < @0"; break;
case "equal to": predicate = $"{advanceFilter.Column} == @0"; break;
case "not equal": predicate = $"{advanceFilter.Column} != @0"; break;
case "greater or equal": predicate = $"{advanceFilter.Column} >= @0"; break;
case "smaller or equal": predicate = $"{advanceFilter.Column} <= @0"; break;
default: continue;
}
if (!string.IsNullOrEmpty(predicate))
{
// Dynamic LINQ handles type conversion (string "100" to int 100) automatically
query = query.Where(predicate, advanceFilter.Value);
}
}
}
// 2. Apply Sorting
if (filter?.SortFilters != null && filter.SortFilters.Any())
{
// Build a comma-separated sort string: "Column1 desc, Column2 asc"
var sortExpressions = new List<string>();
foreach (var sort in filter.SortFilters)
{
string direction = sort.SortDescending ? "desc" : "asc";
sortExpressions.Add($"{sort.Column} {direction}");
}
query = query.OrderBy(string.Join(", ", sortExpressions));
}
else
{
// Default sorting
query = query.OrderBy($"{defaultSortColumn} desc");
}
// 3.Apply GroupBy
if (!string.IsNullOrEmpty(filter?.GroupByColumn))
{
query.GroupBy(filter.GroupByColumn, "it")
.Select("new (Key, it as Items)"); // Reshape to { Key: "Value", Items: [...] }
}
return query;
}
public static IQueryable<T> ApplySearchFilters<T>(this IQueryable<T> query, List<SearchItem> searchFilters)
{
// 1. Apply Search Filters (Contains/Text search)
if (searchFilters.Any())
{
foreach (var search in searchFilters)
{
if (string.IsNullOrWhiteSpace(search.Column) || string.IsNullOrWhiteSpace(search.Value)) continue;
// Generates: x.Column.Contains("Value")
// Case insensitive logic can be handled here if needed
query = query.Where($"{search.Column}.Contains(@0)", search.Value);
}
}
return query;
}
public static IQueryable<T> ApplyGroupByFilters<T>(this IQueryable<T> query, string groupByColumn)
{
query.GroupBy(groupByColumn, "it")
.Select("new (Key, it as Items)"); // Reshape to { Key: "Value", Items: [...] }
return query;
}
}
}

View File

@ -1162,35 +1162,23 @@ namespace Marco.Pms.Services.Helpers
var processedByIds = model.Select(m => m.ProcessedById).ToList();
var paidByIds = model.Select(m => m.PaidById).ToList();
var projectTask = Task.Run(async () =>
var infraProjectTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Projects.AsNoTracking().Where(p => projectIds.Contains(p.Id) && p.TenantId == tenantId).ToListAsync();
return await dbContext.Projects
.AsNoTracking()
.Where(p => projectIds.Contains(p.Id) && p.TenantId == tenantId)
.Select(p => _mapper.Map<ProjectBasicMongoDB>(p))
.ToListAsync();
});
var paidByTask = Task.Run(async () =>
var serviceProjectTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.Include(e => e.JobRole).AsNoTracking().Where(e => paidByIds.Contains(e.Id) && e.TenantId == tenantId).ToListAsync();
});
var createdByTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.Include(e => e.JobRole).AsNoTracking().Where(e => createdByIds.Contains(e.Id) && e.TenantId == tenantId).ToListAsync();
});
var reviewedByTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.Include(e => e.JobRole).AsNoTracking().Where(e => reviewedByIds.Contains(e.Id) && e.TenantId == tenantId).ToListAsync();
});
var approvedByTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.Include(e => e.JobRole).AsNoTracking().Where(e => approvedByIds.Contains(e.Id) && e.TenantId == tenantId).ToListAsync();
});
var processedByTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.Include(e => e.JobRole).AsNoTracking().Where(e => processedByIds.Contains(e.Id) && e.TenantId == tenantId).ToListAsync();
return await dbContext.ServiceProjects
.AsNoTracking()
.Where(sp => projectIds.Contains(sp.Id) && sp.TenantId == tenantId)
.Select(sp => _mapper.Map<ProjectBasicMongoDB>(sp))
.ToListAsync();
});
var expenseCategoryTask = Task.Run(async () =>
{
@ -1202,6 +1190,15 @@ namespace Marco.Pms.Services.Helpers
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.PaymentModeMatser.AsNoTracking().Where(pm => paymentModeIds.Contains(pm.Id)).ToListAsync();
});
await Task.WhenAll(infraProjectTask, serviceProjectTask, expenseCategoryTask, paymentModeTask);
var projects = infraProjectTask.Result;
projects.AddRange(serviceProjectTask.Result);
var expenseCategories = expenseCategoryTask.Result;
var paymentModes = paymentModeTask.Result;
var statusMappingTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -1218,6 +1215,43 @@ namespace Marco.Pms.Services.Helpers
NextStatus = g.Select(s => s.NextStatus).OrderBy(s => s!.Name).ToList()
}).ToListAsync();
});
var paidByTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.Include(e => e.JobRole).AsNoTracking().Where(e => paidByIds.Contains(e.Id) && e.TenantId == tenantId).ToListAsync();
});
var createdByTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.Include(e => e.JobRole).AsNoTracking().Where(e => createdByIds.Contains(e.Id) && e.TenantId == tenantId).ToListAsync();
});
await Task.WhenAll(statusMappingTask, paidByTask, createdByTask);
var statusMappings = statusMappingTask.Result;
var paidBys = paidByTask.Result;
var createdBys = createdByTask.Result;
var reviewedByTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.Include(e => e.JobRole).AsNoTracking().Where(e => reviewedByIds.Contains(e.Id) && e.TenantId == tenantId).ToListAsync();
});
var approvedByTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.Include(e => e.JobRole).AsNoTracking().Where(e => approvedByIds.Contains(e.Id) && e.TenantId == tenantId).ToListAsync();
});
var processedByTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.Include(e => e.JobRole).AsNoTracking().Where(e => processedByIds.Contains(e.Id) && e.TenantId == tenantId).ToListAsync();
});
await Task.WhenAll(reviewedByTask, approvedByTask, processedByTask);
var reviewedBys = reviewedByTask.Result;
var approvedBys = approvedByTask.Result;
var processedBy = processedByTask.Result;
var statusTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -1249,26 +1283,17 @@ namespace Marco.Pms.Services.Helpers
.ToListAsync();
});
// Await all prerequisite checks at once.
await Task.WhenAll(projectTask, expenseCategoryTask, paymentModeTask, statusMappingTask, paidByTask, createdByTask, reviewedByTask, approvedByTask,
processedByTask, statusTask, billAttachmentsTask);
var projects = projectTask.Result;
var expenseCategories = expenseCategoryTask.Result;
var paymentModes = paymentModeTask.Result;
var statusMappings = statusMappingTask.Result;
var paidBys = paidByTask.Result;
var createdBys = createdByTask.Result;
var reviewedBys = reviewedByTask.Result;
var approvedBys = approvedByTask.Result;
var processedBy = processedByTask.Result;
// Await all prerequisite checks at once.
await Task.WhenAll(statusTask, billAttachmentsTask);
var billAttachments = billAttachmentsTask.Result;
expenseList = model.Select(m =>
{
var response = _mapper.Map<ExpenseDetailsMongoDB>(m);
response.Project = projects.Where(p => p.Id == m.ProjectId).Select(p => _mapper.Map<ProjectBasicMongoDB>(p)).FirstOrDefault() ?? new ProjectBasicMongoDB();
response.Project = projects.Where(p => Guid.Parse(p.Id) == m.ProjectId).FirstOrDefault() ?? new ProjectBasicMongoDB();
response.PaidBy = paidBys.Where(p => p.Id == m.PaidById).Select(p => _mapper.Map<BasicEmployeeMongoDB>(p)).FirstOrDefault() ?? new BasicEmployeeMongoDB();
response.CreatedBy = createdBys.Where(e => e.Id == m.CreatedById).Select(e => _mapper.Map<BasicEmployeeMongoDB>(e)).FirstOrDefault() ?? new BasicEmployeeMongoDB();
response.ReviewedBy = reviewedBys.Where(e => e.Id == m.CreatedById).Select(e => _mapper.Map<BasicEmployeeMongoDB>(e)).FirstOrDefault();
@ -1292,35 +1317,23 @@ namespace Marco.Pms.Services.Helpers
}
private async Task<ExpenseDetailsMongoDB> GetAllExpnesRelatedTablesForSingle(Expenses model, Guid tenantId)
{
var projectTask = Task.Run(async () =>
var infraProjectTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == model.ProjectId && p.TenantId == tenantId);
return await dbContext.Projects
.AsNoTracking()
.Where(p => p.Id == model.ProjectId && p.TenantId == tenantId)
.Select(p => _mapper.Map<ProjectBasicMongoDB>(p))
.FirstOrDefaultAsync();
});
var paidByTask = Task.Run(async () =>
var serviceProjectTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.Include(e => e.JobRole).AsNoTracking().FirstOrDefaultAsync(e => e.Id == model.PaidById && e.TenantId == tenantId);
});
var createdByTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.Include(e => e.JobRole).AsNoTracking().FirstOrDefaultAsync(e => e.Id == model.CreatedById && e.TenantId == tenantId);
});
var reviewedByTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.Include(e => e.JobRole).AsNoTracking().FirstOrDefaultAsync(e => e.Id == model.ReviewedById && e.TenantId == tenantId);
});
var approvedByTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.Include(e => e.JobRole).AsNoTracking().FirstOrDefaultAsync(e => e.Id == model.ApprovedById && e.TenantId == tenantId);
});
var processedByTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.Include(e => e.JobRole).AsNoTracking().FirstOrDefaultAsync(e => e.Id == model.ProcessedById && e.TenantId == tenantId);
return await dbContext.ServiceProjects
.AsNoTracking()
.Where(sp => sp.Id == model.ProjectId && sp.TenantId == tenantId)
.Select(sp => _mapper.Map<ProjectBasicMongoDB>(sp))
.FirstOrDefaultAsync();
});
var expenseCategoryTask = Task.Run(async () =>
{
@ -1332,6 +1345,12 @@ namespace Marco.Pms.Services.Helpers
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.PaymentModeMatser.AsNoTracking().FirstOrDefaultAsync(pm => pm.Id == model.PaymentModeId);
});
await Task.WhenAll(infraProjectTask, serviceProjectTask, expenseCategoryTask, paymentModeTask);
var project = infraProjectTask.Result ?? serviceProjectTask.Result ?? new ProjectBasicMongoDB();
var expenseCategory = expenseCategoryTask.Result;
var paymentMode = paymentModeTask.Result;
var statusMappingTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -1348,6 +1367,43 @@ namespace Marco.Pms.Services.Helpers
NextStatus = g.Select(s => s.NextStatus).OrderBy(s => s!.Name).ToList()
}).FirstOrDefaultAsync();
});
var paidByTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.Include(e => e.JobRole).AsNoTracking().FirstOrDefaultAsync(e => e.Id == model.PaidById && e.TenantId == tenantId);
});
var createdByTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.Include(e => e.JobRole).AsNoTracking().FirstOrDefaultAsync(e => e.Id == model.CreatedById && e.TenantId == tenantId);
});
await Task.WhenAll(statusMappingTask, paidByTask, createdByTask);
var statusMapping = statusMappingTask.Result;
var paidBy = paidByTask.Result;
var createdBy = createdByTask.Result;
var reviewedByTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.Include(e => e.JobRole).AsNoTracking().FirstOrDefaultAsync(e => e.Id == model.ReviewedById && e.TenantId == tenantId);
});
var approvedByTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.Include(e => e.JobRole).AsNoTracking().FirstOrDefaultAsync(e => e.Id == model.ApprovedById && e.TenantId == tenantId);
});
var processedByTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.Include(e => e.JobRole).AsNoTracking().FirstOrDefaultAsync(e => e.Id == model.ProcessedById && e.TenantId == tenantId);
});
await Task.WhenAll(reviewedByTask, approvedByTask, processedByTask);
var reviewedBy = reviewedByTask.Result;
var approvedBy = approvedByTask.Result;
var processedBy = processedByTask.Result;
var statusTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -1378,25 +1434,13 @@ namespace Marco.Pms.Services.Helpers
.FirstOrDefaultAsync();
});
// Await all prerequisite checks at once.
await Task.WhenAll(projectTask, expenseCategoryTask, paymentModeTask, statusMappingTask, paidByTask, createdByTask, reviewedByTask, approvedByTask,
processedByTask, statusTask, billAttachmentsTask);
var project = projectTask.Result;
var expenseCategory = expenseCategoryTask.Result;
var paymentMode = paymentModeTask.Result;
var statusMapping = statusMappingTask.Result;
var paidBy = paidByTask.Result;
var createdBy = createdByTask.Result;
var reviewedBy = reviewedByTask.Result;
var approvedBy = approvedByTask.Result;
var processedBy = processedByTask.Result;
await Task.WhenAll(statusTask, billAttachmentsTask);
var billAttachment = billAttachmentsTask.Result;
var response = _mapper.Map<ExpenseDetailsMongoDB>(model);
response.Project = _mapper.Map<ProjectBasicMongoDB>(project);
response.Project = project;
response.PaidBy = _mapper.Map<BasicEmployeeMongoDB>(paidBy);
response.CreatedBy = _mapper.Map<BasicEmployeeMongoDB>(createdBy);
response.ReviewedBy = _mapper.Map<BasicEmployeeMongoDB>(reviewedBy);

View File

@ -25,10 +25,9 @@ namespace Marco.Pms.Services.Helpers
_cache = cache;
}
public async Task<ProjectStatisticReport?> GetDailyProjectReportWithOutTenant(Guid projectId)
public async Task<ProjectStatisticReport?> GetDailyProjectReportWithOutTenant(Guid projectId, DateTime reportDate)
{
// await _cache.GetBuildingAndFloorByWorkAreaId();
DateTime reportDate = DateTime.UtcNow.AddDays(-1).Date;
var project = await _cache.GetProjectDetailsWithBuildings(projectId);
if (project == null)
{

View File

@ -162,6 +162,10 @@ namespace Marco.Pms.Services.MappingProfiles
dest => dest.ProjectStatusId,
opt => opt.MapFrom(src => Guid.Empty)
);
CreateMap<ProjectBasicMongoDB, BasicProjectVM>()
.ForMember(
dest => dest.Id,
opt => opt.MapFrom(src => new Guid(src.Id)));
CreateMap<ProjectMongoDB, Project>()
.ForMember(
@ -197,9 +201,17 @@ namespace Marco.Pms.Services.MappingProfiles
CreateMap<ServiceProject, BasicProjectVM>();
CreateMap<ServiceProject, ServiceProjectVM>();
CreateMap<ServiceProject, BasicServiceProjectVM>();
CreateMap<ServiceProject, ProjectBasicMongoDB>();
CreateMap<ServiceProject, ServiceProjectDetailsVM>();
CreateMap<ServiceProjectAllocation, ServiceProjectAllocationVM>();
#region ======================================================= Project Branch =======================================================
CreateMap<ProjectBranchDto, ProjectBranch>();
CreateMap<ProjectBranch, ProjectBranchVM>();
CreateMap<ProjectBranch, ProjectBranchDetailsVM>();
CreateMap<ProjectBranch, BasicProjectBranchVM>();
#endregion
//#region ======================================================= Talking Points =======================================================
//CreateMap<TalkingPointDto, TalkingPoint>();
//CreateMap<TalkingPoint, TalkingPointVM>();

View File

@ -42,6 +42,7 @@
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.MongoDB" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.7.0" />
</ItemGroup>
<ItemGroup>

View File

@ -474,6 +474,12 @@ namespace Marco.Pms.Services.Service
return await _dbContext.ContactsEmails.Where(e => contactIds.Contains(e.ContactId)).ToListAsync();
});
await Task.WhenAll(contactTask, phoneTask, emailTask);
var contacts = contactTask.Result;
var phones = phoneTask.Result;
var emails = emailTask.Result;
var tagTask = Task.Run(async () =>
{
await using var _dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -493,11 +499,8 @@ namespace Marco.Pms.Services.Service
return await _dbContext.ContactBucketMappings.Where(cb => contactIds.Contains(cb.ContactId)).ToListAsync();
});
await Task.WhenAll(contactTask);
await Task.WhenAll(tagTask, contactProjectTask, contactBucketTask);
var contacts = contactTask.Result;
var phones = phoneTask.Result;
var emails = emailTask.Result;
var tags = tagTask.Result;
var contactProjects = contactProjectTask.Result;
var contactBuckets = contactBucketTask.Result;
@ -587,6 +590,13 @@ namespace Marco.Pms.Services.Service
_logger.LogWarning("Employee with ID {LoggedInEmployeeId} tries to update contact with ID {ContactId} is not found in database", loggedInEmployeeId);
return ApiResponse<object>.ErrorResponse("Contact not found", "Contact not found", 404);
}
var projectIds = await dbContext.ContactProjectMappings
.AsNoTracking()
.Where(cp => cp.ContactId == contact.Id && cp.TenantId == tenantId)
.Select(cp => cp.ProjectId)
.ToListAsync();
ContactProfileVM contactVM = _mapper.Map<ContactProfileVM>(contact);
var phonesTask = Task.Run(async () =>
@ -609,21 +619,26 @@ namespace Marco.Pms.Services.Service
.ToListAsync();
});
var contactProjectsTask = Task.Run(async () =>
var infraProjectTask = Task.Run(async () =>
{
await using var taskDbContext = await _dbContextFactory.CreateDbContextAsync();
return await taskDbContext.ContactProjectMappings
.AsNoTracking()
.Include(cp => cp.Project)
.Where(cp => cp.ContactId == contact.Id && cp.Project != null && cp.Project.TenantId == tenantId)
.Select(cp => new BasicProjectVM
{
Id = cp.Project!.Id,
Name = cp.Project.Name
})
.ToListAsync();
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.Projects.Where(p => projectIds.Contains(p.Id) && p.TenantId == tenantId).Select(p => _mapper.Map<BasicProjectVM>(p)).ToListAsync();
});
var serviceProjectTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ServiceProjects.Where(sp => projectIds.Contains(sp.Id) && sp.TenantId == tenantId).Select(sp => _mapper.Map<BasicProjectVM>(sp)).ToListAsync();
});
await Task.WhenAll(phonesTask, emailsTask, infraProjectTask, serviceProjectTask);
contactVM.ContactPhones = phonesTask.Result;
contactVM.ContactEmails = emailsTask.Result;
var projects = infraProjectTask.Result;
projects.AddRange(serviceProjectTask.Result);
contactVM.Projects = projects;
var contactBucketsTask = Task.Run(async () =>
{
await using var taskDbContext = await _dbContextFactory.CreateDbContextAsync();
@ -679,12 +694,10 @@ namespace Marco.Pms.Services.Service
.ToListAsync();
});
await Task.WhenAll(phonesTask, emailsTask, contactProjectsTask, contactBucketsTask, contactTagsTask, contactNotesTask);
contactVM.ContactPhones = phonesTask.Result;
contactVM.ContactEmails = emailsTask.Result;
await Task.WhenAll(contactBucketsTask, contactTagsTask, contactNotesTask);
contactVM.Tags = contactTagsTask.Result;
contactVM.Buckets = contactBucketsTask.Result;
contactVM.Projects = contactProjectsTask.Result;
contactVM.Notes = contactNotesTask.Result;
_logger.LogInfo("Employee ID {EmployeeId} fetched profile of contact {ContactId}", loggedInEmployeeId, contact.Id);
return ApiResponse<object>.SuccessResponse(contactVM, "Contact profile fetched successfully");
@ -3289,11 +3302,18 @@ namespace Marco.Pms.Services.Service
{
if (!(dto.ProjectIds?.Any() ?? false)) return new List<ContactProjectMapping>();
var validProjectIds = await _context.Projects
var infraProjectIds = await _context.Projects
.Where(p => dto.ProjectIds.Contains(p.Id) && p.TenantId == tenantId)
.Select(p => p.Id)
.ToListAsync();
var serviceProjectIds = await _context.ServiceProjects
.Where(sp => dto.ProjectIds.Contains(sp.Id) && sp.IsActive && sp.TenantId == tenantId)
.Select(sp => sp.Id)
.ToListAsync();
var validProjectIds = infraProjectIds.Concat(serviceProjectIds).Distinct().Where(p => p != Guid.Empty).ToList();
var mappings = validProjectIds.Select(projectId => new ContactProjectMapping
{
ProjectId = projectId,

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@ namespace Marco.Pms.Services.Service
{
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILoggingService _logger;
private static readonly Guid Review = Guid.Parse("6537018f-f4e9-4cb3-a210-6c3b2da999d7");
private static readonly Guid RejectedByReviewer = Guid.Parse("965eda62-7907-4963-b4a1-657fb0b2724b");
@ -25,10 +26,12 @@ namespace Marco.Pms.Services.Service
private static readonly Guid Processed = Guid.Parse("61578360-3a49-4c34-8604-7b35a3787b95");
public FirebaseService(IDbContextFactory<ApplicationDbContext> dbContextFactory,
IServiceScopeFactory serviceScopeFactory)
IServiceScopeFactory serviceScopeFactory,
ILoggingService logger)
{
_dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory));
_serviceScopeFactory = serviceScopeFactory ?? throw new ArgumentNullException(nameof(serviceScopeFactory));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
// Auth Controller
@ -124,171 +127,174 @@ namespace Marco.Pms.Services.Service
await SendMessageToMultipleDevicesOnlyDataAsync(registrationTokensForData, data);
}
// Attendance Controller
public async Task SendAttendanceMessageAsync(Guid projectId, string name, ATTENDANCE_MARK_TYPE markType, Guid employeeId, Guid tenantId)
#region =================================================================== Attendance Functions ===================================================================
/// <summary>
/// Sends attendance-related notifications for the specified project and employee attendance action.
/// </summary>
/// <param name="projectId">The Id of the project where attendance is marked.</param>
/// <param name="name">Name of the employee marking attendance.</param>
/// <param name="markType">Type of attendance mark action.</param>
/// <param name="employeeId">Employee for whom attendance is marked.</param>
/// <param name="origin">Origin of the request (optional), used to filter notifications.</param>
/// <param name="loggedInEmployeeId">Employee Id of the caller (to exclude self-notifications).</param>
/// <param name="tenantId">Tenant identifier for multi-tenant setup.</param>
/// <returns>Task representing the asynchronous operation.</returns>
public async Task SendAttendanceMessageAsync(Guid projectId, string name, ATTENDANCE_MARK_TYPE markType, Guid employeeId, string origin, Guid loggedInEmployeeId, Guid tenantId)
{
await using var _context = await _dbContextFactory.CreateDbContextAsync();
var projectTask = Task.Run(async () =>
try
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.ProjectAllocations
.Include(pa => pa.Project)
.Where(pa => pa.ProjectId == projectId && pa.IsActive && pa.Project != null)
.GroupBy(pa => pa.ProjectId)
.Select(g => new
// Fetch project details and assigned employees grouped by ProjectId
var project = await dbContext.ProjectAllocations
.Include(pa => pa.Project)
.Where(pa => pa.ProjectId == projectId && pa.IsActive && pa.Project != null)
.GroupBy(pa => pa.ProjectId)
.Select(g => new
{
ProjectName = g.Select(pa => pa.Project!.Name).FirstOrDefault(),
EmployeeIds = g.Select(pa => pa.EmployeeId).Distinct().ToList()
})
.FirstOrDefaultAsync();
if (project == null)
{
ProjectName = g.Select(pa => pa.Project!.Name).FirstOrDefault(),
EmployeeIds = g.Select(pa => pa.EmployeeId).Distinct().ToList()
}).FirstOrDefaultAsync();
});
_logger.LogWarning("No active project allocations found for ProjectId: {ProjectId}", projectId);
return; // or throw if critical
}
var teamAttendanceRoleTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.RolePermissionMappings
.Where(rp => rp.FeaturePermissionId == PermissionsMaster.TeamAttendance)
.Select(rp => rp.ApplicationRoleId).ToListAsync();
});
var regularizeAttendanceRoleTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.RolePermissionMappings
.Where(rp => rp.FeaturePermissionId == PermissionsMaster.RegularizeAttendance)
.Select(rp => rp.ApplicationRoleId).ToListAsync();
});
var manageProjectsRoleTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.RolePermissionMappings
.Where(rp => rp.FeaturePermissionId == PermissionsMaster.ManageProject)
.Select(rp => rp.ApplicationRoleId).ToListAsync();
});
var projectAssignedEmployeeIds = project.EmployeeIds;
await Task.WhenAll(projectTask, teamAttendanceRoleTask, manageProjectsRoleTask, regularizeAttendanceRoleTask);
// Load required role IDs in parallel for efficiency
var teamAttendanceRoleIdsTask = dbContext.RolePermissionMappings.Where(rp => rp.FeaturePermissionId == PermissionsMaster.TeamAttendance).Select(rp => rp.ApplicationRoleId).ToListAsync();
var regularizeAttendanceRoleIdsTask = dbContext.RolePermissionMappings.Where(rp => rp.FeaturePermissionId == PermissionsMaster.RegularizeAttendance).Select(rp => rp.ApplicationRoleId).ToListAsync();
var manageProjectsRoleIdsTask = dbContext.RolePermissionMappings.Where(rp => rp.FeaturePermissionId == PermissionsMaster.ManageProject).Select(rp => rp.ApplicationRoleId).ToListAsync();
var teamAttendanceRoleIds = teamAttendanceRoleTask.Result;
var regularizeAttendanceRoleIds = regularizeAttendanceRoleTask.Result;
var manageProjectsRoleIds = manageProjectsRoleTask.Result;
var project = projectTask.Result;
await Task.WhenAll(teamAttendanceRoleIdsTask, regularizeAttendanceRoleIdsTask, manageProjectsRoleIdsTask);
List<Guid> projectAssignedEmployeeIds = project?.EmployeeIds ?? new List<Guid>();
var teamAttendanceRoleIds = teamAttendanceRoleIdsTask.Result;
var regularizeAttendanceRoleIds = regularizeAttendanceRoleIdsTask.Result;
var manageProjectsRoleIds = manageProjectsRoleIdsTask.Result;
var employeeIdsTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.EmployeeRoleMappings
.Where(er => (projectAssignedEmployeeIds.Contains(er.EmployeeId) || manageProjectsRoleIds.Contains(er.RoleId)) && teamAttendanceRoleIds.Contains(er.RoleId))
.Select(er => er.EmployeeId)
.ToListAsync();
});
var teamEmployeeIdsTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.EmployeeRoleMappings
.Where(er => projectAssignedEmployeeIds.Contains(er.EmployeeId) || manageProjectsRoleIds.Contains(er.RoleId))
.Select(er => er.EmployeeId)
.ToListAsync();
});
// Fetch employees eligible for attendance notifications
var employeeIds = await dbContext.EmployeeRoleMappings
.Where(er => (projectAssignedEmployeeIds.Contains(er.EmployeeId) || manageProjectsRoleIds.Contains(er.RoleId))
&& teamAttendanceRoleIds.Contains(er.RoleId))
.Select(er => er.EmployeeId)
.Distinct()
.ToListAsync();
await Task.WhenAll(employeeIdsTask, teamEmployeeIdsTask);
// Fetch team employees (for data-only notifications)
var teamEmployeeIds = await dbContext.EmployeeRoleMappings
.Where(er => projectAssignedEmployeeIds.Contains(er.EmployeeId) || manageProjectsRoleIds.Contains(er.RoleId))
.Select(er => er.EmployeeId)
.Distinct()
.ToListAsync();
var employeeIds = employeeIdsTask.Result;
var teamEmployeeIds = teamEmployeeIdsTask.Result;
List<Guid> messageNotificationIds = new();
var mesaageNotificationIds = new List<Guid>();
Notification notificationFirebase;
switch (markType)
{
case ATTENDANCE_MARK_TYPE.CHECK_IN:
notificationFirebase = new Notification
// Construct notification content based on attendance mark type
Notification notificationFirebase = markType switch
{
ATTENDANCE_MARK_TYPE.CHECK_IN => new Notification
{
Title = "Attendance Update",
Body = $" {name} has checked in for project {project?.ProjectName ?? ""}."
};
mesaageNotificationIds.AddRange(employeeIds);
break;
case ATTENDANCE_MARK_TYPE.CHECK_OUT:
notificationFirebase = new Notification
Body = $"{name} has checked in for project {project.ProjectName}."
},
ATTENDANCE_MARK_TYPE.CHECK_OUT => new Notification
{
Title = "Attendance Update",
Body = $" {name} has checked out for project {project?.ProjectName ?? ""}."
};
mesaageNotificationIds.AddRange(employeeIds);
break;
case ATTENDANCE_MARK_TYPE.REQUEST_REGULARIZE:
notificationFirebase = new Notification
Body = $"{name} has checked out for project {project.ProjectName}."
},
ATTENDANCE_MARK_TYPE.REQUEST_REGULARIZE => new Notification
{
Title = "Regularization Request",
Body = $" {name} has submitted a regularization request for project {project?.ProjectName ?? ""}."
};
mesaageNotificationIds = await _context.EmployeeRoleMappings
.Where(er => (projectAssignedEmployeeIds.Contains(er.EmployeeId) || manageProjectsRoleIds.Contains(er.RoleId)) && regularizeAttendanceRoleIds.Contains(er.RoleId))
.Select(er => er.EmployeeId)
.ToListAsync();
break;
case ATTENDANCE_MARK_TYPE.REGULARIZE:
notificationFirebase = new Notification
Body = $"{name} has submitted a regularization request for project {project.ProjectName}."
},
ATTENDANCE_MARK_TYPE.REGULARIZE => new Notification
{
Title = " Regularization Approved",
Body = $" {name}'s regularization request for project {project?.ProjectName ?? ""} has been accepted."
};
mesaageNotificationIds.Add(employeeId);
break;
case ATTENDANCE_MARK_TYPE.REGULARIZE_REJECT:
notificationFirebase = new Notification
Title = "Regularization Approved",
Body = $"{name}'s regularization request for project {project.ProjectName} has been accepted."
},
ATTENDANCE_MARK_TYPE.REGULARIZE_REJECT => new Notification
{
Title = "Regularization Denied",
Body = $" {name}'s regularization request for project {project?.ProjectName ?? ""} has been rejected."
};
mesaageNotificationIds.Add(employeeId);
break;
default:
notificationFirebase = new Notification
Body = $"{name}'s regularization request for project {project.ProjectName} has been rejected."
},
_ => new Notification
{
Title = "Attendance Update",
Body = $" {name} has update his/her attendance for project {project?.ProjectName ?? ""}."
};
break;
Body = $"{name} has updated attendance for project {project.ProjectName}."
}
};
// Set notification recipients based on type
if (markType == ATTENDANCE_MARK_TYPE.REQUEST_REGULARIZE)
{
messageNotificationIds = await dbContext.EmployeeRoleMappings
.Where(er => (projectAssignedEmployeeIds.Contains(er.EmployeeId) || manageProjectsRoleIds.Contains(er.RoleId))
&& regularizeAttendanceRoleIds.Contains(er.RoleId))
.Select(er => er.EmployeeId)
.Distinct()
.ToListAsync();
}
else if (markType == ATTENDANCE_MARK_TYPE.REGULARIZE || markType == ATTENDANCE_MARK_TYPE.REGULARIZE_REJECT)
{
messageNotificationIds.Add(employeeId);
}
else
{
messageNotificationIds.AddRange(employeeIds);
}
var dataPayload = new Dictionary<string, string>
{
{ "Keyword", "Attendance" },
{ "ProjectId", projectId.ToString() },
{ "Action", markType.ToString() }
};
// Filter out current (logged-in) employee from notifications if origin is not provided (optional)
if (string.IsNullOrWhiteSpace(origin))
{
messageNotificationIds = messageNotificationIds.Where(e => e != Guid.Empty && e != loggedInEmployeeId).Distinct().ToList();
teamEmployeeIds = teamEmployeeIds.Where(e => e != Guid.Empty && e != loggedInEmployeeId).Distinct().ToList();
}
// Fetch FCM tokens for notification recipients
var registrationTokensForNotification = messageNotificationIds.Count > 0
? await dbContext.FCMTokenMappings.Where(ft => messageNotificationIds.Contains(ft.EmployeeId) && ft.ExpiredAt >= DateTime.UtcNow && ft.TenantId == tenantId).Select(ft => ft.FcmToken).ToListAsync()
: new List<string>();
// Fetch FCM tokens for data-only notification recipients
var registrationTokensForData = teamEmployeeIds.Count > 0
? await dbContext.FCMTokenMappings.Where(ft => teamEmployeeIds.Contains(ft.EmployeeId) && ft.ExpiredAt >= DateTime.UtcNow && ft.TenantId == tenantId).Select(ft => ft.FcmToken).ToListAsync()
: new List<string>();
// Send notifications concurrently
var sendNotificationTask = registrationTokensForNotification.Count > 0
? SendMessageToMultipleDevicesWithDataAsync(registrationTokensForNotification, notificationFirebase, dataPayload)
: Task.CompletedTask;
var sendDataOnlyTask = registrationTokensForData.Count > 0
? SendMessageToMultipleDevicesOnlyDataAsync(registrationTokensForData, dataPayload)
: Task.CompletedTask;
await Task.WhenAll(sendNotificationTask, sendDataOnlyTask);
_logger.LogInfo("Attendance message sent successfully for ProjectId {ProjectId}, MarkType {MarkType}, EmployeeId {EmployeeId}, Origin {Origin}",
projectId, markType, employeeId, origin);
}
// List of device registration tokens to send the message to
var data = new Dictionary<string, string>()
{
{ "Keyword", "Attendance" },
{ "ProjectId", projectId.ToString() },
{ "Action", markType.ToString() }
};
var registrationTokensForNotificationTask = Task.Run(async () =>
catch (Exception ex)
{
if (mesaageNotificationIds.Any())
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var registrationTokensForNotification = await dbContext.FCMTokenMappings
.Where(ft => mesaageNotificationIds.Contains(ft.EmployeeId) && ft.ExpiredAt >= DateTime.UtcNow && ft.TenantId == tenantId)
.Select(ft => ft.FcmToken).ToListAsync();
await SendMessageToMultipleDevicesWithDataAsync(registrationTokensForNotification, notificationFirebase, data);
}
});
var registrationTokensForDataTask = Task.Run(async () =>
{
if (teamEmployeeIds.Any())
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var registrationTokensForData = await dbContext.FCMTokenMappings
.Where(ft => teamEmployeeIds.Contains(ft.EmployeeId) && ft.ExpiredAt >= DateTime.UtcNow && ft.TenantId == tenantId)
.Select(ft => ft.FcmToken).ToListAsync();
await SendMessageToMultipleDevicesOnlyDataAsync(registrationTokensForData, data);
}
});
await Task.WhenAll(registrationTokensForNotificationTask, registrationTokensForDataTask);
_logger.LogError(ex, "Error sending attendance message for ProjectId {ProjectId}, MarkType {MarkType}, EmployeeId {EmployeeId}, Origin {Origin}",
projectId, markType, employeeId, origin);
}
}
#endregion
// Task Controller
public async Task SendAssignTaskMessageAsync(Guid workItemId, string name, List<Guid> teamMembers, Guid tenantId)
{

View File

@ -3,6 +3,7 @@ using Marco.Pms.Model.Employees;
using Marco.Pms.Model.Entitlements;
using Marco.Pms.Services.Helpers;
using MarcoBMS.Services.Helpers;
using MarcoBMS.Services.Service;
using Microsoft.EntityFrameworkCore;
namespace Marco.Pms.Services.Service
@ -12,49 +13,18 @@ namespace Marco.Pms.Services.Service
private readonly ApplicationDbContext _context;
private readonly RolesHelper _rolesHelper;
private readonly CacheUpdateHelper _cache;
private readonly ILoggingService _logger;
private readonly Guid tenantId;
public PermissionServices(ApplicationDbContext context, RolesHelper rolesHelper, CacheUpdateHelper cache, UserHelper userHelper)
public PermissionServices(ApplicationDbContext context, RolesHelper rolesHelper, CacheUpdateHelper cache, ILoggingService logger, UserHelper userHelper)
{
_context = context;
_rolesHelper = rolesHelper;
_cache = cache;
_logger = logger;
tenantId = userHelper.GetTenantId();
}
//public async Task<bool> HasPermission(Guid featurePermissionId, Guid employeeId, Guid? projectId = null)
//{
// var featurePermissionIds = await _cache.GetPermissions(employeeId);
// if (featurePermissionIds == null)
// {
// List<FeaturePermission> featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeId(employeeId);
// featurePermissionIds = featurePermission.Select(fp => fp.Id).ToList();
// }
// if (projectId != null)
// {
// var projectLevelPerissionIds = await _context.ProjectLevelPermissionMappings
// .Where(pl => pl.ProjectId == projectId.Value && pl.EmployeeId == employeeId).Select(pl => pl.PermissionId).ToListAsync();
// var projectLevelModuleIds = new HashSet<Guid>
// {
// Guid.Parse("53176ebf-c75d-42e5-839f-4508ffac3def"),
// Guid.Parse("9d4b5489-2079-40b9-bd77-6e1bf90bc19f"),
// Guid.Parse("81ab8a87-8ccd-4015-a917-0627cee6a100"),
// Guid.Parse("52c9cf54-1eb2-44d2-81bb-524cf29c0a94"),
// Guid.Parse("a8cf4331-8f04-4961-8360-a3f7c3cc7462")
// };
// var allProjectLevelPermissionIds = await _context.FeaturePermissions
// .Where(fp => projectLevelModuleIds.Contains(fp.FeatureId) && !projectLevelPerissionIds.Contains(fp.Id)).Select(fp => fp.Id).ToListAsync();
// featurePermissionIds.RemoveRange(allProjectLevelPermissionIds);
// featurePermissionIds.AddRange(projectLevelPerissionIds);
// featurePermissionIds = featurePermissionIds.Distinct().ToList();
// }
// var hasPermission = featurePermissionIds.Contains(featurePermissionId);
// return hasPermission;
//}
/// <summary>
/// Checks whether an employee has a specific feature permission, optionally within a project context.
/// </summary>
@ -156,5 +126,78 @@ namespace Marco.Pms.Services.Service
}
return projectIds.Contains(projectId);
}
/// <summary>
/// Determines if an employee has permission to access a specific service project.
/// Permission is granted if the user is directly allocated to the project OR
/// assigned to any active job ticket within the project.
/// </summary>
/// <param name="loggedInEmployeeId">The ID of the user requesting access.</param>
/// <param name="projectId">The ID of the project to access.</param>
/// <returns>True if access is allowed, otherwise False.</returns>
public async Task<bool> HasServiceProjectPermission(Guid loggedInEmployeeId, Guid projectId)
{
Guid ReviewDoneStatus = Guid.Parse("ed10ab57-dbaa-4ca5-8ecd-56745dcbdbd7");
Guid ClosedStatus = Guid.Parse("3ddeefb5-ae3c-4e10-a922-35e0a452bb69");
// 1. Input Validation
if (loggedInEmployeeId == Guid.Empty || projectId == Guid.Empty)
{
_logger.LogWarning("Permission check failed: Invalid input parameters. EmployeeId: {EmployeeId}, ProjectId: {ProjectId}", loggedInEmployeeId, projectId);
return false;
}
try
{
_logger.LogInfo("Starting permission check for Employee: {EmployeeId} on Project: {ProjectId}", loggedInEmployeeId, projectId);
// 2. Check Level 1: Is the user a generic Team Member of the project?
// This is usually the most common case, so checking this first saves complex query execution.
bool isTeamMember = await _context.ServiceProjectAllocations
.AsNoTracking() // Optimization: Read-only query does not need tracking
.AnyAsync(spa => spa.ProjectId == projectId
&& spa.EmployeeId == loggedInEmployeeId
&& spa.IsActive
&& spa.TenantId == tenantId);
if (isTeamMember)
{
_logger.LogInfo("Access Granted: User {EmployeeId} is a team member of Project {ProjectId}.", loggedInEmployeeId, projectId);
return true;
}
// 3. Check Level 2: Is the user assigned to any ACTIVE specific Job Ticket?
// Optimization: Combined the check for JobTicket and Mapping into a single Join query.
// This prevents pulling a list of JobIds into memory (fixing memory bloat) and reduces DB roundtrips.
bool hasActiveJobAssignment = await _context.JobTickets
.AsNoTracking()
.Where(jt => jt.ProjectId == projectId
&& jt.StatusId != ReviewDoneStatus
&& jt.StatusId != ClosedStatus
&& jt.IsActive)
.Join(_context.JobEmployeeMappings,
ticket => ticket.Id,
mapping => mapping.JobTicketId,
(ticket, mapping) => mapping)
.AnyAsync(mapping => mapping.AssigneeId == loggedInEmployeeId
&& mapping.TenantId == tenantId);
if (hasActiveJobAssignment)
{
_logger.LogInfo("Access Granted: User {EmployeeId} is assigned active tickets in Project {ProjectId}.", loggedInEmployeeId, projectId);
return true;
}
// 4. Default Deny
_logger.LogWarning("Access Denied: User {EmployeeId} has no permissions for Project {ProjectId}.", loggedInEmployeeId, projectId);
return false;
}
catch (Exception ex)
{
// 5. Robust Error Handling
// Log the full stack trace for debugging, but return false to maintain security (fail-closed).
_logger.LogError(ex, "An error occurred while checking permissions for Employee: {EmployeeId} on Project: {ProjectId}", loggedInEmployeeId, projectId);
return false;
}
}
}
}

View File

@ -220,6 +220,12 @@ namespace Marco.Pms.Services.Service
_logger.LogInfo("Cache HIT. All {ProjectCount} projects found in cache.", projectIds.Count);
}
if (pageNumber <= 0 || pageSize <= 0)
{
_logger.LogInfo("Successfully retrieved a total of {ProjectCount} projects.", responseVms.Count);
return ApiResponse<object>.SuccessResponse(responseVms, "Projects retrieved successfully.", 200);
}
var totalEntites = responseVms.Count;
var totalPages = (int)Math.Ceiling((double)totalEntites / pageSize);

View File

@ -12,7 +12,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces
Task SendEmployeeSuspendMessageAsync(Guid employeeId, Guid tenantId);
Task SendAttendanceMessageAsync(Guid projectId, string Name, ATTENDANCE_MARK_TYPE markType, Guid employeeId, Guid tenantId);
Task SendAttendanceMessageAsync(Guid projectId, string Name, ATTENDANCE_MARK_TYPE markType, Guid employeeId, string origin, Guid loggedInEmployeeId, Guid tenantId);
Task SendAssignTaskMessageAsync(Guid workItemId, string name, List<Guid> teamMembers, Guid tenantId);
Task SendReportTaskMessageAsync(Guid taskAllocationId, string name, Guid tenantId);
Task SendTaskCommentMessageAsync(Guid taskAllocationId, string name, Guid tenantId);

View File

@ -15,6 +15,15 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces
Task<ApiResponse<object>> DeActivateServiceProjectAsync(Guid id, bool isActive, Employee loggedInEmployee, Guid tenantId);
#endregion
#region =================================================================== Project Branch Functions ===================================================================
Task<ApiResponse<object>> GetProjectBranchListByProjectAsync(Guid projectId, bool isActive, string? searchString, int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> GetProjectBranchDetailsAsync(Guid id, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> GetBranchTypeListAsync(string? searchString, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> CreateProjectBranchAsync(ProjectBranchDto model, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> UpdateProjectBranchAsync(Guid id, ProjectBranchDto model, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> DeleteProjectBranchAsync(Guid id, bool isActive, Employee loggedInEmployee, Guid tenantId);
#endregion
#region =================================================================== Service Project Allocation Functions ===================================================================
Task<ApiResponse<object>> GetServiceProjectAllocationListAsync(Guid? projectId, Guid? employeeId, bool isActive, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> ManageServiceProjectAllocationAsync(List<ServiceProjectAllocationDto> model, Employee loggedInEmployee, Guid tenantId);
@ -25,7 +34,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces
#endregion
#region =================================================================== Job Tickets Functions ===================================================================
Task<ApiResponse<object>> GetJobTicketsListAsync(Guid? projectId, int pageNumber, int pageSize, bool isActive, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> GetJobTicketsListAsync(Guid? projectId, int pageNumber, int pageSize, bool isActive, bool isArchive, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> GetJobTicketDetailsAsync(Guid id, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> GetJobTagListAsync(Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> CreateJobTicketAsync(CreateJobTicketDto model, Employee loggedInEmployee, Guid tenantId);

View File

@ -30,12 +30,14 @@ namespace Marco.Pms.Services.Service
private readonly ILoggingService _logger;
private readonly S3UploadService _s3Service;
private readonly IMapper _mapper;
private readonly UtilityMongoDBHelper _updateLogHelper;
private readonly Guid NewStatus = Guid.Parse("32d76a02-8f44-4aa0-9b66-c3716c45a918");
private readonly Guid AssignedStatus = Guid.Parse("cfa1886d-055f-4ded-84c6-42a2a8a14a66");
private readonly Guid InProgressStatus = Guid.Parse("5a6873a5-fed7-4745-a52f-8f61bf3bd72d");
private readonly Guid ReviewStatus = Guid.Parse("aab71020-2fb8-44d9-9430-c9a7e9bf33b0");
private readonly Guid DoneStatus = Guid.Parse("ed10ab57-dbaa-4ca5-8ecd-56745dcbdbd7");
private readonly Guid WorkDoneStatus = Guid.Parse("aab71020-2fb8-44d9-9430-c9a7e9bf33b0");
private readonly Guid ReviewDoneStatus = Guid.Parse("ed10ab57-dbaa-4ca5-8ecd-56745dcbdbd7");
private readonly Guid ClosedStatus = Guid.Parse("3ddeefb5-ae3c-4e10-a922-35e0a452bb69");
private readonly Guid OnHoldStatus = Guid.Parse("75a0c8b8-9c6a-41af-80bf-b35bab722eb2");
@ -44,7 +46,8 @@ namespace Marco.Pms.Services.Service
ApplicationDbContext context,
ILoggingService logger,
S3UploadService s3Service,
IMapper mapper)
IMapper mapper,
UtilityMongoDBHelper updateLogHelper)
{
_serviceScopeFactory = serviceScopeFactory;
_context = context;
@ -52,11 +55,11 @@ namespace Marco.Pms.Services.Service
_s3Service = s3Service;
_mapper = mapper;
_dbContextFactory = dbContextFactory;
_updateLogHelper = updateLogHelper;
}
#region =================================================================== Service Project Functions ===================================================================
/// <summary>
/// Retrieves a paginated list of active service projects for a tenant, including related services, job counts, and team member information.
/// </summary>
@ -94,7 +97,8 @@ namespace Marco.Pms.Services.Service
{
var normalizedSearch = searchString.Trim().ToLowerInvariant();
serviceProjectQuery = serviceProjectQuery
.Where(sp => sp.Name.ToLower().Contains(normalizedSearch));
.Where(sp => sp.Name.ToLower().Contains(normalizedSearch) ||
(!string.IsNullOrWhiteSpace(sp.ShortName) && sp.ShortName.ToLower().Contains(normalizedSearch)));
}
// Calculate total count and pages for pagination metadata
@ -126,8 +130,8 @@ namespace Marco.Pms.Services.Service
.Select(g => new
{
ProjectId = g.Key,
JobsPassedDueDateCount = g.Count(jt => jt.StatusId != DoneStatus && jt.StatusId != ClosedStatus && jt.DueDate.Date < DateTime.UtcNow.Date),
ActiveJobsCount = g.Count(jt => jt.StatusId != DoneStatus && jt.StatusId != ClosedStatus && jt.StatusId != OnHoldStatus),
JobsPassedDueDateCount = g.Count(jt => jt.StatusId != ReviewDoneStatus && jt.StatusId != ClosedStatus && jt.DueDate.Date < DateTime.UtcNow.Date),
ActiveJobsCount = g.Count(jt => jt.StatusId != ReviewDoneStatus && jt.StatusId != ClosedStatus && jt.StatusId != OnHoldStatus),
AssignedJobsCount = g.Count(jt => jt.StatusId == AssignedStatus),
OnHoldJobsCount = g.Count(jt => jt.StatusId == OnHoldStatus)
})
@ -195,7 +199,6 @@ namespace Marco.Pms.Services.Service
}
}
/// <summary>
/// Retrieves detailed information for a specific service project, including related client, status, services, and audit information.
/// </summary>
@ -439,9 +442,7 @@ namespace Marco.Pms.Services.Service
}
// Create BSON snapshot of existing entity for audit logging (MongoDB)
using var scope = _serviceScopeFactory.CreateScope();
var updateLogHelper = scope.ServiceProvider.GetRequiredService<UtilityMongoDBHelper>();
BsonDocument existingEntityBson = updateLogHelper.EntityToBsonDocument(serviceProject);
BsonDocument existingEntityBson = _updateLogHelper.EntityToBsonDocument(serviceProject);
// Map incoming DTO to the tracked entity
_mapper.Map(model, serviceProject);
@ -509,7 +510,7 @@ namespace Marco.Pms.Services.Service
});
// Push update log asynchronously to MongoDB for audit
var updateLogTask = updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
var updateLogTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
{
EntityId = id.ToString(),
UpdatedById = loggedInEmployee.Id.ToString(),
@ -566,9 +567,7 @@ namespace Marco.Pms.Services.Service
}
// Create BSON snapshot of existing entity for audit logging (MongoDB)
using var scope = _serviceScopeFactory.CreateScope();
var updateLogHelper = scope.ServiceProvider.GetRequiredService<UtilityMongoDBHelper>();
BsonDocument existingEntityBson = updateLogHelper.EntityToBsonDocument(serviceProject);
BsonDocument existingEntityBson = _updateLogHelper.EntityToBsonDocument(serviceProject);
// Update active status as requested by the client
serviceProject.IsActive = isActive;
@ -576,7 +575,7 @@ namespace Marco.Pms.Services.Service
await _context.SaveChangesAsync();
// Push update log asynchronously to MongoDB for audit
await updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
{
EntityId = id.ToString(),
UpdatedById = loggedInEmployee.Id.ToString(),
@ -599,6 +598,426 @@ namespace Marco.Pms.Services.Service
#endregion
#region =================================================================== Project Branch Functions ===================================================================
/// <summary>
/// Retrieves a paginated list of project branches filtered by activity status and optional search criteria.
/// Implements enterprise-grade optimizations, detailed logging, and standardized error handling.
/// </summary>
/// <param name="projectId">Unique identifier for the project.</param>
/// <param name="isActive">Filter by active/inactive branches.</param>
/// <param name="searchString">Optional search string for filtering by branch name, address, or type.</param>
/// <param name="pageNumber">Current page number for pagination.</param>
/// <param name="pageSize">Number of records per page.</param>
/// <param name="loggedInEmployee">Current logged-in employee details.</param>
/// <param name="tenantId">Tenant identifier for multi-tenant architecture.</param>
/// <returns>ApiResponse containing paginated branches or error details.</returns>
public async Task<ApiResponse<object>> GetProjectBranchListByProjectAsync(Guid projectId, bool isActive, string? searchString, int pageNumber, int pageSize,
Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("GetProjectBranchListByProjectAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
// Log method invocation with parameters for audit and debugging
_logger.LogInfo("Fetching project branches for ProjectId: {ProjectId}, IsActive: {IsActive}, Page: {PageNumber}, Size: {PageSize}", projectId, isActive, pageNumber, pageSize);
try
{
// Check if the service project exists
var serviceProject = await _context.ServiceProjects
.AsNoTracking()
.FirstOrDefaultAsync(sp => sp.Id == projectId && sp.TenantId == tenantId);
if (serviceProject == null)
{
_logger.LogWarning("Service project not found for ProjectId: {ProjectId}", projectId);
return ApiResponse<object>.ErrorResponse("Service project not found", "Service project not found", 404);
}
// Build base query with necessary includes and filters
var branchQuery = _context.ProjectBranches
.Include(pb => pb.Project).ThenInclude(sp => sp!.Status)
.Include(pb => pb.CreatedBy).ThenInclude(e => e!.JobRole)
.AsNoTracking()
.Where(pb => pb.ProjectId == projectId && pb.TenantId == tenantId && pb.IsActive == isActive);
// Apply search filtering if search string is provided
if (!string.IsNullOrWhiteSpace(searchString))
{
var normalized = searchString.Trim().ToLowerInvariant();
branchQuery = branchQuery.Where(pb =>
pb.BranchName.ToLower().Contains(normalized) ||
pb.Address.ToLower().Contains(normalized) ||
pb.BranchType.ToLower().Contains(normalized));
}
// Count total records for pagination metadata
var totalEntities = await branchQuery.CountAsync();
var totalPages = (int)Math.Ceiling((double)totalEntities / pageSize);
// Fetch paginated data sorted by name descending
var branches = await branchQuery
.OrderByDescending(pb => pb.BranchName)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
// Map entities to view models
var projectBranchVMs = _mapper.Map<List<ProjectBranchVM>>(branches);
// Prepare response with pagination metadata
var response = new
{
CurrentPage = pageNumber,
TotalPages = totalPages,
TotalEntities = totalEntities,
Data = projectBranchVMs
};
// Log successful fetch
_logger.LogInfo("Fetched {Count} branches for Project: {ProjectName}", projectBranchVMs.Count, serviceProject.Name);
return ApiResponse<object>.SuccessResponse(response, $"{projectBranchVMs.Count} branches of project {serviceProject.Name} fetched successfully");
}
catch (Exception ex)
{
// Log exception details
_logger.LogError(ex, "Error occurred while fetching project branches for ProjectId: {ProjectId}", projectId);
// Return standardized problem details response
return ApiResponse<object>.ErrorResponse("An unexpected error occurred.", ex.Message, 500);
}
}
/// <summary>
/// Retrieves detailed information for a single project branch by ID, including related project and employee metadata.
/// Provides enterprise-grade optimization, structured error handling, and detailed logging.
/// </summary>
/// <param name="id">Unique identifier of the project branch.</param>
/// <param name="loggedInEmployee">Information about the currently logged-in employee (for auditing/security).</param>
/// <param name="tenantId">The current tenant's unique identifier (multi-tenancy support).</param>
/// <returns>ApiResponse with the branch details or a standardized error.</returns>
public async Task<ApiResponse<object>> GetProjectBranchDetailsAsync(Guid id, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("GetProjectBranchDetailsAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
_logger.LogInfo("Attempting to fetch details for ProjectBranchId: {ProjectBranchId}, TenantId: {TenantId} by EmployeeId: {EmployeeId}",
id, tenantId, loggedInEmployee.Id);
try
{
// Query the branch with required related entities; .AsNoTracking improves read speed/performance for lookups.
var projectBranch = await _context.ProjectBranches
.AsNoTracking()
.Include(pb => pb.Project).ThenInclude(sp => sp!.Status)
.Include(pb => pb.CreatedBy).ThenInclude(e => e!.JobRole)
.Include(pb => pb.UpdatedBy).ThenInclude(e => e!.JobRole)
.FirstOrDefaultAsync(pb => pb.Id == id && pb.TenantId == tenantId);
// Not found: log and return a descriptive error, using the correct HTTP status code.
if (projectBranch == null)
{
_logger.LogWarning("Project branch not found. ProjectBranchId: {ProjectBranchId}, TenantId: {TenantId}", id, tenantId);
return ApiResponse<object>.ErrorResponse(
"Project branch not found",
"No project branch exists with the given ID for this tenant.",
404
);
}
// Map entity to detail view model to avoid exposing domain internals in API.
var branchDetails = _mapper.Map<ProjectBranchDetailsVM>(projectBranch);
_logger.LogInfo("Project branch details successfully fetched. ProjectBranchId: {ProjectBranchId}", id);
// Return success with data using a descriptive message.
return ApiResponse<object>.SuccessResponse(branchDetails, "Project branch details fetched successfully.");
}
catch (Exception ex)
{
// Log the complete exception with an error log, capturing all contextual info for troubleshooting.
_logger.LogError(ex, "Error while fetching project branch details. ProjectBranchId: {ProjectBranchId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}",
id, tenantId, loggedInEmployee.Id);
// Return a standardized error message; hide internal error details when handing unknown errors.
return ApiResponse<object>.ErrorResponse(
"An unexpected error occurred while fetching project branch details.",
ex.Message,
500
);
}
}
/// <summary>
/// Retrieves a filtered, distinct list of project branch types for a specified tenant.
/// Supports optional search filtering, optimized for read-only access.
/// </summary>
/// <param name="searchString">Optional search string to filter branch types.</param>
/// <param name="loggedInEmployee">The employee requesting data, for audit logging.</param>
/// <param name="tenantId">Tenant identifier to scope data in a multi-tenant environment.</param>
/// <returns>ApiResponse with list of branch types or error message.</returns>
public async Task<ApiResponse<object>> GetBranchTypeListAsync(string? searchString, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("GetBranchTypeListAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
_logger.LogInfo("Fetching distinct project branch types for TenantId: {TenantId}, RequestedBy: {EmployeeId}", tenantId, loggedInEmployee.Id);
try
{
// Build initial query with no tracking for optimized read performance
var branchTypeQuery = _context.ProjectBranches
.AsNoTracking()
.Where(pb => pb.TenantId == tenantId)
.Select(pb => pb.BranchType);
// Apply search filter if provided
if (!string.IsNullOrWhiteSpace(searchString))
{
_logger.LogDebug("Applying search filter for branch types with searchString: {SearchString}", searchString);
branchTypeQuery = branchTypeQuery.Where(bt => bt.Contains(searchString));
}
// Get distinct branch types asynchronously
var branchTypes = await branchTypeQuery
.Distinct()
.OrderBy(bt => bt)
.ToListAsync();
_logger.LogInfo("Fetched {Count} distinct branch types for TenantId: {TenantId}", branchTypes.Count, tenantId);
return ApiResponse<object>.SuccessResponse(branchTypes, $"{branchTypes.Count} project branch types fetched successfully.", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching branch types for TenantId: {TenantId}", tenantId);
return ApiResponse<object>.ErrorResponse("Failed to fetch branch types due to an internal error.", ex.Message, 500);
}
}
/// <summary>
/// Creates a new project branch associated with a specific service project.
/// Applies enterprise-grade validation, logging, and exception handling.
/// </summary>
/// <param name="model">DTO containing project branch creation data.</param>
/// <param name="loggedInEmployee">Logged-in employee details for auditing.</param>
/// <param name="tenantId">Tenant identifier for multi-tenancy context.</param>
/// <returns>ApiResponse containing created project branch details or error info.</returns>
public async Task<ApiResponse<object>> CreateProjectBranchAsync(ProjectBranchDto model, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("CreateProjectBranchAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
_logger.LogInfo("Starting project branch creation. ProjectId: {ProjectId}, TenantId: {TenantId}, CreatedBy: {EmployeeId}",
model.ProjectId, tenantId, loggedInEmployee.Id);
try
{
// Validate existence of related service project for given tenant
var serviceProject = await _context.ServiceProjects
.AsNoTracking()
.FirstOrDefaultAsync(sp => sp.Id == model.ProjectId && sp.TenantId == tenantId && sp.IsActive);
if (serviceProject == null)
{
_logger.LogWarning("Service project not found for ProjectId: {ProjectId}, TenantId: {TenantId}", model.ProjectId, tenantId);
return ApiResponse<object>.ErrorResponse("Service project not found", "No service project exists with the given ID for this tenant.", 404);
}
// Map DTO to domain entity and initialize audit and status fields
var projectBranch = _mapper.Map<ProjectBranch>(model);
projectBranch.Id = Guid.NewGuid();
projectBranch.IsActive = true;
projectBranch.CreatedAt = DateTime.UtcNow;
projectBranch.CreatedById = loggedInEmployee.Id;
projectBranch.TenantId = tenantId;
// Add and persist new project branch
await using var transaction = await _context.Database.BeginTransactionAsync();
_context.ProjectBranches.Add(projectBranch);
await _context.SaveChangesAsync();
await transaction.CommitAsync();
// Map to response view models assembling nested related data
var response = _mapper.Map<ProjectBranchVM>(projectBranch);
response.Project = _mapper.Map<BasicServiceProjectVM>(serviceProject);
response.CreatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
_logger.LogInfo("Project branch created successfully. ProjectBranchId: {ProjectBranchId}", projectBranch.Id);
return ApiResponse<object>.SuccessResponse(response, "Created project branch successfully", 201);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occurred while creating project branch. ProjectId: {ProjectId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}",
model.ProjectId, tenantId, loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("An unexpected error occurred while creating the project branch.", ex.Message, 500);
}
}
/// <summary>
/// Updates an existing project branch with new data. Ensures data consistency, logs changes, and maintains comprehensive audit trail.
/// Implements enterprise best practices for validation, logging, transaction management, and error handling.
/// </summary>
/// <param name="id">ID of the project branch to update.</param>
/// <param name="model">DTO containing updated project branch data.</param>
/// <param name="loggedInEmployee">Current employee performing the update (for audit and logging).</param>
/// <param name="tenantId">Tenant ID for multi-tenant data isolation.</param>
/// <returns>ApiResponse indicating success or failure with detailed messages.</returns>
public async Task<ApiResponse<object>> UpdateProjectBranchAsync(Guid id, ProjectBranchDto model, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("UpdateProjectBranchAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
// Validate ID consistency between route parameter and payload DTO
if (!model.Id.HasValue && model.Id != id)
{
_logger.LogWarning("ID mismatch: Route ID {RouteId} != Payload ID {PayloadId}", id, model.Id ?? Guid.Empty);
return ApiResponse<object>.ErrorResponse("ID mismatch between route and payload", "The ID provided in the route does not match the payload.", 400);
}
// Fetch current entity state for auditing
var projectBranch = await _context.ProjectBranches
.AsNoTracking()
.FirstOrDefaultAsync(pb => pb.Id == id && pb.TenantId == tenantId);
if (projectBranch == null)
{
_logger.LogWarning("Project branch not found for update. Id: {Id}, TenantId: {TenantId}", id, tenantId);
return ApiResponse<object>.ErrorResponse("Project branch not found", "No project branch exists with the provided ID for this tenant.", 404);
}
// Convert existing entity to BSON for detailed audit logging
BsonDocument existingEntityBson = _updateLogHelper.EntityToBsonDocument(projectBranch);
// Map the incoming DTO onto the existing entity
_mapper.Map(model, projectBranch);
projectBranch.UpdatedAt = DateTime.UtcNow;
projectBranch.UpdatedById = loggedInEmployee.Id;
try
{
// Execute update within a transaction to ensure atomicity
await using var transaction = await _context.Database.BeginTransactionAsync();
// Mark the entity as modified
_context.ProjectBranches.Update(projectBranch);
await _context.SaveChangesAsync();
// Commit transaction
await transaction.CommitAsync();
// Log the update in a dedicated audit log asynchronously
var updateLogTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
{
EntityId = id.ToString(),
UpdatedById = loggedInEmployee.Id.ToString(),
OldObject = existingEntityBson,
UpdatedAt = DateTime.UtcNow
}, "ProjectBranchModificationLog");
// Fetch the latest entity details with related info for response
var branchTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ProjectBranches
.Include(pb => pb.Project).ThenInclude(sp => sp!.Status)
.Include(pb => pb.CreatedBy).ThenInclude(e => e!.JobRole)
.AsNoTracking()
.FirstOrDefaultAsync(pb => pb.Id == id && pb.TenantId == tenantId);
});
await Task.WhenAll(updateLogTask, branchTask);
// Map updated entity to view model for API response
var response = _mapper.Map<ProjectBranchVM>(branchTask.Result);
_logger.LogInfo("Successfully updated project branch. Id: {Id}", id);
return ApiResponse<object>.SuccessResponse(response, "Project branch updated successfully.", 200);
}
catch (Exception ex)
{
// Log detailed error for troubleshooting
_logger.LogError(ex, "Error during project branch update. Id: {Id}, TenantId: {TenantId}", id, tenantId);
return ApiResponse<object>.ErrorResponse("Failed to update project branch due to an internal error.", ex.Message, 500);
}
}
/// <summary>
/// Soft deletes or restores a project branch by toggling its IsActive flag.
/// Implements audit logging, transaction safety, and detailed error handling to ensure enterprise readiness.
/// </summary>
/// <param name="id">The unique identifier of the project branch to be deleted or restored.</param>
/// <param name="isActive">Boolean indicating active state; false to soft delete, true to restore.</param>
/// <param name="loggedInEmployee">The authenticated employee performing the operation, for auditing purposes.</param>
/// <param name="tenantId">Tenant ID to enforce multi-tenant data isolation.</param>
/// <returns>ApiResponse indicating the result of the operation, with status and descriptive message.</returns>
public async Task<ApiResponse<object>> DeleteProjectBranchAsync(Guid id, bool isActive, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("DeleteProjectBranchAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
_logger.LogInfo("Starting soft delete operation for ProjectBranchId: {ProjectBranchId}, TenantId: {TenantId}, By EmployeeId: {EmployeeId}",
id, tenantId, loggedInEmployee.Id);
try
{
// Fetch the existing project branch record for the tenant
var projectBranch = await _context.ProjectBranches
.FirstOrDefaultAsync(pb => pb.Id == id && pb.TenantId == tenantId);
if (projectBranch == null)
{
_logger.LogWarning("Project branch not found for soft delete. ProjectBranchId: {ProjectBranchId}, TenantId: {TenantId}", id, tenantId);
return ApiResponse<object>.ErrorResponse("Project branch not found", "No project branch exists with the given ID for this tenant.", 404);
}
// Capture existing entity state for audit logging
BsonDocument existingEntityBson = _updateLogHelper.EntityToBsonDocument(projectBranch);
// Update the IsActive flag to soft delete or restore
projectBranch.IsActive = isActive;
// Save changes within a transaction to ensure atomicity
await using var transaction = await _context.Database.BeginTransactionAsync();
await _context.SaveChangesAsync();
await transaction.CommitAsync();
// Log the change asynchronously for audit trail
await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
{
EntityId = id.ToString(),
UpdatedById = loggedInEmployee.Id.ToString(),
OldObject = existingEntityBson,
UpdatedAt = DateTime.UtcNow
}, "ProjectBranchModificationLog");
_logger.LogInfo("Soft delete operation completed successfully for ProjectBranchId: {ProjectBranchId}", id);
return ApiResponse<object>.SuccessResponse(new { }, isActive ? "Branch restored successfully" : "Branch deleted successfully", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occurred during soft delete operation for ProjectBranchId: {ProjectBranchId}, TenantId: {TenantId}", id, tenantId);
return ApiResponse<object>.ErrorResponse("Failed to delete project branch due to an internal error.", ex.Message, 500);
}
}
#endregion
#region =================================================================== Service Project Allocation Functions ===================================================================
/// <summary>
@ -1046,7 +1465,7 @@ namespace Marco.Pms.Services.Service
/// <param name="tenantId">Tenant context.</param>
/// <param name="loggedInEmployee">Employee requesting data.</param>
/// <returns>Paged list of JobTicketVM plus metadata, or error response.</returns>
public async Task<ApiResponse<object>> GetJobTicketsListAsync(Guid? projectId, int pageNumber, int pageSize, bool isActive, Employee loggedInEmployee, Guid tenantId)
public async Task<ApiResponse<object>> GetJobTicketsListAsync(Guid? projectId, int pageNumber, int pageSize, bool isActive, bool isArchive, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
@ -1077,6 +1496,7 @@ namespace Marco.Pms.Services.Service
.Where(jt =>
jt.TenantId == tenantId &&
jt.IsActive == isActive &&
jt.IsArchive == isArchive &&
jt.Project != null &&
jt.Status != null &&
jt.CreatedBy != null &&
@ -1193,11 +1613,13 @@ namespace Marco.Pms.Services.Service
.Include(jt => jt.Status)
.Include(jt => jt.Project)
.Include(jt => jt.CreatedBy).ThenInclude(e => e!.JobRole)
.Include(jt => jt.ProjectBranch)
.AsNoTracking()
.FirstOrDefaultAsync(jt =>
jt.Id == id &&
jt.TenantId == tenantId &&
jt.IsActive &&
!jt.IsArchive &&
jt.Project != null &&
jt.Status != null &&
jt.CreatedBy != null &&
@ -1206,7 +1628,7 @@ namespace Marco.Pms.Services.Service
if (jobTicket == null)
{
_logger.LogWarning("Job ticket not found or inactive. JobTicketId: {JobTicketId}, TenantId: {TenantId}", id, tenantId);
return ApiResponse<object>.ErrorResponse("Job not found", "Job ticket not found or inactive.", 404);
return ApiResponse<object>.ErrorResponse("The job could not be found. Please check the job details and try again.", "Job ticket not found or inactive.", 404);
}
// Load all job statuses for status mappings in logs
@ -1258,6 +1680,7 @@ namespace Marco.Pms.Services.Service
Status = status,
NextStatus = nextStatus,
Comment = ul.Comment,
UpdatedAt = ul.UpdatedAt,
UpdatedBy = _mapper.Map<BasicEmployeeVM>(ul.UpdatedBy)
};
}).ToList();
@ -1360,6 +1783,11 @@ namespace Marco.Pms.Services.Service
/// <returns>ApiResponse containing the created job ticket view or error details.</returns>
public async Task<ApiResponse<object>> CreateJobTicketAsync(CreateJobTicketDto model, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("CreateJobTicketAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
await using var transaction = await _context.Database.BeginTransactionAsync();
try
{
@ -1390,6 +1818,40 @@ namespace Marco.Pms.Services.Service
return ApiResponse<object>.ErrorResponse("Service project not found", "Service project not found or inactive", 404);
}
if (model.ProjectBranchId.HasValue)
{
// Log the attempt to fetch project branch
_logger.LogInfo("Attempting to fetch project branch with ID: {ProjectBranchId}, Project ID: {ProjectId}, Tenant ID: {TenantId}",
model.ProjectBranchId, serviceProject.Id, tenantId);
// Query project branch with all necessary filters
var projectBranch = await _context.ProjectBranches
.AsNoTracking()
.FirstOrDefaultAsync(pb =>
pb.Id == model.ProjectBranchId.Value &&
pb.ProjectId == serviceProject.Id &&
pb.TenantId == tenantId &&
pb.IsActive);
// Check if project branch exists
if (projectBranch == null)
{
// Log the failure to find project branch
_logger.LogWarning("Project branch not found for ID: {ProjectBranchId}, Project ID: {ProjectId}, Tenant ID: {TenantId}",
model.ProjectBranchId, serviceProject.Id, tenantId);
// Return a structured error response
return ApiResponse<object>.ErrorResponse(
"Project branch not found",
"The specified project branch does not exist or is not active for the given project and tenant.",
404);
}
// Log successful retrieval
_logger.LogInfo("Successfully retrieved project branch with ID: {ProjectBranchId}", model.ProjectBranchId);
}
var hasAssignees = model.Assignees?.Any(a => a.IsActive) ?? false;
string uIDPrefix = $"JT/{DateTime.Now:MMyy}";
@ -1406,6 +1868,8 @@ namespace Marco.Pms.Services.Service
jobTicket.StatusId = hasAssignees ? AssignedStatus : NewStatus;
jobTicket.UIDPrefix = uIDPrefix;
jobTicket.UIDPostfix = uIDPostfix;
jobTicket.IsActive = true;
jobTicket.IsArchive = false;
jobTicket.CreatedAt = DateTime.UtcNow;
jobTicket.CreatedById = loggedInEmployee.Id;
jobTicket.TenantId = tenantId;
@ -1570,12 +2034,12 @@ namespace Marco.Pms.Services.Service
var jobTicket = await _context.JobTickets
.Include(jt => jt.Project)
.Include(jt => jt.CreatedBy).ThenInclude(e => e!.JobRole)
.FirstOrDefaultAsync(jt => jt.Id == model.JobTicketId && jt.TenantId == tenantId);
.FirstOrDefaultAsync(jt => jt.Id == model.JobTicketId && jt.TenantId == tenantId && jt.IsActive && !jt.IsArchive);
if (jobTicket == null)
{
_logger.LogWarning("Job ticket {JobTicketId} not found for status change in tenant {TenantId}", model.JobTicketId, tenantId);
return ApiResponse<object>.ErrorResponse("Job Not Found", "Job ticket not found.", 404);
return ApiResponse<object>.ErrorResponse("The job could not be found. Please check the job details and try again.", "Job ticket not found.", 404);
}
var jobStatusMapping = await GetJobStatusMappingAsync(jobTicket.StatusId, model.StatusId, jobTicket.ProjectId, loggedInEmployee.Id, tenantId);
@ -1650,7 +2114,7 @@ namespace Marco.Pms.Services.Service
var projectTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ServiceProjects.FirstOrDefaultAsync(sp => sp.Id == model.ProjectId && sp.TenantId == tenantId && sp.IsActive);
return await context.ServiceProjects.FirstOrDefaultAsync(sp => sp.Id == jobTicket.ProjectId && sp.TenantId == tenantId && sp.IsActive);
});
var statusTask = Task.Run(async () =>
{
@ -1663,7 +2127,7 @@ namespace Marco.Pms.Services.Service
// Validate existence of foreign entities
if (projectTask.Result == null)
{
_logger.LogWarning("Service project not found during job ticket update. ProjectId: {ProjectId}, TenantId: {TenantId}", model.ProjectId, tenantId);
_logger.LogWarning("Service project not found during job ticket update. ProjectId: {ProjectId}, TenantId: {TenantId}", jobTicket.ProjectId, tenantId);
return ApiResponse<object>.ErrorResponse("Service project not found", "Service project not found", 404);
}
if (statusTask.Result == null)
@ -1697,11 +2161,21 @@ namespace Marco.Pms.Services.Service
};
_context.StatusUpdateLogs.Add(updateLog);
}
if (jobTicket.IsArchive != model.IsArchive)
{
// Validate if job ticket status permits archiving
if (model.IsArchive && jobTicket.StatusId != ReviewDoneStatus && jobTicket.StatusId != ClosedStatus)
{
_logger.LogWarning("Archiving failed: Job status not eligible. JobTicketId: {JobTicketId}, StatusId: {StatusId}", jobTicket.Id, jobTicket.StatusId);
return ApiResponse<object>.ErrorResponse(
"Archiving failed: Only jobs with status Done or Closed can be archived.",
"Invalid status: Job not eligible for archiving.",
400);
}
}
// Create BSON snapshot of existing entity for audit logging (MongoDB)
using var scope = _serviceScopeFactory.CreateScope();
var updateLogHelper = scope.ServiceProvider.GetRequiredService<UtilityMongoDBHelper>();
BsonDocument existingEntityBson = updateLogHelper.EntityToBsonDocument(jobTicket);
BsonDocument existingEntityBson = _updateLogHelper.EntityToBsonDocument(jobTicket);
// Map updated properties from DTO, set audit metadata
_mapper.Map(model, jobTicket);
@ -1825,7 +2299,7 @@ namespace Marco.Pms.Services.Service
await transaction.CommitAsync();
// Push update log asynchronously to MongoDB for audit
var updateLogTask = updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
var updateLogTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
{
EntityId = id.ToString(),
UpdatedById = loggedInEmployee.Id.ToString(),
@ -1859,13 +2333,13 @@ namespace Marco.Pms.Services.Service
{
await transaction.RollbackAsync();
_logger.LogError(dbEx, "Database error while updating job ticket for project {ProjectId} by employee {EmployeeId} in tenant {TenantId}",
model.ProjectId, loggedInEmployee.Id, tenantId);
jobTicket.ProjectId, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Database Error", "An error occurred while saving data to the database.", 500);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception while updating job ticket for project {ProjectId} by employee {EmployeeId} in tenant {TenantId}",
model.ProjectId, loggedInEmployee.Id, tenantId);
jobTicket.ProjectId, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500);
}
}
@ -1906,18 +2380,18 @@ namespace Marco.Pms.Services.Service
.Include(jc => jc.JobTicket).ThenInclude(jt => jt!.Status)
.Include(jc => jc.CreatedBy).ThenInclude(e => e!.JobRole)
.Include(jc => jc.UpdatedBy).ThenInclude(e => e!.JobRole)
.Where(jc => jc.TenantId == tenantId && jc.JobTicket != null && jc.CreatedBy != null && jc.CreatedBy.JobRole != null);
.Where(jc => jc.TenantId == tenantId && jc.JobTicket != null && !jc.JobTicket.IsArchive && jc.CreatedBy != null && jc.CreatedBy.JobRole != null);
// Filter by jobTicketId if provided after verifying existence
if (jobTicketId.HasValue)
{
var jobTicketExists = await _context.JobTickets.AnyAsync(jt =>
jt.Id == jobTicketId && jt.TenantId == tenantId);
jt.Id == jobTicketId && jt.TenantId == tenantId && !jt.IsArchive);
if (!jobTicketExists)
{
_logger.LogWarning("Job ticket {JobTicketId} not found in tenant {TenantId} for comment listing", jobTicketId, tenantId);
return ApiResponse<object>.ErrorResponse("Job not found", "Job ticket not found.", 404);
return ApiResponse<object>.ErrorResponse("The job could not be found or is archived. Please check the job details and try again.", "Job ticket not found.", 404);
}
commentQuery = commentQuery.Where(jc => jc.JobTicketId == jobTicketId.Value);
@ -2024,12 +2498,12 @@ namespace Marco.Pms.Services.Service
var jobTicket = await _context.JobTickets
.Include(jt => jt.Status)
.AsNoTracking()
.FirstOrDefaultAsync(jt => jt.Id == model.JobTicketId && jt.TenantId == tenantId);
.FirstOrDefaultAsync(jt => jt.Id == model.JobTicketId && jt.TenantId == tenantId && !jt.IsArchive);
if (jobTicket == null)
{
_logger.LogWarning("Job ticket {JobTicketId} not found or inaccessible in tenant {TenantId}", model.JobTicketId, tenantId);
return ApiResponse<object>.ErrorResponse("Job Not Found", "Job ticket not found or inaccessible.", 404);
return ApiResponse<object>.ErrorResponse("The job could not be found or is archived. Please check the job details and try again.", "Job ticket not found or inaccessible.", 404);
}
// Create new comment entity
@ -2129,6 +2603,11 @@ namespace Marco.Pms.Services.Service
/// <returns>ApiResponse containing updated comment details or error information.</returns>
public async Task<ApiResponse<object>> UpdateCommentAsync(Guid id, JobCommentDto model, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("UpdateCommentAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
// Transaction ensures atomic update of comment and attachments.
await using var transaction = await _context.Database.BeginTransactionAsync();
try
@ -2153,7 +2632,7 @@ namespace Marco.Pms.Services.Service
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobTickets
.AsNoTracking()
.FirstOrDefaultAsync(jc => jc.Id == model.JobTicketId && jc.TenantId == tenantId && jc.IsActive);
.FirstOrDefaultAsync(jt => jt.Id == model.JobTicketId && jt.TenantId == tenantId && !jt.IsArchive);
});
await Task.WhenAll(jobCommentTask, jobTicketTask);
@ -2163,7 +2642,7 @@ namespace Marco.Pms.Services.Service
if (jobTicket == null)
{
_logger.LogWarning("Job ticket {JobTicketId} not found for updating comment {CommentId}", model.JobTicketId, id);
return ApiResponse<object>.ErrorResponse("Job not found", "Job not found", 404);
return ApiResponse<object>.ErrorResponse("The job could not be found or is archived. Please check the job details and try again.", "The job could not be found. Please check the job details and try again.", 404);
}
if (jobComment == null)
{
@ -2172,9 +2651,7 @@ namespace Marco.Pms.Services.Service
}
// Audit: BSON snapshot before update (MongoDB)
using var scope = _serviceScopeFactory.CreateScope();
var updateLogHelper = scope.ServiceProvider.GetRequiredService<UtilityMongoDBHelper>();
BsonDocument existingEntityBson = updateLogHelper.EntityToBsonDocument(jobComment);
BsonDocument existingEntityBson = _updateLogHelper.EntityToBsonDocument(jobComment);
// Update comment core fields and audit
_mapper.Map(model, jobComment);
@ -2274,7 +2751,7 @@ namespace Marco.Pms.Services.Service
}
// Push audit log to MongoDB
var updateLogTask = updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
var updateLogTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
{
EntityId = id.ToString(),
UpdatedById = loggedInEmployee.Id.ToString(),
@ -2345,6 +2822,11 @@ namespace Marco.Pms.Services.Service
#region =================================================================== Job Tagging Functions ===================================================================
public async Task<ApiResponse<object>> GetAttendanceForSelfAsync(Guid jobTicketId, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("GetAttendanceForSelfAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
_logger.LogInfo("GetAttendanceForSelfAsync initiated for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, jobTicketId);
try
@ -2353,12 +2835,12 @@ namespace Marco.Pms.Services.Service
var jobTicket = await _context.JobTickets
.AsNoTracking()
.Include(jt => jt.Status)
.FirstOrDefaultAsync(jt => jt.Id == jobTicketId && jt.TenantId == tenantId && jt.IsActive);
.FirstOrDefaultAsync(jt => jt.Id == jobTicketId && jt.TenantId == tenantId && !jt.IsArchive);
if (jobTicket == null)
{
_logger.LogWarning("JobTicket not found. JobTicketId: {JobTicketId}, TenantId: {TenantId}", jobTicketId, tenantId);
return ApiResponse<object>.ErrorResponse("Job not found", "Job is not found", 404);
return ApiResponse<object>.ErrorResponse("The job could not be found. Please check the job details and try again.", "Job is not found", 404);
}
var jobEmployeeMapping = await _context.JobEmployeeMappings
@ -2419,6 +2901,12 @@ namespace Marco.Pms.Services.Service
}
public async Task<ApiResponse<object>> GetAttendanceLogForAttendanceAsync(Guid jobAttendanceId, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("GetAttendanceLogForAttendanceAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
_logger.LogInfo("GetAttendanceLogForAttendanceAsync called for JobAttendanceId: {JobAttendanceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", jobAttendanceId, tenantId, loggedInEmployee.Id);
try
@ -2497,6 +2985,13 @@ namespace Marco.Pms.Services.Service
}
public async Task<ApiResponse<object>> GetAttendanceForJobTeamAsync(Guid jobTicketId, DateTime? startDate, DateTime? endDate, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("GetAttendanceForJobTeamAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
_logger.LogInfo("GetAttendanceForJobTeamAsync called for JobTicketId: {JobTicketId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", jobTicketId, tenantId, loggedInEmployee.Id);
try
@ -2504,7 +2999,7 @@ namespace Marco.Pms.Services.Service
// Validate the existence and active status of the job ticket including its status related data
var jobTicket = await _context.JobTickets
.AsNoTracking()
.FirstOrDefaultAsync(jt => jt.Id == jobTicketId && jt.TenantId == tenantId && jt.IsActive);
.FirstOrDefaultAsync(jt => jt.Id == jobTicketId && jt.TenantId == tenantId && !jt.IsArchive);
if (jobTicket == null)
{
@ -2554,6 +3049,13 @@ namespace Marco.Pms.Services.Service
}
public async Task<ApiResponse<object>> ManageJobTaggingAsync(JobAttendanceDto model, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("ManageJobTaggingAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
_logger.LogInfo("ManageJobTaggingAsync called for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
try
@ -2562,11 +3064,11 @@ namespace Marco.Pms.Services.Service
var jobTicket = await _context.JobTickets
.AsNoTracking()
.Include(jt => jt.Status)
.FirstOrDefaultAsync(jt => jt.Id == model.JobTcketId && jt.TenantId == tenantId && jt.IsActive);
.FirstOrDefaultAsync(jt => jt.Id == model.JobTcketId && jt.TenantId == tenantId && !jt.IsArchive);
if (jobTicket == null)
{
_logger.LogWarning("JobTicket not found. JobTicketId: {JobTicketId}, TenantId: {TenantId}", model.JobTcketId, tenantId);
return ApiResponse<object>.ErrorResponse("Job not found", "Job not found", 404);
return ApiResponse<object>.ErrorResponse("The job could not be found. Please check the job details and try again.", "The job could not be found. Please check the job details and try again.", 404);
}
// Check if the current user is part of the job team
@ -2733,8 +3235,6 @@ namespace Marco.Pms.Services.Service
//private async Task DeleteTalkingPointAttachments(List<Guid> documentIds)
//{
// using var scope = _serviceScopeFactory.CreateScope();
// var _updateLogHelper = scope.ServiceProvider.GetRequiredService<UtilityMongoDBHelper>();
// var attachmentTask = Task.Run(async () =>
// {
@ -2778,8 +3278,6 @@ namespace Marco.Pms.Services.Service
private async Task DeleteJobAttachemnts(List<Guid> documentIds)
{
using var scope = _serviceScopeFactory.CreateScope();
var _updateLogHelper = scope.ServiceProvider.GetRequiredService<UtilityMongoDBHelper>();
var attachmentTask = Task.Run(async () =>
{