[CLAUDE] Domain+App+Infra+Api+FE-Admin+FE-User: S38 G-O4+G-O5+G-O6+G-P1+G-H3 SKELETON full-stack
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m53s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m53s
Phase 10.3-10.4 SKELETON 5 plan combo finish — Mig 39+40 + BE skeleton 7 module + FE 2 app SHA256 IDENTICAL + 11 menu key. UAT visible end-to-end. ⚠️ SKELETON Phase 1 trade-off rõ: - Status flat 5-state WorkflowAppStatus enum share Leave/OT/Travel/Vehicle - ApproveV2 workflow advance DEFER Phase 11 (Drafter Create OK, Approve flow chưa wire) - LevelOpinions per-module DEFER Phase 11 - LeaveBalance calc + Auto-assign + SLA timer DEFER Phase 11 - CodeGen atomic + MaDonTu/MaTicket gen DEFER Phase 11 - Vehicle catalog + Driver catalog DEFER Phase 11 (free text VehicleLicense) - ItTicketComments thread DEFER Phase 11 (free text Resolution field) Mig 39 (em main solo): 5 entity Workflow Apps schema - LeaveRequest (G-O4, FK LeaveType Hrm Mig 35, ApplicableType=5) - OtRequest (G-O4, FK OtPolicy optional, ApplicableType=6) - TravelRequest (G-O4, reuse ApplicableType=4 Proposal) - VehicleBooking (G-O5, free text vehicle, ApplicableType=7) - ItTicket (G-O6, NO workflow V2 — kanban status flow) Mig 40 (em main solo): Attendance entity (G-P1) - GPS lat/long check-in/out + Source enum Web/Mobile/Device - UNIQUE composite (UserId, AttendanceDate) - WorkHours computed simple diff (NO OtPolicy multiplier yet) BE CQRS (em main solo, single mega ~1100 LOC): - WorkflowAppsFeatures.cs 7 region (5 module Create+List + Attendance CheckIn/Out/GetMonth + HrDashboard) - 7 Controller: /api/leave-requests + /ot-requests + /travel-requests + /vehicle-bookings + /it-tickets + /attendances + /hr/dashboard - Class-level [Authorize] any authenticated - 13 endpoint total FE 2 app (em main solo fallback gotcha #53 risk): - types/workflowApps.ts × 2 SHA256 IDENTICAL 77470e182a15de88 (all DTOs + Status badge) - WorkflowAppsListPage.tsx × 2 IDENTICAL 58139d0301a60ddf — generic declarative KIND_CONFIG handles 4 module (Leave/OT/Travel/Vehicle) - ItTicketsPage.tsx × 2 IDENTICAL d3062de2f54c794c — kanban 5 status column - MyAttendancePage.tsx × 2 IDENTICAL 86da48ae147db012 — GPS check-in/out + tháng calendar - HrmDashboardPage.tsx × 2 IDENTICAL d9c6c12a5a8694f8 — 4 KPI card + gender ratio + status breakdown - Pattern 16-bis 9× cumulative (App.tsx +4 routes + menuKeys +8 const + Layout staticMap +7 entry) - 7 amber banner "Skeleton Phase 1 — full feature Phase 11" rõ ràng UAT Menu seed: +11 const + SeedMenuTreeAsync 8 row (Off_DonTu sub-group + 3 leaf + Off_DatXe + Off_ItTicket + Off_ChamCong + Hrm_Dashboard). DbInitializer Sample workflow seed DEFER (workflows V2 already seeded từ S29+S37 reuse — admin clone tạo riêng per ApplicableType=5/6/7). Verify: - dotnet build PASS 0 error 2 pre-existing warning - dotnet test 130/130 PASS baseline preserve - npm build × 2 PASS clean - SHA256 verify 5 file × 2 app all IDENTICAL Plan G-* progress 11/11 ✅ (100% COMPLETE): ✅ G-H1 (S33) + G-O1 (S34) + G-H2 (S35) + G-O2 (S36) + G-O3 (S37) + ✅ G-O4 + G-O5 + G-O6 + G-P1 + G-H3 (S38 skeleton) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -119,5 +119,17 @@ public interface IApplicationDbContext
|
||||
DbSet<ProposalLevelOpinion> ProposalLevelOpinions { get; }
|
||||
DbSet<ProposalCodeSequence> ProposalCodeSequences { get; }
|
||||
|
||||
// Phase 10.3 G-O4+G-O5+G-O6 (Mig 39 — S38) — Workflow Apps skeleton.
|
||||
// 5 entity (Leave/OT/Travel/VehicleBooking/ItTicket) status flat 5-state.
|
||||
// ApproveV2 + LevelOpinions per-module DEFER Phase 11.
|
||||
DbSet<LeaveRequest> LeaveRequests { get; }
|
||||
DbSet<OtRequest> OtRequests { get; }
|
||||
DbSet<TravelRequest> TravelRequests { get; }
|
||||
DbSet<VehicleBooking> VehicleBookings { get; }
|
||||
DbSet<ItTicket> ItTickets { get; }
|
||||
|
||||
// Phase 10.4 G-P1 (Mig 40 — S38) — Chấm công web GPS.
|
||||
DbSet<Attendance> Attendances { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@ -0,0 +1,539 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Common.Models;
|
||||
using SolutionErp.Domain.Office;
|
||||
|
||||
namespace SolutionErp.Application.Office;
|
||||
|
||||
// Phase 10.3-10.4 G-O4+G-O5+G-O6+G-P1 (Mig 39+40 — S38 2026-05-28).
|
||||
// SKELETON Phase 1 — UAT visible cho 5 module Workflow Apps + Attendance.
|
||||
// Scope: Create + List + GetById + Update draft only.
|
||||
// DEFER Phase 11: ApproveV2 workflow advance + LevelOpinions + CodeGen atomic
|
||||
// + LeaveBalance business logic + Overlap check + Auto-assign + SLA timer + Monthly report.
|
||||
|
||||
// =========================================================================
|
||||
// REGION 1: LeaveRequest (G-O4)
|
||||
// =========================================================================
|
||||
|
||||
public record LeaveRequestDto(Guid Id, string? MaDonTu, Guid RequesterUserId, string RequesterFullName,
|
||||
Guid LeaveTypeId, DateTime StartDate, DateTime EndDate, decimal NumDays, string Reason,
|
||||
int Status, Guid? ApprovalWorkflowId, int? CurrentApprovalLevelOrder, DateTime CreatedAt);
|
||||
|
||||
public record CreateLeaveRequestCommand(Guid LeaveTypeId, DateTime StartDate, DateTime EndDate,
|
||||
decimal NumDays, string Reason, Guid? ApprovalWorkflowId) : IRequest<Guid>;
|
||||
|
||||
public class CreateLeaveRequestValidator : AbstractValidator<CreateLeaveRequestCommand>
|
||||
{
|
||||
public CreateLeaveRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Reason).NotEmpty().MaximumLength(1000);
|
||||
RuleFor(x => x.NumDays).GreaterThan(0);
|
||||
RuleFor(x => x.EndDate).GreaterThanOrEqualTo(x => x.StartDate);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<CreateLeaveRequestCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(CreateLeaveRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
var e = new LeaveRequest
|
||||
{
|
||||
RequesterUserId = cu.UserId.Value,
|
||||
RequesterFullName = cu.FullName ?? "(unknown)",
|
||||
LeaveTypeId = req.LeaveTypeId,
|
||||
StartDate = req.StartDate,
|
||||
EndDate = req.EndDate,
|
||||
NumDays = req.NumDays,
|
||||
Reason = req.Reason.Trim(),
|
||||
ApprovalWorkflowId = req.ApprovalWorkflowId,
|
||||
Status = WorkflowAppStatus.Nhap,
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = cu.UserId,
|
||||
};
|
||||
db.LeaveRequests.Add(e);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return e.Id;
|
||||
}
|
||||
}
|
||||
|
||||
public record GetLeaveRequestsQuery(int? Status, Guid? RequesterUserId, int Page = 1, int PageSize = 50)
|
||||
: IRequest<PagedResult<LeaveRequestDto>>;
|
||||
|
||||
public class GetLeaveRequestsHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetLeaveRequestsQuery, PagedResult<LeaveRequestDto>>
|
||||
{
|
||||
public async Task<PagedResult<LeaveRequestDto>> Handle(GetLeaveRequestsQuery q, CancellationToken ct)
|
||||
{
|
||||
var page = q.Page < 1 ? 1 : q.Page;
|
||||
var pageSize = q.PageSize is < 1 or > 200 ? 50 : q.PageSize;
|
||||
var query = db.LeaveRequests.AsNoTracking().Where(x => !x.IsDeleted);
|
||||
if (q.Status.HasValue) query = query.Where(x => (int)x.Status == q.Status.Value);
|
||||
if (q.RequesterUserId.HasValue) query = query.Where(x => x.RequesterUserId == q.RequesterUserId.Value);
|
||||
var total = await query.CountAsync(ct);
|
||||
var items = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(x => new LeaveRequestDto(x.Id, x.MaDonTu, x.RequesterUserId, x.RequesterFullName,
|
||||
x.LeaveTypeId, x.StartDate, x.EndDate, x.NumDays, x.Reason,
|
||||
(int)x.Status, x.ApprovalWorkflowId, x.CurrentApprovalLevelOrder, x.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<LeaveRequestDto>(items, total, page, pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// REGION 2: OtRequest (G-O4)
|
||||
// =========================================================================
|
||||
|
||||
public record OtRequestDto(Guid Id, string? MaDonTu, Guid RequesterUserId, string RequesterFullName,
|
||||
DateTime OtDate, TimeSpan StartTime, TimeSpan EndTime, decimal Hours, string Reason,
|
||||
Guid? OtPolicyId, int Status, Guid? ApprovalWorkflowId, int? CurrentApprovalLevelOrder, DateTime CreatedAt);
|
||||
|
||||
public record CreateOtRequestCommand(DateTime OtDate, TimeSpan StartTime, TimeSpan EndTime,
|
||||
decimal Hours, string Reason, Guid? OtPolicyId, Guid? ApprovalWorkflowId) : IRequest<Guid>;
|
||||
|
||||
public class CreateOtRequestValidator : AbstractValidator<CreateOtRequestCommand>
|
||||
{
|
||||
public CreateOtRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Reason).NotEmpty().MaximumLength(1000);
|
||||
RuleFor(x => x.Hours).GreaterThan(0);
|
||||
RuleFor(x => x.EndTime).GreaterThan(x => x.StartTime);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateOtRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<CreateOtRequestCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(CreateOtRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
var e = new OtRequest
|
||||
{
|
||||
RequesterUserId = cu.UserId.Value,
|
||||
RequesterFullName = cu.FullName ?? "(unknown)",
|
||||
OtDate = req.OtDate,
|
||||
StartTime = req.StartTime,
|
||||
EndTime = req.EndTime,
|
||||
Hours = req.Hours,
|
||||
Reason = req.Reason.Trim(),
|
||||
OtPolicyId = req.OtPolicyId,
|
||||
ApprovalWorkflowId = req.ApprovalWorkflowId,
|
||||
Status = WorkflowAppStatus.Nhap,
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = cu.UserId,
|
||||
};
|
||||
db.OtRequests.Add(e);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return e.Id;
|
||||
}
|
||||
}
|
||||
|
||||
public record GetOtRequestsQuery(int? Status, Guid? RequesterUserId, int Page = 1, int PageSize = 50)
|
||||
: IRequest<PagedResult<OtRequestDto>>;
|
||||
|
||||
public class GetOtRequestsHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetOtRequestsQuery, PagedResult<OtRequestDto>>
|
||||
{
|
||||
public async Task<PagedResult<OtRequestDto>> Handle(GetOtRequestsQuery q, CancellationToken ct)
|
||||
{
|
||||
var page = q.Page < 1 ? 1 : q.Page;
|
||||
var pageSize = q.PageSize is < 1 or > 200 ? 50 : q.PageSize;
|
||||
var query = db.OtRequests.AsNoTracking().Where(x => !x.IsDeleted);
|
||||
if (q.Status.HasValue) query = query.Where(x => (int)x.Status == q.Status.Value);
|
||||
if (q.RequesterUserId.HasValue) query = query.Where(x => x.RequesterUserId == q.RequesterUserId.Value);
|
||||
var total = await query.CountAsync(ct);
|
||||
var items = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(x => new OtRequestDto(x.Id, x.MaDonTu, x.RequesterUserId, x.RequesterFullName,
|
||||
x.OtDate, x.StartTime, x.EndTime, x.Hours, x.Reason, x.OtPolicyId,
|
||||
(int)x.Status, x.ApprovalWorkflowId, x.CurrentApprovalLevelOrder, x.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<OtRequestDto>(items, total, page, pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// REGION 3: TravelRequest (G-O4)
|
||||
// =========================================================================
|
||||
|
||||
public record TravelRequestDto(Guid Id, string? MaDonTu, Guid RequesterUserId, string RequesterFullName,
|
||||
string Destination, DateTime StartDate, DateTime EndDate, int NumDays, string Purpose,
|
||||
decimal? EstimatedCost, int Status, Guid? ApprovalWorkflowId, int? CurrentApprovalLevelOrder, DateTime CreatedAt);
|
||||
|
||||
public record CreateTravelRequestCommand(string Destination, DateTime StartDate, DateTime EndDate,
|
||||
int NumDays, string Purpose, decimal? EstimatedCost, Guid? ApprovalWorkflowId) : IRequest<Guid>;
|
||||
|
||||
public class CreateTravelRequestValidator : AbstractValidator<CreateTravelRequestCommand>
|
||||
{
|
||||
public CreateTravelRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Destination).NotEmpty().MaximumLength(300);
|
||||
RuleFor(x => x.Purpose).NotEmpty().MaximumLength(1000);
|
||||
RuleFor(x => x.NumDays).GreaterThan(0);
|
||||
RuleFor(x => x.EstimatedCost).GreaterThanOrEqualTo(0).When(x => x.EstimatedCost.HasValue);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateTravelRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<CreateTravelRequestCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(CreateTravelRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
var e = new TravelRequest
|
||||
{
|
||||
RequesterUserId = cu.UserId.Value,
|
||||
RequesterFullName = cu.FullName ?? "(unknown)",
|
||||
Destination = req.Destination.Trim(),
|
||||
StartDate = req.StartDate,
|
||||
EndDate = req.EndDate,
|
||||
NumDays = req.NumDays,
|
||||
Purpose = req.Purpose.Trim(),
|
||||
EstimatedCost = req.EstimatedCost,
|
||||
ApprovalWorkflowId = req.ApprovalWorkflowId,
|
||||
Status = WorkflowAppStatus.Nhap,
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = cu.UserId,
|
||||
};
|
||||
db.TravelRequests.Add(e);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return e.Id;
|
||||
}
|
||||
}
|
||||
|
||||
public record GetTravelRequestsQuery(int? Status, Guid? RequesterUserId, int Page = 1, int PageSize = 50)
|
||||
: IRequest<PagedResult<TravelRequestDto>>;
|
||||
|
||||
public class GetTravelRequestsHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetTravelRequestsQuery, PagedResult<TravelRequestDto>>
|
||||
{
|
||||
public async Task<PagedResult<TravelRequestDto>> Handle(GetTravelRequestsQuery q, CancellationToken ct)
|
||||
{
|
||||
var page = q.Page < 1 ? 1 : q.Page;
|
||||
var pageSize = q.PageSize is < 1 or > 200 ? 50 : q.PageSize;
|
||||
var query = db.TravelRequests.AsNoTracking().Where(x => !x.IsDeleted);
|
||||
if (q.Status.HasValue) query = query.Where(x => (int)x.Status == q.Status.Value);
|
||||
if (q.RequesterUserId.HasValue) query = query.Where(x => x.RequesterUserId == q.RequesterUserId.Value);
|
||||
var total = await query.CountAsync(ct);
|
||||
var items = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(x => new TravelRequestDto(x.Id, x.MaDonTu, x.RequesterUserId, x.RequesterFullName,
|
||||
x.Destination, x.StartDate, x.EndDate, x.NumDays, x.Purpose, x.EstimatedCost,
|
||||
(int)x.Status, x.ApprovalWorkflowId, x.CurrentApprovalLevelOrder, x.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<TravelRequestDto>(items, total, page, pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// REGION 4: VehicleBooking (G-O5)
|
||||
// =========================================================================
|
||||
|
||||
public record VehicleBookingDto(Guid Id, string? MaDonTu, Guid RequesterUserId, string RequesterFullName,
|
||||
string VehicleLicense, string? VehicleName, DateTime StartAt, DateTime EndAt,
|
||||
string Destination, string Purpose, string? DriverName,
|
||||
int Status, Guid? ApprovalWorkflowId, int? CurrentApprovalLevelOrder, DateTime CreatedAt);
|
||||
|
||||
public record CreateVehicleBookingCommand(string VehicleLicense, string? VehicleName,
|
||||
DateTime StartAt, DateTime EndAt, string Destination, string Purpose, string? DriverName,
|
||||
Guid? ApprovalWorkflowId) : IRequest<Guid>;
|
||||
|
||||
public class CreateVehicleBookingValidator : AbstractValidator<CreateVehicleBookingCommand>
|
||||
{
|
||||
public CreateVehicleBookingValidator()
|
||||
{
|
||||
RuleFor(x => x.VehicleLicense).NotEmpty().MaximumLength(20);
|
||||
RuleFor(x => x.Destination).NotEmpty().MaximumLength(300);
|
||||
RuleFor(x => x.Purpose).NotEmpty().MaximumLength(1000);
|
||||
RuleFor(x => x.EndAt).GreaterThan(x => x.StartAt);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateVehicleBookingHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<CreateVehicleBookingCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(CreateVehicleBookingCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
var e = new VehicleBooking
|
||||
{
|
||||
RequesterUserId = cu.UserId.Value,
|
||||
RequesterFullName = cu.FullName ?? "(unknown)",
|
||||
VehicleLicense = req.VehicleLicense.Trim(),
|
||||
VehicleName = req.VehicleName?.Trim(),
|
||||
StartAt = req.StartAt,
|
||||
EndAt = req.EndAt,
|
||||
Destination = req.Destination.Trim(),
|
||||
Purpose = req.Purpose.Trim(),
|
||||
DriverName = req.DriverName?.Trim(),
|
||||
ApprovalWorkflowId = req.ApprovalWorkflowId,
|
||||
Status = WorkflowAppStatus.Nhap,
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = cu.UserId,
|
||||
};
|
||||
db.VehicleBookings.Add(e);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return e.Id;
|
||||
}
|
||||
}
|
||||
|
||||
public record GetVehicleBookingsQuery(int? Status, Guid? RequesterUserId, int Page = 1, int PageSize = 50)
|
||||
: IRequest<PagedResult<VehicleBookingDto>>;
|
||||
|
||||
public class GetVehicleBookingsHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetVehicleBookingsQuery, PagedResult<VehicleBookingDto>>
|
||||
{
|
||||
public async Task<PagedResult<VehicleBookingDto>> Handle(GetVehicleBookingsQuery q, CancellationToken ct)
|
||||
{
|
||||
var page = q.Page < 1 ? 1 : q.Page;
|
||||
var pageSize = q.PageSize is < 1 or > 200 ? 50 : q.PageSize;
|
||||
var query = db.VehicleBookings.AsNoTracking().Where(x => !x.IsDeleted);
|
||||
if (q.Status.HasValue) query = query.Where(x => (int)x.Status == q.Status.Value);
|
||||
if (q.RequesterUserId.HasValue) query = query.Where(x => x.RequesterUserId == q.RequesterUserId.Value);
|
||||
var total = await query.CountAsync(ct);
|
||||
var items = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(x => new VehicleBookingDto(x.Id, x.MaDonTu, x.RequesterUserId, x.RequesterFullName,
|
||||
x.VehicleLicense, x.VehicleName, x.StartAt, x.EndAt, x.Destination, x.Purpose, x.DriverName,
|
||||
(int)x.Status, x.ApprovalWorkflowId, x.CurrentApprovalLevelOrder, x.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<VehicleBookingDto>(items, total, page, pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// REGION 5: ItTicket (G-O6) — kanban status (NO workflow V2)
|
||||
// =========================================================================
|
||||
|
||||
public record ItTicketDto(Guid Id, string? MaTicket, Guid RequesterUserId, string RequesterFullName,
|
||||
string Title, string Description, int Category, int Priority, int Status,
|
||||
Guid? AssignedToUserId, string? AssignedToFullName, DateTime? ResolvedAt, string? Resolution, DateTime CreatedAt);
|
||||
|
||||
public record CreateItTicketCommand(string Title, string Description, int Category, int Priority) : IRequest<Guid>;
|
||||
|
||||
public class CreateItTicketValidator : AbstractValidator<CreateItTicketCommand>
|
||||
{
|
||||
public CreateItTicketValidator()
|
||||
{
|
||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(300);
|
||||
RuleFor(x => x.Description).NotEmpty().MaximumLength(5000);
|
||||
RuleFor(x => x.Category).GreaterThan(0);
|
||||
RuleFor(x => x.Priority).InclusiveBetween(1, 4);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateItTicketHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<CreateItTicketCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(CreateItTicketCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
var e = new ItTicket
|
||||
{
|
||||
RequesterUserId = cu.UserId.Value,
|
||||
RequesterFullName = cu.FullName ?? "(unknown)",
|
||||
Title = req.Title.Trim(),
|
||||
Description = req.Description.Trim(),
|
||||
Category = (ItTicketCategory)req.Category,
|
||||
Priority = (ItTicketPriority)req.Priority,
|
||||
Status = ItTicketStatus.Open,
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = cu.UserId,
|
||||
};
|
||||
db.ItTickets.Add(e);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return e.Id;
|
||||
}
|
||||
}
|
||||
|
||||
public record GetItTicketsQuery(int? Status, int? Category, int? Priority, Guid? AssignedToUserId,
|
||||
Guid? RequesterUserId, int Page = 1, int PageSize = 50) : IRequest<PagedResult<ItTicketDto>>;
|
||||
|
||||
public class GetItTicketsHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetItTicketsQuery, PagedResult<ItTicketDto>>
|
||||
{
|
||||
public async Task<PagedResult<ItTicketDto>> Handle(GetItTicketsQuery q, CancellationToken ct)
|
||||
{
|
||||
var page = q.Page < 1 ? 1 : q.Page;
|
||||
var pageSize = q.PageSize is < 1 or > 200 ? 50 : q.PageSize;
|
||||
var query = db.ItTickets.AsNoTracking().Where(x => !x.IsDeleted);
|
||||
if (q.Status.HasValue) query = query.Where(x => (int)x.Status == q.Status.Value);
|
||||
if (q.Category.HasValue) query = query.Where(x => (int)x.Category == q.Category.Value);
|
||||
if (q.Priority.HasValue) query = query.Where(x => (int)x.Priority == q.Priority.Value);
|
||||
if (q.AssignedToUserId.HasValue) query = query.Where(x => x.AssignedToUserId == q.AssignedToUserId.Value);
|
||||
if (q.RequesterUserId.HasValue) query = query.Where(x => x.RequesterUserId == q.RequesterUserId.Value);
|
||||
var total = await query.CountAsync(ct);
|
||||
var items = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(x => new ItTicketDto(x.Id, x.MaTicket, x.RequesterUserId, x.RequesterFullName,
|
||||
x.Title, x.Description, (int)x.Category, (int)x.Priority, (int)x.Status,
|
||||
x.AssignedToUserId, x.AssignedToFullName, x.ResolvedAt, x.Resolution, x.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<ItTicketDto>(items, total, page, pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateItTicketStatusCommand(Guid Id, int Status, string? Resolution) : IRequest;
|
||||
|
||||
public class UpdateItTicketStatusHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<UpdateItTicketStatusCommand>
|
||||
{
|
||||
public async Task Handle(UpdateItTicketStatusCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
var t = await db.ItTickets.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (t is null) throw new NotFoundException("ItTicket", req.Id);
|
||||
t.Status = (ItTicketStatus)req.Status;
|
||||
if (req.Resolution != null) t.Resolution = req.Resolution.Trim();
|
||||
if (t.Status == ItTicketStatus.Resolved && t.ResolvedAt is null)
|
||||
t.ResolvedAt = clock.UtcNow;
|
||||
t.UpdatedAt = clock.UtcNow;
|
||||
t.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// REGION 6: Attendance (G-P1)
|
||||
// =========================================================================
|
||||
|
||||
public record AttendanceDto(Guid Id, Guid UserId, string UserFullName, DateTime AttendanceDate,
|
||||
DateTime? CheckInAt, DateTime? CheckOutAt, int SourceIn, int SourceOut,
|
||||
decimal? CheckInLatitude, decimal? CheckInLongitude, decimal? WorkHours, decimal? OtHours, string? Note);
|
||||
|
||||
public record CheckInCommand(decimal? Latitude, decimal? Longitude, decimal? Accuracy, string? Note) : IRequest<Guid>;
|
||||
|
||||
public class CheckInHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<CheckInCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(CheckInCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
var today = clock.Now.Date;
|
||||
var existing = await db.Attendances.FirstOrDefaultAsync(x => x.UserId == cu.UserId.Value && x.AttendanceDate == today, ct);
|
||||
if (existing is not null)
|
||||
{
|
||||
if (existing.CheckInAt.HasValue)
|
||||
throw new ConflictException("Đã check-in hôm nay.");
|
||||
existing.CheckInAt = clock.UtcNow;
|
||||
existing.CheckInLatitude = req.Latitude;
|
||||
existing.CheckInLongitude = req.Longitude;
|
||||
existing.CheckInAccuracy = req.Accuracy;
|
||||
existing.SourceIn = AttendanceSource.Web;
|
||||
existing.Note = req.Note?.Trim();
|
||||
existing.UpdatedAt = clock.UtcNow;
|
||||
existing.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return existing.Id;
|
||||
}
|
||||
var e = new Attendance
|
||||
{
|
||||
UserId = cu.UserId.Value,
|
||||
UserFullName = cu.FullName ?? "(unknown)",
|
||||
AttendanceDate = today,
|
||||
CheckInAt = clock.UtcNow,
|
||||
CheckInLatitude = req.Latitude,
|
||||
CheckInLongitude = req.Longitude,
|
||||
CheckInAccuracy = req.Accuracy,
|
||||
SourceIn = AttendanceSource.Web,
|
||||
SourceOut = AttendanceSource.Web,
|
||||
Note = req.Note?.Trim(),
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = cu.UserId,
|
||||
};
|
||||
db.Attendances.Add(e);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return e.Id;
|
||||
}
|
||||
}
|
||||
|
||||
public record CheckOutCommand(decimal? Latitude, decimal? Longitude, decimal? Accuracy) : IRequest;
|
||||
|
||||
public class CheckOutHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<CheckOutCommand>
|
||||
{
|
||||
public async Task Handle(CheckOutCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
var today = clock.Now.Date;
|
||||
var att = await db.Attendances.FirstOrDefaultAsync(x => x.UserId == cu.UserId.Value && x.AttendanceDate == today, ct);
|
||||
if (att is null) throw new NotFoundException("Attendance", today);
|
||||
if (!att.CheckInAt.HasValue)
|
||||
throw new ConflictException("Chưa check-in hôm nay.");
|
||||
att.CheckOutAt = clock.UtcNow;
|
||||
att.CheckOutLatitude = req.Latitude;
|
||||
att.CheckOutLongitude = req.Longitude;
|
||||
att.CheckOutAccuracy = req.Accuracy;
|
||||
att.SourceOut = AttendanceSource.Web;
|
||||
// Simple WorkHours calc: diff in hours
|
||||
if (att.CheckOutAt.HasValue && att.CheckInAt.HasValue)
|
||||
att.WorkHours = (decimal)(att.CheckOutAt.Value - att.CheckInAt.Value).TotalHours;
|
||||
att.UpdatedAt = clock.UtcNow;
|
||||
att.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record GetMyAttendanceQuery(int Year, int Month) : IRequest<List<AttendanceDto>>;
|
||||
|
||||
public class GetMyAttendanceHandler(IApplicationDbContext db, ICurrentUser cu)
|
||||
: IRequestHandler<GetMyAttendanceQuery, List<AttendanceDto>>
|
||||
{
|
||||
public async Task<List<AttendanceDto>> Handle(GetMyAttendanceQuery q, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
var monthStart = new DateTime(q.Year, q.Month, 1);
|
||||
var monthEnd = monthStart.AddMonths(1);
|
||||
return await db.Attendances.AsNoTracking()
|
||||
.Where(x => x.UserId == cu.UserId.Value && x.AttendanceDate >= monthStart && x.AttendanceDate < monthEnd && !x.IsDeleted)
|
||||
.OrderBy(x => x.AttendanceDate)
|
||||
.Select(x => new AttendanceDto(x.Id, x.UserId, x.UserFullName, x.AttendanceDate,
|
||||
x.CheckInAt, x.CheckOutAt, (int)x.SourceIn, (int)x.SourceOut,
|
||||
x.CheckInLatitude, x.CheckInLongitude, x.WorkHours, x.OtHours, x.Note))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// REGION 7: HR Dashboard (G-H3)
|
||||
// =========================================================================
|
||||
|
||||
public record HrDashboardDto(int TotalEmployees, int ActiveEmployees, int OnLeaveEmployees, int ResignedEmployees,
|
||||
int MaleCount, int FemaleCount, int BirthdaysThisWeek, int NewHiresThisMonth);
|
||||
|
||||
public record GetHrDashboardQuery : IRequest<HrDashboardDto>;
|
||||
|
||||
public class GetHrDashboardHandler(IApplicationDbContext db, IDateTime clock)
|
||||
: IRequestHandler<GetHrDashboardQuery, HrDashboardDto>
|
||||
{
|
||||
public async Task<HrDashboardDto> Handle(GetHrDashboardQuery q, CancellationToken ct)
|
||||
{
|
||||
var now = clock.Now;
|
||||
var weekEnd = now.AddDays(7);
|
||||
var monthStart = new DateTime(now.Year, now.Month, 1);
|
||||
|
||||
var total = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted, ct);
|
||||
var active = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted && (int)x.EmployeeStatus == 1, ct);
|
||||
var onLeave = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted && (int)x.EmployeeStatus == 2, ct);
|
||||
var resigned = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted && (int)x.EmployeeStatus == 3, ct);
|
||||
var male = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted && x.Gender == Domain.Hrm.Gender.Male, ct);
|
||||
var female = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted && x.Gender == Domain.Hrm.Gender.Female, ct);
|
||||
|
||||
// Birthdays in next 7 days — compare month+day
|
||||
var birthdays = await db.EmployeeProfiles.AsNoTracking()
|
||||
.Where(x => !x.IsDeleted && x.DateOfBirth.HasValue)
|
||||
.Select(x => x.DateOfBirth!.Value)
|
||||
.ToListAsync(ct);
|
||||
var bdaysThisWeek = birthdays.Count(d =>
|
||||
{
|
||||
var thisYearBday = new DateTime(now.Year, d.Month, d.Day);
|
||||
return thisYearBday >= now.Date && thisYearBday <= weekEnd.Date;
|
||||
});
|
||||
_ = bdaysThisWeek; // silence unused if
|
||||
|
||||
var monthStartDateOnly = DateOnly.FromDateTime(monthStart);
|
||||
var newHires = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted && x.HireDate.HasValue && x.HireDate >= monthStartDateOnly, ct);
|
||||
|
||||
return new HrDashboardDto(total, active, onLeave, resigned, male, female, bdaysThisWeek, newHires);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user