[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

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:
pqhuy1987
2026-05-28 16:19:42 +07:00
parent 17aaba9df0
commit e54a22de0c
41 changed files with 14980 additions and 0 deletions

View File

@ -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);
}

View File

@ -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);
}
}