[CLAUDE] Domain+App+Api+Infra: Plan B G-H1 Task 4+6 — Hrm CQRS 5 endpoint + Permission menu
Phase 10.1 G-H1 Phase 2 — Task 4 (BE CQRS + REST endpoint) + Task 6
(Permission menu seed) cumulative. Foundation BE side complete cho Hồ sơ
Nhân sự module — FE Task 5 + Reviewer Task 7 + CICD verify next.
## Task 4 — BE CQRS + Controller (3 file new, Implementer Case 2)
src/Backend/SolutionErp.Application/Hrm/EmployeeFeatures.cs (~450 LOC):
- CreateEmployeeProfileCommand + Validator + Handler
- Verify User.Id exists qua UserManager.FindByIdAsync
- UNIQUE 1-1 check: throw ConflictException nếu User đã có EmployeeProfile
- EmployeeCode auto-gen qua IEmployeeCodeGenerator (NV/{YYYY}/{Seq:D4})
- 50+ field assignment từ Command record
- UpdateEmployeeProfileCommand + Validator + Handler (mutable fields, UserId+EmployeeCode immutable)
- DeleteEmployeeProfileCommand + Handler (soft delete IsDeleted=true)
- GetEmployeeProfileQuery + Handler (Include 5 satellite collection)
- ListEmployeesQuery + Handler (paged + JOIN Users+Departments, filter Status/DepartmentId/Search)
src/Backend/SolutionErp.Application/Hrm/Dtos/EmployeeDtos.cs (~110 LOC):
- EmployeeProfileListItemDto (Id, EmployeeCode, UserId, FullName/Email/Department JOIN, Status, Phone, HireDate)
- EmployeeProfileDetailDto (full 50+ field + 5 satellite collection)
- 5 satellite DTO: EmployeeWorkHistoryDto + EmployeeEducationDto +
EmployeeFamilyRelationDto + EmployeeSkillDto + EmployeeDocumentDto
src/Backend/SolutionErp.Api/Controllers/EmployeesController.cs (~70 LOC):
- 5 REST endpoint: GET list / GET detail / POST / PUT / DELETE
- Class-level [Authorize] only Phase 1 (per-action policy Hrm_HoSo_View/Create/
Edit/Delete defer Phase 1.5 per Reviewer recommend)
- Route prefix /api/employees
## Task 6 — Permission menu Hrm_HoSo* (em main solo, 2 file mod)
src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs (+10 LOC):
- +2 const: Hrm root group + HrmHoSo leaf
- Update All[] array → SeedAdminPermissionsAsync auto-grant Admin role CRUD
src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs (+4 LOC):
- SeedMenuTreeAsync +2 entry:
- (Hrm, "Nhân sự", null, 28, "UserCircle") — root group
- (HrmHoSo, "Hồ sơ Nhân sự", Hrm, 1, "ContactRound") — leaf
- Order=28 between Budgets=27 và Contracts=30+ (no collision)
- INFRASTRUCTURE menu seed (NOT gated DemoSeed:Disabled — em main verified
outside gate block per gotcha #51 lesson, mirror Plan B Task 3b
SeedDemoEmployeeProfilesAsync placement)
## Reviewer ae752c0 verdict: PASS Smart Friend 6× clean
- 0 critical, 0 major, 3 minor defer Phase 1.5 (per-action policy + bool
partial update + IDateTimeProvider injection)
- Cumulative Smart Friend track: S22 #44 + S25 #48 + S29 Plan CA ≥12 + S29
Plan B ApplicableType + S33 Plan C BW clean + S33 Plan B Phase 2 clean
- gotcha #51 INFRASTRUCTURE seed gate compliance: ✓
- gotcha #50 Layout staticMap mirror: ✓ (Task 5 commit next)
## Verify
- dotnet build: 0 err 0 warn (1.72s)
- dotnet test: 120/120 PASS baseline preserved
- Endpoint claim verified grep 0 mock marker, 5 mediator.Send real
Pattern 12-bis cross-module entity cookie-cutter mirror PE→Hrm reinforced 4×
cumulative (S29 Plan B Contract Chunk C + S33 Task 3 entity scaffold + Task
3b seed + Task 4 CQRS).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -0,0 +1,62 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SolutionErp.Application.Common.Models;
|
||||
using SolutionErp.Application.Hrm;
|
||||
using SolutionErp.Application.Hrm.Dtos;
|
||||
using SolutionErp.Domain.Hrm;
|
||||
|
||||
namespace SolutionErp.Api.Controllers;
|
||||
|
||||
// Phase 10.1 G-H1 Task 4 (S33) — REST endpoint cho Hồ sơ Nhân sự.
|
||||
// 5 main endpoint Phase 1 minimal: List / Get / Create / Update / Delete.
|
||||
// Satellite endpoint (WorkHistory/Education/FamilyRelation/Skill/Document
|
||||
// CRUD) DEFER Phase 1.5.
|
||||
//
|
||||
// Class-level [Authorize] only — em main Task 6 wire per-action policy
|
||||
// "Hrm_HoSo_View/Create/Edit/Delete" sau khi seed MenuKeys.
|
||||
[ApiController]
|
||||
[Route("api/employees")]
|
||||
[Authorize]
|
||||
public class EmployeesController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<EmployeeProfileListItemDto>>> List(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] bool sortDesc = true,
|
||||
[FromQuery] EmployeeStatus? status = null,
|
||||
[FromQuery] Guid? departmentId = null,
|
||||
CancellationToken ct = default)
|
||||
=> Ok(await mediator.Send(new ListEmployeesQuery(status, departmentId)
|
||||
{ Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<EmployeeProfileDetailDto>> Get(Guid id, CancellationToken ct)
|
||||
=> Ok(await mediator.Send(new GetEmployeeProfileQuery(id), ct));
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<object>> Create(
|
||||
[FromBody] CreateEmployeeProfileCommand cmd, CancellationToken ct)
|
||||
{
|
||||
var id = await mediator.Send(cmd, ct);
|
||||
return CreatedAtAction(nameof(Get), new { id }, new { id });
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<IActionResult> Update(
|
||||
Guid id, [FromBody] UpdateEmployeeProfileCommand cmd, CancellationToken ct)
|
||||
{
|
||||
if (id != cmd.Id) return BadRequest(new { detail = "ID không khớp" });
|
||||
await mediator.Send(cmd, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new DeleteEmployeeProfileCommand(id), ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
151
src/Backend/SolutionErp.Application/Hrm/Dtos/EmployeeDtos.cs
Normal file
151
src/Backend/SolutionErp.Application/Hrm/Dtos/EmployeeDtos.cs
Normal file
@ -0,0 +1,151 @@
|
||||
using SolutionErp.Domain.Hrm;
|
||||
|
||||
namespace SolutionErp.Application.Hrm.Dtos;
|
||||
|
||||
// Phase 10.1 G-H1 Task 4 (S33) — DTO bundle cho Hồ sơ Nhân sự CQRS.
|
||||
// 1 list item + 1 detail full + 5 satellite read DTO (inline GetDetail).
|
||||
// Satellite WRITE DTO defer Phase 1.5 — Task 4 chỉ read.
|
||||
|
||||
// ========== List item (paged) ==========
|
||||
|
||||
// JOIN Users + Departments LEFT (User.DepartmentId nullable per Identity entity).
|
||||
// FullName + Email từ User; DepartmentId + DepartmentName từ User → Department.
|
||||
public record EmployeeProfileListItemDto(
|
||||
Guid Id,
|
||||
string EmployeeCode,
|
||||
Guid UserId,
|
||||
string? FullName,
|
||||
string? Email,
|
||||
Guid? DepartmentId,
|
||||
string? DepartmentName,
|
||||
EmployeeStatus Status,
|
||||
string? Phone,
|
||||
DateOnly? HireDate,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt);
|
||||
|
||||
// ========== Detail (full + 5 satellite collection) ==========
|
||||
|
||||
public record EmployeeProfileDetailDto(
|
||||
Guid Id,
|
||||
string EmployeeCode,
|
||||
Guid UserId,
|
||||
string? FullName,
|
||||
string? Email,
|
||||
Guid? DepartmentId,
|
||||
string? DepartmentName,
|
||||
EmployeeStatus EmployeeStatus,
|
||||
Gender? Gender,
|
||||
MaritalStatus? MaritalStatus,
|
||||
EmployeeType? EmployeeType,
|
||||
DateOnly? DateOfBirth,
|
||||
string? BirthPlace,
|
||||
string? Hometown,
|
||||
string? Phone,
|
||||
string? PersonalEmail,
|
||||
string? InternalPhone,
|
||||
string? Ethnicity,
|
||||
string? Religion,
|
||||
string? Nationality,
|
||||
string? IdCardNumber,
|
||||
DateOnly? IdCardIssueDate,
|
||||
string? IdCardIssuePlace,
|
||||
string? TaxCode,
|
||||
string? SocialInsuranceNumber,
|
||||
string? PassportNumber,
|
||||
string? PermanentAddressText,
|
||||
string? StreetAddressPermanent,
|
||||
string? TemporaryAddressText,
|
||||
string? StreetAddressTemporary,
|
||||
DateOnly? HireDate,
|
||||
DateOnly? ResignDate,
|
||||
string? EmergencyContactName,
|
||||
string? EmergencyContactPhone,
|
||||
string? EmergencyContactAddress,
|
||||
string? Qualification,
|
||||
string? AcademicTitle,
|
||||
string? WorkLocation,
|
||||
string? TimekeepingCode,
|
||||
string? BankAccount,
|
||||
string? BankName,
|
||||
string? BankBranch,
|
||||
int? HeightCm,
|
||||
int? WeightKg,
|
||||
string? BloodType,
|
||||
decimal? BaseSalary,
|
||||
decimal? TotalSalary,
|
||||
decimal? AnnualLeaveDays,
|
||||
decimal? RemainingLeaveDays,
|
||||
decimal? CompensatoryLeaveDays,
|
||||
decimal? SeniorityLeaveDays,
|
||||
DateOnly? SocialInsuranceStartDate,
|
||||
string? MedicalRegistrationPlace,
|
||||
bool IsCommunistParty,
|
||||
DateOnly? CommunistPartyJoinDate,
|
||||
bool IsYouthUnion,
|
||||
DateOnly? YouthUnionJoinDate,
|
||||
bool IsTradeUnion,
|
||||
DateOnly? TradeUnionJoinDate,
|
||||
string? PhotoUrl,
|
||||
string? Notes,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt,
|
||||
List<EmployeeWorkHistoryDto> WorkHistories,
|
||||
List<EmployeeEducationDto> Educations,
|
||||
List<EmployeeFamilyRelationDto> FamilyRelations,
|
||||
List<EmployeeSkillDto> Skills,
|
||||
List<EmployeeDocumentDto> Documents);
|
||||
|
||||
// ========== Satellite read DTOs (inline GetDetail bundle) ==========
|
||||
|
||||
public record EmployeeWorkHistoryDto(
|
||||
Guid Id,
|
||||
string CompanyName,
|
||||
string? CompanyAddress,
|
||||
string? Industry,
|
||||
DateOnly? FromDate,
|
||||
DateOnly? ToDate,
|
||||
string? JobTitle,
|
||||
string? JobDescription,
|
||||
string? ResignReason);
|
||||
|
||||
public record EmployeeEducationDto(
|
||||
Guid Id,
|
||||
string SchoolName,
|
||||
string? Major,
|
||||
DegreeLevel? DegreeLevel,
|
||||
EducationMode? EducationMode,
|
||||
GradeLevel? GradeLevel,
|
||||
DateOnly? FromDate,
|
||||
DateOnly? ToDate,
|
||||
DateOnly? CertificateIssueDate,
|
||||
string? Notes);
|
||||
|
||||
public record EmployeeFamilyRelationDto(
|
||||
Guid Id,
|
||||
string FullName,
|
||||
FamilyRelationKind Relationship,
|
||||
int? BirthYear,
|
||||
string? Occupation,
|
||||
string? CurrentAddress,
|
||||
string? Phone);
|
||||
|
||||
// Polymorphic — Kind discriminator. Computer/Language/Other (semantic xem
|
||||
// EmployeeSkill.cs comment header).
|
||||
public record EmployeeSkillDto(
|
||||
Guid Id,
|
||||
SkillKind Kind,
|
||||
string Name,
|
||||
string? LanguageId,
|
||||
string? Level);
|
||||
|
||||
public record EmployeeDocumentDto(
|
||||
Guid Id,
|
||||
EmployeeDocumentType DocumentType,
|
||||
string FileName,
|
||||
string FilePath,
|
||||
long FileSize,
|
||||
string ContentType,
|
||||
DateOnly? IssueDate,
|
||||
DateOnly? ExpiryDate,
|
||||
string? Notes);
|
||||
610
src/Backend/SolutionErp.Application/Hrm/EmployeeFeatures.cs
Normal file
610
src/Backend/SolutionErp.Application/Hrm/EmployeeFeatures.cs
Normal file
@ -0,0 +1,610 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Common.Models;
|
||||
using SolutionErp.Application.Hrm.Dtos;
|
||||
using SolutionErp.Application.Hrm.Services;
|
||||
using SolutionErp.Domain.Hrm;
|
||||
using SolutionErp.Domain.Identity;
|
||||
|
||||
namespace SolutionErp.Application.Hrm;
|
||||
|
||||
// Phase 10.1 G-H1 Task 4 (S33) — Hồ sơ Nhân sự CQRS bundle.
|
||||
// 5 main endpoint Phase 1 minimal: Create / Update / Delete (soft) / Get / List.
|
||||
// Satellite CRUD endpoint (WorkHistory/Education/FamilyRelation/Skill/Document)
|
||||
// DEFER Phase 1.5 — GetDetail trả về 5 satellite collection inline cho FE Tab
|
||||
// hiển thị nháy mắt. Bro UAT có thể request mở satellite write Phase 1.5 sau.
|
||||
//
|
||||
// Pattern mirror PurchaseEvaluationFeatures.cs (Pattern 12-bis cross-module
|
||||
// cookie-cutter PE → Hrm — 4th reinforcement post Plan B Chunk C S29).
|
||||
|
||||
// ========== CREATE ==========
|
||||
|
||||
public record CreateEmployeeProfileCommand(
|
||||
Guid UserId,
|
||||
EmployeeStatus EmployeeStatus = EmployeeStatus.Active,
|
||||
Gender? Gender = null,
|
||||
MaritalStatus? MaritalStatus = null,
|
||||
EmployeeType? EmployeeType = null,
|
||||
DateOnly? DateOfBirth = null,
|
||||
string? BirthPlace = null,
|
||||
string? Hometown = null,
|
||||
string? Phone = null,
|
||||
string? PersonalEmail = null,
|
||||
string? InternalPhone = null,
|
||||
string? Ethnicity = null,
|
||||
string? Religion = null,
|
||||
string? Nationality = null,
|
||||
string? IdCardNumber = null,
|
||||
DateOnly? IdCardIssueDate = null,
|
||||
string? IdCardIssuePlace = null,
|
||||
string? TaxCode = null,
|
||||
string? SocialInsuranceNumber = null,
|
||||
string? PassportNumber = null,
|
||||
string? PermanentAddressText = null,
|
||||
string? StreetAddressPermanent = null,
|
||||
string? TemporaryAddressText = null,
|
||||
string? StreetAddressTemporary = null,
|
||||
DateOnly? HireDate = null,
|
||||
string? EmergencyContactName = null,
|
||||
string? EmergencyContactPhone = null,
|
||||
string? EmergencyContactAddress = null,
|
||||
string? Qualification = null,
|
||||
string? AcademicTitle = null,
|
||||
string? WorkLocation = null,
|
||||
string? TimekeepingCode = null,
|
||||
string? BankAccount = null,
|
||||
string? BankName = null,
|
||||
string? BankBranch = null,
|
||||
int? HeightCm = null,
|
||||
int? WeightKg = null,
|
||||
string? BloodType = null,
|
||||
decimal? BaseSalary = null,
|
||||
decimal? TotalSalary = null,
|
||||
decimal? AnnualLeaveDays = null,
|
||||
DateOnly? SocialInsuranceStartDate = null,
|
||||
string? MedicalRegistrationPlace = null,
|
||||
bool IsCommunistParty = false,
|
||||
DateOnly? CommunistPartyJoinDate = null,
|
||||
bool IsYouthUnion = false,
|
||||
DateOnly? YouthUnionJoinDate = null,
|
||||
bool IsTradeUnion = false,
|
||||
DateOnly? TradeUnionJoinDate = null,
|
||||
string? PhotoUrl = null,
|
||||
string? Notes = null) : IRequest<Guid>;
|
||||
|
||||
public class CreateEmployeeProfileCommandValidator : AbstractValidator<CreateEmployeeProfileCommand>
|
||||
{
|
||||
public CreateEmployeeProfileCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.UserId).NotEmpty();
|
||||
RuleFor(x => x.EmployeeStatus).IsInEnum();
|
||||
|
||||
// Liên hệ
|
||||
RuleFor(x => x.Phone).MaximumLength(20);
|
||||
RuleFor(x => x.PersonalEmail).MaximumLength(200).EmailAddress()
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.PersonalEmail));
|
||||
RuleFor(x => x.InternalPhone).MaximumLength(20);
|
||||
|
||||
// Cá nhân
|
||||
RuleFor(x => x.BirthPlace).MaximumLength(200);
|
||||
RuleFor(x => x.Hometown).MaximumLength(200);
|
||||
RuleFor(x => x.Ethnicity).MaximumLength(50);
|
||||
RuleFor(x => x.Religion).MaximumLength(50);
|
||||
RuleFor(x => x.Nationality).MaximumLength(50);
|
||||
|
||||
// Giấy tờ
|
||||
RuleFor(x => x.IdCardNumber).MaximumLength(20);
|
||||
RuleFor(x => x.IdCardIssuePlace).MaximumLength(200);
|
||||
RuleFor(x => x.TaxCode).MaximumLength(20);
|
||||
RuleFor(x => x.SocialInsuranceNumber).MaximumLength(20);
|
||||
RuleFor(x => x.PassportNumber).MaximumLength(20);
|
||||
|
||||
// Địa chỉ
|
||||
RuleFor(x => x.PermanentAddressText).MaximumLength(500);
|
||||
RuleFor(x => x.StreetAddressPermanent).MaximumLength(200);
|
||||
RuleFor(x => x.TemporaryAddressText).MaximumLength(500);
|
||||
RuleFor(x => x.StreetAddressTemporary).MaximumLength(200);
|
||||
|
||||
// Khẩn cấp
|
||||
RuleFor(x => x.EmergencyContactName).MaximumLength(200);
|
||||
RuleFor(x => x.EmergencyContactPhone).MaximumLength(20);
|
||||
RuleFor(x => x.EmergencyContactAddress).MaximumLength(500);
|
||||
|
||||
// Trình độ + vị trí
|
||||
RuleFor(x => x.Qualification).MaximumLength(200);
|
||||
RuleFor(x => x.AcademicTitle).MaximumLength(200);
|
||||
RuleFor(x => x.WorkLocation).MaximumLength(200);
|
||||
RuleFor(x => x.TimekeepingCode).MaximumLength(50);
|
||||
|
||||
// Ngân hàng
|
||||
RuleFor(x => x.BankAccount).MaximumLength(50);
|
||||
RuleFor(x => x.BankName).MaximumLength(200);
|
||||
RuleFor(x => x.BankBranch).MaximumLength(200);
|
||||
|
||||
// Sức khoẻ
|
||||
RuleFor(x => x.BloodType).MaximumLength(5);
|
||||
|
||||
// Lương + Phép (decimal — không âm)
|
||||
RuleFor(x => x.BaseSalary).GreaterThanOrEqualTo(0).When(x => x.BaseSalary.HasValue);
|
||||
RuleFor(x => x.TotalSalary).GreaterThanOrEqualTo(0).When(x => x.TotalSalary.HasValue);
|
||||
RuleFor(x => x.AnnualLeaveDays).GreaterThanOrEqualTo(0).When(x => x.AnnualLeaveDays.HasValue);
|
||||
|
||||
// BHXH
|
||||
RuleFor(x => x.MedicalRegistrationPlace).MaximumLength(200);
|
||||
|
||||
// Khác
|
||||
RuleFor(x => x.PhotoUrl).MaximumLength(500);
|
||||
RuleFor(x => x.Notes).MaximumLength(2000);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateEmployeeProfileCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
IEmployeeCodeGenerator codeGen,
|
||||
UserManager<User> userManager) : IRequestHandler<CreateEmployeeProfileCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(CreateEmployeeProfileCommand request, CancellationToken ct)
|
||||
{
|
||||
// Verify User tồn tại (1-1 link User.Id).
|
||||
var user = await userManager.FindByIdAsync(request.UserId.ToString())
|
||||
?? throw new NotFoundException("User", request.UserId);
|
||||
|
||||
// Verify NO existing EmployeeProfile cho UserId này (UNIQUE 1-1 ràng buộc Mig 34).
|
||||
// Check cả soft-deleted để admin biết user đã từng có profile (re-activate Phase 1.5).
|
||||
var existing = await db.EmployeeProfiles.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.UserId == request.UserId, ct);
|
||||
if (existing != null)
|
||||
throw new ConflictException(existing.IsDeleted
|
||||
? $"User {user.UserName} đã có hồ sơ NV (đã xoá mềm). Cần khôi phục thay vì tạo mới."
|
||||
: $"User {user.UserName} đã có hồ sơ NV — mỗi user chỉ được 1 hồ sơ.");
|
||||
|
||||
// Atomic MaNhanVien sequence — format NV/{YYYY}/{Seq:D4} (mirror PE pattern).
|
||||
var employeeCode = await codeGen.GenerateAsync(ct);
|
||||
|
||||
var entity = new EmployeeProfile
|
||||
{
|
||||
UserId = request.UserId,
|
||||
EmployeeCode = employeeCode,
|
||||
EmployeeStatus = request.EmployeeStatus,
|
||||
Gender = request.Gender,
|
||||
MaritalStatus = request.MaritalStatus,
|
||||
EmployeeType = request.EmployeeType,
|
||||
DateOfBirth = request.DateOfBirth,
|
||||
BirthPlace = request.BirthPlace,
|
||||
Hometown = request.Hometown,
|
||||
Phone = request.Phone,
|
||||
PersonalEmail = request.PersonalEmail,
|
||||
InternalPhone = request.InternalPhone,
|
||||
Ethnicity = request.Ethnicity,
|
||||
Religion = request.Religion,
|
||||
Nationality = request.Nationality ?? "Việt Nam",
|
||||
IdCardNumber = request.IdCardNumber,
|
||||
IdCardIssueDate = request.IdCardIssueDate,
|
||||
IdCardIssuePlace = request.IdCardIssuePlace,
|
||||
TaxCode = request.TaxCode,
|
||||
SocialInsuranceNumber = request.SocialInsuranceNumber,
|
||||
PassportNumber = request.PassportNumber,
|
||||
PermanentAddressText = request.PermanentAddressText,
|
||||
StreetAddressPermanent = request.StreetAddressPermanent,
|
||||
TemporaryAddressText = request.TemporaryAddressText,
|
||||
StreetAddressTemporary = request.StreetAddressTemporary,
|
||||
HireDate = request.HireDate,
|
||||
EmergencyContactName = request.EmergencyContactName,
|
||||
EmergencyContactPhone = request.EmergencyContactPhone,
|
||||
EmergencyContactAddress = request.EmergencyContactAddress,
|
||||
Qualification = request.Qualification,
|
||||
AcademicTitle = request.AcademicTitle,
|
||||
WorkLocation = request.WorkLocation,
|
||||
TimekeepingCode = request.TimekeepingCode,
|
||||
BankAccount = request.BankAccount,
|
||||
BankName = request.BankName,
|
||||
BankBranch = request.BankBranch,
|
||||
HeightCm = request.HeightCm,
|
||||
WeightKg = request.WeightKg,
|
||||
BloodType = request.BloodType,
|
||||
BaseSalary = request.BaseSalary,
|
||||
TotalSalary = request.TotalSalary,
|
||||
AnnualLeaveDays = request.AnnualLeaveDays,
|
||||
SocialInsuranceStartDate = request.SocialInsuranceStartDate,
|
||||
MedicalRegistrationPlace = request.MedicalRegistrationPlace,
|
||||
IsCommunistParty = request.IsCommunistParty,
|
||||
CommunistPartyJoinDate = request.CommunistPartyJoinDate,
|
||||
IsYouthUnion = request.IsYouthUnion,
|
||||
YouthUnionJoinDate = request.YouthUnionJoinDate,
|
||||
IsTradeUnion = request.IsTradeUnion,
|
||||
TradeUnionJoinDate = request.TradeUnionJoinDate,
|
||||
PhotoUrl = request.PhotoUrl,
|
||||
Notes = request.Notes,
|
||||
};
|
||||
|
||||
db.EmployeeProfiles.Add(entity);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return entity.Id;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== UPDATE ==========
|
||||
|
||||
// UserId + EmployeeCode immutable — admin KHÔNG đổi link user lẫn mã NV.
|
||||
// Field mutable: toàn bộ phần còn lại Create command + ResignDate + 3 leave fields
|
||||
// extra (RemainingLeaveDays/CompensatoryLeaveDays/SeniorityLeaveDays) cho admin chỉnh.
|
||||
public record UpdateEmployeeProfileCommand(
|
||||
Guid Id,
|
||||
EmployeeStatus EmployeeStatus,
|
||||
Gender? Gender,
|
||||
MaritalStatus? MaritalStatus,
|
||||
EmployeeType? EmployeeType,
|
||||
DateOnly? DateOfBirth,
|
||||
string? BirthPlace,
|
||||
string? Hometown,
|
||||
string? Phone,
|
||||
string? PersonalEmail,
|
||||
string? InternalPhone,
|
||||
string? Ethnicity,
|
||||
string? Religion,
|
||||
string? Nationality,
|
||||
string? IdCardNumber,
|
||||
DateOnly? IdCardIssueDate,
|
||||
string? IdCardIssuePlace,
|
||||
string? TaxCode,
|
||||
string? SocialInsuranceNumber,
|
||||
string? PassportNumber,
|
||||
string? PermanentAddressText,
|
||||
string? StreetAddressPermanent,
|
||||
string? TemporaryAddressText,
|
||||
string? StreetAddressTemporary,
|
||||
DateOnly? HireDate,
|
||||
DateOnly? ResignDate,
|
||||
string? EmergencyContactName,
|
||||
string? EmergencyContactPhone,
|
||||
string? EmergencyContactAddress,
|
||||
string? Qualification,
|
||||
string? AcademicTitle,
|
||||
string? WorkLocation,
|
||||
string? TimekeepingCode,
|
||||
string? BankAccount,
|
||||
string? BankName,
|
||||
string? BankBranch,
|
||||
int? HeightCm,
|
||||
int? WeightKg,
|
||||
string? BloodType,
|
||||
decimal? BaseSalary,
|
||||
decimal? TotalSalary,
|
||||
decimal? AnnualLeaveDays,
|
||||
decimal? RemainingLeaveDays,
|
||||
decimal? CompensatoryLeaveDays,
|
||||
decimal? SeniorityLeaveDays,
|
||||
DateOnly? SocialInsuranceStartDate,
|
||||
string? MedicalRegistrationPlace,
|
||||
bool IsCommunistParty,
|
||||
DateOnly? CommunistPartyJoinDate,
|
||||
bool IsYouthUnion,
|
||||
DateOnly? YouthUnionJoinDate,
|
||||
bool IsTradeUnion,
|
||||
DateOnly? TradeUnionJoinDate,
|
||||
string? PhotoUrl,
|
||||
string? Notes) : IRequest;
|
||||
|
||||
public class UpdateEmployeeProfileCommandValidator : AbstractValidator<UpdateEmployeeProfileCommand>
|
||||
{
|
||||
public UpdateEmployeeProfileCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Id).NotEmpty();
|
||||
RuleFor(x => x.EmployeeStatus).IsInEnum();
|
||||
|
||||
RuleFor(x => x.Phone).MaximumLength(20);
|
||||
RuleFor(x => x.PersonalEmail).MaximumLength(200).EmailAddress()
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.PersonalEmail));
|
||||
RuleFor(x => x.InternalPhone).MaximumLength(20);
|
||||
|
||||
RuleFor(x => x.BirthPlace).MaximumLength(200);
|
||||
RuleFor(x => x.Hometown).MaximumLength(200);
|
||||
RuleFor(x => x.Ethnicity).MaximumLength(50);
|
||||
RuleFor(x => x.Religion).MaximumLength(50);
|
||||
RuleFor(x => x.Nationality).MaximumLength(50);
|
||||
|
||||
RuleFor(x => x.IdCardNumber).MaximumLength(20);
|
||||
RuleFor(x => x.IdCardIssuePlace).MaximumLength(200);
|
||||
RuleFor(x => x.TaxCode).MaximumLength(20);
|
||||
RuleFor(x => x.SocialInsuranceNumber).MaximumLength(20);
|
||||
RuleFor(x => x.PassportNumber).MaximumLength(20);
|
||||
|
||||
RuleFor(x => x.PermanentAddressText).MaximumLength(500);
|
||||
RuleFor(x => x.StreetAddressPermanent).MaximumLength(200);
|
||||
RuleFor(x => x.TemporaryAddressText).MaximumLength(500);
|
||||
RuleFor(x => x.StreetAddressTemporary).MaximumLength(200);
|
||||
|
||||
RuleFor(x => x.EmergencyContactName).MaximumLength(200);
|
||||
RuleFor(x => x.EmergencyContactPhone).MaximumLength(20);
|
||||
RuleFor(x => x.EmergencyContactAddress).MaximumLength(500);
|
||||
|
||||
RuleFor(x => x.Qualification).MaximumLength(200);
|
||||
RuleFor(x => x.AcademicTitle).MaximumLength(200);
|
||||
RuleFor(x => x.WorkLocation).MaximumLength(200);
|
||||
RuleFor(x => x.TimekeepingCode).MaximumLength(50);
|
||||
|
||||
RuleFor(x => x.BankAccount).MaximumLength(50);
|
||||
RuleFor(x => x.BankName).MaximumLength(200);
|
||||
RuleFor(x => x.BankBranch).MaximumLength(200);
|
||||
|
||||
RuleFor(x => x.BloodType).MaximumLength(5);
|
||||
|
||||
RuleFor(x => x.BaseSalary).GreaterThanOrEqualTo(0).When(x => x.BaseSalary.HasValue);
|
||||
RuleFor(x => x.TotalSalary).GreaterThanOrEqualTo(0).When(x => x.TotalSalary.HasValue);
|
||||
RuleFor(x => x.AnnualLeaveDays).GreaterThanOrEqualTo(0).When(x => x.AnnualLeaveDays.HasValue);
|
||||
RuleFor(x => x.RemainingLeaveDays).GreaterThanOrEqualTo(0).When(x => x.RemainingLeaveDays.HasValue);
|
||||
RuleFor(x => x.CompensatoryLeaveDays).GreaterThanOrEqualTo(0).When(x => x.CompensatoryLeaveDays.HasValue);
|
||||
RuleFor(x => x.SeniorityLeaveDays).GreaterThanOrEqualTo(0).When(x => x.SeniorityLeaveDays.HasValue);
|
||||
|
||||
RuleFor(x => x.MedicalRegistrationPlace).MaximumLength(200);
|
||||
|
||||
RuleFor(x => x.PhotoUrl).MaximumLength(500);
|
||||
RuleFor(x => x.Notes).MaximumLength(2000);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateEmployeeProfileCommandHandler(
|
||||
IApplicationDbContext db) : IRequestHandler<UpdateEmployeeProfileCommand>
|
||||
{
|
||||
public async Task Handle(UpdateEmployeeProfileCommand request, CancellationToken ct)
|
||||
{
|
||||
var entity = await db.EmployeeProfiles
|
||||
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, ct)
|
||||
?? throw new NotFoundException("EmployeeProfile", request.Id);
|
||||
|
||||
// UserId + EmployeeCode immutable — bỏ qua mọi attempt update.
|
||||
entity.EmployeeStatus = request.EmployeeStatus;
|
||||
entity.Gender = request.Gender;
|
||||
entity.MaritalStatus = request.MaritalStatus;
|
||||
entity.EmployeeType = request.EmployeeType;
|
||||
entity.DateOfBirth = request.DateOfBirth;
|
||||
entity.BirthPlace = request.BirthPlace;
|
||||
entity.Hometown = request.Hometown;
|
||||
entity.Phone = request.Phone;
|
||||
entity.PersonalEmail = request.PersonalEmail;
|
||||
entity.InternalPhone = request.InternalPhone;
|
||||
entity.Ethnicity = request.Ethnicity;
|
||||
entity.Religion = request.Religion;
|
||||
entity.Nationality = request.Nationality ?? "Việt Nam";
|
||||
entity.IdCardNumber = request.IdCardNumber;
|
||||
entity.IdCardIssueDate = request.IdCardIssueDate;
|
||||
entity.IdCardIssuePlace = request.IdCardIssuePlace;
|
||||
entity.TaxCode = request.TaxCode;
|
||||
entity.SocialInsuranceNumber = request.SocialInsuranceNumber;
|
||||
entity.PassportNumber = request.PassportNumber;
|
||||
entity.PermanentAddressText = request.PermanentAddressText;
|
||||
entity.StreetAddressPermanent = request.StreetAddressPermanent;
|
||||
entity.TemporaryAddressText = request.TemporaryAddressText;
|
||||
entity.StreetAddressTemporary = request.StreetAddressTemporary;
|
||||
entity.HireDate = request.HireDate;
|
||||
entity.ResignDate = request.ResignDate;
|
||||
entity.EmergencyContactName = request.EmergencyContactName;
|
||||
entity.EmergencyContactPhone = request.EmergencyContactPhone;
|
||||
entity.EmergencyContactAddress = request.EmergencyContactAddress;
|
||||
entity.Qualification = request.Qualification;
|
||||
entity.AcademicTitle = request.AcademicTitle;
|
||||
entity.WorkLocation = request.WorkLocation;
|
||||
entity.TimekeepingCode = request.TimekeepingCode;
|
||||
entity.BankAccount = request.BankAccount;
|
||||
entity.BankName = request.BankName;
|
||||
entity.BankBranch = request.BankBranch;
|
||||
entity.HeightCm = request.HeightCm;
|
||||
entity.WeightKg = request.WeightKg;
|
||||
entity.BloodType = request.BloodType;
|
||||
entity.BaseSalary = request.BaseSalary;
|
||||
entity.TotalSalary = request.TotalSalary;
|
||||
entity.AnnualLeaveDays = request.AnnualLeaveDays;
|
||||
entity.RemainingLeaveDays = request.RemainingLeaveDays;
|
||||
entity.CompensatoryLeaveDays = request.CompensatoryLeaveDays;
|
||||
entity.SeniorityLeaveDays = request.SeniorityLeaveDays;
|
||||
entity.SocialInsuranceStartDate = request.SocialInsuranceStartDate;
|
||||
entity.MedicalRegistrationPlace = request.MedicalRegistrationPlace;
|
||||
entity.IsCommunistParty = request.IsCommunistParty;
|
||||
entity.CommunistPartyJoinDate = request.CommunistPartyJoinDate;
|
||||
entity.IsYouthUnion = request.IsYouthUnion;
|
||||
entity.YouthUnionJoinDate = request.YouthUnionJoinDate;
|
||||
entity.IsTradeUnion = request.IsTradeUnion;
|
||||
entity.TradeUnionJoinDate = request.TradeUnionJoinDate;
|
||||
entity.PhotoUrl = request.PhotoUrl;
|
||||
entity.Notes = request.Notes;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== DELETE (soft) ==========
|
||||
|
||||
public record DeleteEmployeeProfileCommand(Guid Id) : IRequest;
|
||||
|
||||
public class DeleteEmployeeProfileCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
ICurrentUser currentUser) : IRequestHandler<DeleteEmployeeProfileCommand>
|
||||
{
|
||||
public async Task Handle(DeleteEmployeeProfileCommand request, CancellationToken ct)
|
||||
{
|
||||
var entity = await db.EmployeeProfiles
|
||||
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, ct)
|
||||
?? throw new NotFoundException("EmployeeProfile", request.Id);
|
||||
|
||||
entity.IsDeleted = true;
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
entity.DeletedBy = currentUser.UserId;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== GET DETAIL ==========
|
||||
|
||||
public record GetEmployeeProfileQuery(Guid Id) : IRequest<EmployeeProfileDetailDto>;
|
||||
|
||||
public class GetEmployeeProfileQueryHandler(
|
||||
IApplicationDbContext db) : IRequestHandler<GetEmployeeProfileQuery, EmployeeProfileDetailDto>
|
||||
{
|
||||
public async Task<EmployeeProfileDetailDto> Handle(GetEmployeeProfileQuery request, CancellationToken ct)
|
||||
{
|
||||
var entity = await db.EmployeeProfiles.AsNoTracking()
|
||||
.Include(x => x.WorkHistories)
|
||||
.Include(x => x.Educations)
|
||||
.Include(x => x.FamilyRelations)
|
||||
.Include(x => x.Skills)
|
||||
.Include(x => x.Documents)
|
||||
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, ct)
|
||||
?? throw new NotFoundException("EmployeeProfile", request.Id);
|
||||
|
||||
// JOIN User + Department LEFT (Department nullable per User entity).
|
||||
var userInfo = await (
|
||||
from u in db.Users.AsNoTracking()
|
||||
where u.Id == entity.UserId
|
||||
join d in db.Departments.AsNoTracking() on u.DepartmentId equals d.Id into dj
|
||||
from d in dj.DefaultIfEmpty()
|
||||
select new { u.FullName, u.Email, u.DepartmentId, DepartmentName = d != null ? d.Name : null })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
return new EmployeeProfileDetailDto(
|
||||
entity.Id,
|
||||
entity.EmployeeCode,
|
||||
entity.UserId,
|
||||
userInfo?.FullName,
|
||||
userInfo?.Email,
|
||||
userInfo?.DepartmentId,
|
||||
userInfo?.DepartmentName,
|
||||
entity.EmployeeStatus,
|
||||
entity.Gender,
|
||||
entity.MaritalStatus,
|
||||
entity.EmployeeType,
|
||||
entity.DateOfBirth,
|
||||
entity.BirthPlace,
|
||||
entity.Hometown,
|
||||
entity.Phone,
|
||||
entity.PersonalEmail,
|
||||
entity.InternalPhone,
|
||||
entity.Ethnicity,
|
||||
entity.Religion,
|
||||
entity.Nationality,
|
||||
entity.IdCardNumber,
|
||||
entity.IdCardIssueDate,
|
||||
entity.IdCardIssuePlace,
|
||||
entity.TaxCode,
|
||||
entity.SocialInsuranceNumber,
|
||||
entity.PassportNumber,
|
||||
entity.PermanentAddressText,
|
||||
entity.StreetAddressPermanent,
|
||||
entity.TemporaryAddressText,
|
||||
entity.StreetAddressTemporary,
|
||||
entity.HireDate,
|
||||
entity.ResignDate,
|
||||
entity.EmergencyContactName,
|
||||
entity.EmergencyContactPhone,
|
||||
entity.EmergencyContactAddress,
|
||||
entity.Qualification,
|
||||
entity.AcademicTitle,
|
||||
entity.WorkLocation,
|
||||
entity.TimekeepingCode,
|
||||
entity.BankAccount,
|
||||
entity.BankName,
|
||||
entity.BankBranch,
|
||||
entity.HeightCm,
|
||||
entity.WeightKg,
|
||||
entity.BloodType,
|
||||
entity.BaseSalary,
|
||||
entity.TotalSalary,
|
||||
entity.AnnualLeaveDays,
|
||||
entity.RemainingLeaveDays,
|
||||
entity.CompensatoryLeaveDays,
|
||||
entity.SeniorityLeaveDays,
|
||||
entity.SocialInsuranceStartDate,
|
||||
entity.MedicalRegistrationPlace,
|
||||
entity.IsCommunistParty,
|
||||
entity.CommunistPartyJoinDate,
|
||||
entity.IsYouthUnion,
|
||||
entity.YouthUnionJoinDate,
|
||||
entity.IsTradeUnion,
|
||||
entity.TradeUnionJoinDate,
|
||||
entity.PhotoUrl,
|
||||
entity.Notes,
|
||||
entity.CreatedAt,
|
||||
entity.UpdatedAt,
|
||||
entity.WorkHistories.Select(w => new EmployeeWorkHistoryDto(
|
||||
w.Id, w.CompanyName, w.CompanyAddress, w.Industry,
|
||||
w.FromDate, w.ToDate, w.JobTitle, w.JobDescription, w.ResignReason)).ToList(),
|
||||
entity.Educations.Select(ed => new EmployeeEducationDto(
|
||||
ed.Id, ed.SchoolName, ed.Major, ed.DegreeLevel, ed.EducationMode, ed.GradeLevel,
|
||||
ed.FromDate, ed.ToDate, ed.CertificateIssueDate, ed.Notes)).ToList(),
|
||||
entity.FamilyRelations.Select(f => new EmployeeFamilyRelationDto(
|
||||
f.Id, f.FullName, f.Relationship,
|
||||
f.BirthYear, f.Occupation, f.CurrentAddress, f.Phone)).ToList(),
|
||||
entity.Skills.Select(s => new EmployeeSkillDto(
|
||||
s.Id, s.Kind, s.Name, s.LanguageId, s.Level)).ToList(),
|
||||
entity.Documents.Select(doc => new EmployeeDocumentDto(
|
||||
doc.Id, doc.DocumentType,
|
||||
doc.FileName, doc.FilePath, doc.FileSize, doc.ContentType,
|
||||
doc.IssueDate, doc.ExpiryDate, doc.Notes)).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== LIST (paged) ==========
|
||||
|
||||
// PagedRequest provides Page/PageSize/Search/SortDesc (pattern PE List).
|
||||
// Spec dùng `PagedQuery` nhưng codebase chỉ có `PagedRequest` — em main confirm
|
||||
// dùng PagedRequest (Pattern 12-bis mirror PE ListPurchaseEvaluationsQuery line 458).
|
||||
public record ListEmployeesQuery(
|
||||
EmployeeStatus? Status = null,
|
||||
Guid? DepartmentId = null) : PagedRequest, IRequest<PagedResult<EmployeeProfileListItemDto>>;
|
||||
|
||||
public class ListEmployeesQueryHandler(
|
||||
IApplicationDbContext db) : IRequestHandler<ListEmployeesQuery, PagedResult<EmployeeProfileListItemDto>>
|
||||
{
|
||||
public async Task<PagedResult<EmployeeProfileListItemDto>> Handle(
|
||||
ListEmployeesQuery request, CancellationToken ct)
|
||||
{
|
||||
// JOIN Users for FullName/Email + Departments for DepartmentName (LEFT join
|
||||
// Departments — User.DepartmentId nullable). Mirror PE Plan AG4 JOIN pattern.
|
||||
var q = from e in db.EmployeeProfiles.AsNoTracking().Where(x => !x.IsDeleted)
|
||||
join u in db.Users.AsNoTracking() on e.UserId equals u.Id
|
||||
join d in db.Departments.AsNoTracking() on u.DepartmentId equals d.Id into dj
|
||||
from d in dj.DefaultIfEmpty()
|
||||
select new { e, u, d };
|
||||
|
||||
if (request.Status is not null)
|
||||
q = q.Where(x => x.e.EmployeeStatus == request.Status);
|
||||
if (request.DepartmentId is not null)
|
||||
q = q.Where(x => x.u.DepartmentId == request.DepartmentId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Search))
|
||||
{
|
||||
var s = request.Search.Trim();
|
||||
q = q.Where(x =>
|
||||
x.e.EmployeeCode.Contains(s) ||
|
||||
x.u.FullName.Contains(s));
|
||||
}
|
||||
|
||||
q = request.SortDesc
|
||||
? q.OrderByDescending(x => x.e.CreatedAt)
|
||||
: q.OrderBy(x => x.e.CreatedAt);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var items = await q
|
||||
.Skip((request.Page - 1) * request.PageSize).Take(request.PageSize)
|
||||
.Select(x => new EmployeeProfileListItemDto(
|
||||
x.e.Id,
|
||||
x.e.EmployeeCode,
|
||||
x.e.UserId,
|
||||
x.u.FullName,
|
||||
x.u.Email,
|
||||
x.u.DepartmentId,
|
||||
x.d != null ? x.d.Name : null,
|
||||
x.e.EmployeeStatus,
|
||||
x.e.Phone,
|
||||
x.e.HireDate,
|
||||
x.e.CreatedAt,
|
||||
x.e.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return new PagedResult<EmployeeProfileListItemDto>(items, total, request.Page, request.PageSize);
|
||||
}
|
||||
}
|
||||
@ -76,6 +76,15 @@ public static class MenuKeys
|
||||
public const string BudgetCreate = "Bg_Create";
|
||||
public const string BudgetPending = "Bg_Pending";
|
||||
|
||||
// ============================================================
|
||||
// Module Nhân sự (Phase 10.1 G-H1 — Mig 34 S33 2026-05-26).
|
||||
// Port từ NamGroup CT_NHANSU 1675 NV. 1 root group + 1 leaf Hồ sơ NS.
|
||||
// Phase 1 minimal scope — satellite CRUD endpoint defer Phase 1.5.
|
||||
// Future Phase 10.4 add: Hrm_Dashboard (G-H3) + Hrm_Config* (G-H2).
|
||||
// ============================================================
|
||||
public const string Hrm = "Hrm"; // root group
|
||||
public const string HrmHoSo = "Hrm_HoSo"; // Hồ sơ Nhân sự (list + detail + edit)
|
||||
|
||||
public static readonly string[] PurchaseEvaluationTypeCodes =
|
||||
["DuyetNcc", "DuyetNccPhuongAn"];
|
||||
|
||||
@ -100,6 +109,7 @@ public static class MenuKeys
|
||||
Contracts, Forms, Reports,
|
||||
PurchaseEvaluations,
|
||||
Budgets, BudgetList, BudgetCreate, BudgetPending,
|
||||
Hrm, HrmHoSo, // Mig 34 — Phase 10.1
|
||||
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
|
||||
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
|
||||
];
|
||||
|
||||
@ -1479,6 +1479,10 @@ public static class DbInitializer
|
||||
(MenuKeys.BudgetList, "Danh sách", MenuKeys.Budgets, 1, "List"),
|
||||
(MenuKeys.BudgetCreate, "Thao tác", MenuKeys.Budgets, 2, "Plus"),
|
||||
(MenuKeys.BudgetPending, "Duyệt", MenuKeys.Budgets, 3, "CheckCircle2"),
|
||||
// Module Nhân sự (Phase 10.1 G-H1 — Mig 34 S33). 1 root + 1 leaf
|
||||
// Phase 1 minimal. Phase 1.5 + G-H2/G-H3 thêm Config/Dashboard.
|
||||
(MenuKeys.Hrm, "Nhân sự", null, 28, "UserCircle"),
|
||||
(MenuKeys.HrmHoSo, "Hồ sơ Nhân sự", MenuKeys.Hrm, 1, "ContactRound"),
|
||||
};
|
||||
|
||||
// Per-type sub-menu under Contracts: 1 group + 3 leaves each
|
||||
|
||||
Reference in New Issue
Block a user