[CLAUDE] Phase1.2: CRUD Master + Permission Matrix + FE admin pages

Backend:
- Domain/Master: Supplier (+ SupplierType 5 loai), Project, Department (AuditableEntity)
- Domain/Identity: MenuItem, Permission, MenuKeys const (12 menu)
- EF Configurations voi unique Code + query filter IsDeleted
- DbSets + IApplicationDbContext interface update
- Application: PagedResult + PagedRequest generic
- Application/Master CQRS CRUD 3 entity (Create/Update/Delete/Get/List voi paging search sort)
- Application/Permissions: GetMyMenuTree (union OR role, filter tree), ListMenuItems, ListPermissionsByRole, UpsertPermission (guard admin khong tu giam quyen), ListRoles
- Api/Authorization: MenuPermissionRequirement + Handler (Admin bypass, query DB)
- Program.cs: register 48 policy {menu}.{action} tu MenuKeys x Actions
- Api/Controllers: Suppliers, Projects, Departments, Menus, Roles, Permissions
- DbInitializer: seed 12 menu + admin full CRUD permissions
- Migration AddMasterData + AddPermissions

Frontend (fe-admin):
- Types: menuKeys.ts const, menu.ts (MenuNode/Role/Permission), master.ts (Supplier/Project/Department + SupplierType const-object)
- AuthContext: load menu from /menus/me, cache localStorage, refreshMenu()
- usePermission hook + PermissionGuard component (wrap button)
- UI kit them: Dialog (modal overlay), Textarea, Select
- Generic: DataTable (column config, sortable, loading, empty) + Pagination
- PageHeader component
- apiError helper extract message tu ProblemDetails
- Layout rewrite: render menu dong tu AuthContext.menu (MenuGroup collapsible + NavLink + lucide icon map)
- Pages: master/Suppliers, master/Projects, master/Departments (CRUD + search + sort + paging + Dialog form)
- Page system/Permissions: ma tran Role x MenuKey x CRUD checkbox (tick tu dong PUT upsert)
- App.tsx them 4 route moi

Bug fix:
- MenuPermissionHandler: EF expression tree khong support switch expression -> tach switch ra ngoai AnyAsync
- TS erasableSyntaxOnly khong cho enum -> SupplierType const-object pattern (typeof[keyof])

E2E verified via Vite proxy:
- GET /menus/me -> 6 root + 6 child nodes (12 menus)
- GET /roles -> 12 roles
- POST/GET/PUT/DELETE /suppliers -> full CRUD, soft delete OK
- tsc -b fe-admin pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-21 11:30:14 +07:00
parent 49a5f57a50
commit 54d6c9ba52
63 changed files with 4422 additions and 93 deletions

View File

@ -1,6 +1,16 @@
using Microsoft.EntityFrameworkCore;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
namespace SolutionErp.Application.Common.Interfaces;
public interface IApplicationDbContext
{
DbSet<Supplier> Suppliers { get; }
DbSet<Project> Projects { get; }
DbSet<Department> Departments { get; }
DbSet<MenuItem> MenuItems { get; }
DbSet<Permission> Permissions { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,24 @@
namespace SolutionErp.Application.Common.Models;
public record PagedResult<T>(
IReadOnlyList<T> Items,
int Total,
int Page,
int PageSize)
{
public int TotalPages => (int)Math.Ceiling(Total / (double)PageSize);
public bool HasNext => Page * PageSize < Total;
public bool HasPrev => Page > 1;
}
public abstract record PagedRequest
{
private int _page = 1;
private int _pageSize = 20;
public int Page { get => _page; init => _page = value < 1 ? 1 : value; }
public int PageSize { get => _pageSize; init => _pageSize = value switch { < 1 => 20, > 200 => 200, _ => value }; }
public string? Search { get; init; }
public string? SortBy { get; init; }
public bool SortDesc { get; init; }
}

View File

@ -0,0 +1,128 @@
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.Master;
namespace SolutionErp.Application.Master.Departments;
public record DepartmentDto(
Guid Id,
string Code,
string Name,
Guid? ManagerUserId,
string? Note,
DateTime CreatedAt,
DateTime? UpdatedAt);
public record ListDepartmentsQuery : PagedRequest, IRequest<PagedResult<DepartmentDto>>;
public class ListDepartmentsQueryHandler(IApplicationDbContext db) : IRequestHandler<ListDepartmentsQuery, PagedResult<DepartmentDto>>
{
public async Task<PagedResult<DepartmentDto>> Handle(ListDepartmentsQuery request, CancellationToken ct)
{
var query = db.Departments.AsNoTracking();
if (!string.IsNullOrWhiteSpace(request.Search))
{
var s = request.Search.Trim();
query = query.Where(x => x.Code.Contains(s) || x.Name.Contains(s));
}
query = (request.SortBy, request.SortDesc) switch
{
("code", true) => query.OrderByDescending(x => x.Code),
("code", false) => query.OrderBy(x => x.Code),
("name", true) => query.OrderByDescending(x => x.Name),
("name", false) => query.OrderBy(x => x.Name),
_ => query.OrderByDescending(x => x.CreatedAt),
};
var total = await query.CountAsync(ct);
var items = await query
.Skip((request.Page - 1) * request.PageSize).Take(request.PageSize)
.Select(x => new DepartmentDto(x.Id, x.Code, x.Name, x.ManagerUserId, x.Note, x.CreatedAt, x.UpdatedAt))
.ToListAsync(ct);
return new PagedResult<DepartmentDto>(items, total, request.Page, request.PageSize);
}
}
public record GetDepartmentQuery(Guid Id) : IRequest<DepartmentDto>;
public class GetDepartmentQueryHandler(IApplicationDbContext db) : IRequestHandler<GetDepartmentQuery, DepartmentDto>
{
public async Task<DepartmentDto> Handle(GetDepartmentQuery request, CancellationToken ct)
{
var x = await db.Departments.AsNoTracking().FirstOrDefaultAsync(p => p.Id == request.Id, ct)
?? throw new NotFoundException("Department", request.Id);
return new DepartmentDto(x.Id, x.Code, x.Name, x.ManagerUserId, x.Note, x.CreatedAt, x.UpdatedAt);
}
}
public record CreateDepartmentCommand(string Code, string Name, Guid? ManagerUserId, string? Note) : IRequest<Guid>;
public class CreateDepartmentCommandValidator : AbstractValidator<CreateDepartmentCommand>
{
public CreateDepartmentCommandValidator()
{
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
}
}
public class CreateDepartmentCommandHandler(IApplicationDbContext db) : IRequestHandler<CreateDepartmentCommand, Guid>
{
public async Task<Guid> Handle(CreateDepartmentCommand request, CancellationToken ct)
{
if (await db.Departments.AnyAsync(x => x.Code == request.Code, ct))
throw new ConflictException($"Mã phòng ban '{request.Code}' đã tồn tại.");
var entity = new Department
{
Code = request.Code, Name = request.Name,
ManagerUserId = request.ManagerUserId, Note = request.Note,
};
db.Departments.Add(entity);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record UpdateDepartmentCommand(Guid Id, string Code, string Name, Guid? ManagerUserId, string? Note) : IRequest;
public class UpdateDepartmentCommandValidator : AbstractValidator<UpdateDepartmentCommand>
{
public UpdateDepartmentCommandValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
}
}
public class UpdateDepartmentCommandHandler(IApplicationDbContext db) : IRequestHandler<UpdateDepartmentCommand>
{
public async Task Handle(UpdateDepartmentCommand request, CancellationToken ct)
{
var entity = await db.Departments.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("Department", request.Id);
if (entity.Code != request.Code && await db.Departments.AnyAsync(x => x.Code == request.Code && x.Id != request.Id, ct))
throw new ConflictException($"Mã phòng ban '{request.Code}' đã tồn tại.");
entity.Code = request.Code;
entity.Name = request.Name;
entity.ManagerUserId = request.ManagerUserId;
entity.Note = request.Note;
await db.SaveChangesAsync(ct);
}
}
public record DeleteDepartmentCommand(Guid Id) : IRequest;
public class DeleteDepartmentCommandHandler(IApplicationDbContext db) : IRequestHandler<DeleteDepartmentCommand>
{
public async Task Handle(DeleteDepartmentCommand request, CancellationToken ct)
{
var entity = await db.Departments.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("Department", request.Id);
db.Departments.Remove(entity);
await db.SaveChangesAsync(ct);
}
}

View File

@ -0,0 +1,150 @@
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.Master;
namespace SolutionErp.Application.Master.Projects;
public record ProjectDto(
Guid Id,
string Code,
string Name,
DateTime? StartDate,
DateTime? EndDate,
Guid? ManagerUserId,
decimal? BudgetTotal,
string? Note,
DateTime CreatedAt,
DateTime? UpdatedAt);
// ===================== LIST =====================
public record ListProjectsQuery : PagedRequest, IRequest<PagedResult<ProjectDto>>;
public class ListProjectsQueryHandler(IApplicationDbContext db) : IRequestHandler<ListProjectsQuery, PagedResult<ProjectDto>>
{
public async Task<PagedResult<ProjectDto>> Handle(ListProjectsQuery request, CancellationToken ct)
{
var query = db.Projects.AsNoTracking();
if (!string.IsNullOrWhiteSpace(request.Search))
{
var s = request.Search.Trim();
query = query.Where(x => x.Code.Contains(s) || x.Name.Contains(s));
}
query = (request.SortBy, request.SortDesc) switch
{
("code", true) => query.OrderByDescending(x => x.Code),
("code", false) => query.OrderBy(x => x.Code),
("name", true) => query.OrderByDescending(x => x.Name),
("name", false) => query.OrderBy(x => x.Name),
_ => query.OrderByDescending(x => x.CreatedAt),
};
var total = await query.CountAsync(ct);
var items = await query
.Skip((request.Page - 1) * request.PageSize).Take(request.PageSize)
.Select(x => new ProjectDto(x.Id, x.Code, x.Name, x.StartDate, x.EndDate, x.ManagerUserId, x.BudgetTotal, x.Note, x.CreatedAt, x.UpdatedAt))
.ToListAsync(ct);
return new PagedResult<ProjectDto>(items, total, request.Page, request.PageSize);
}
}
// ===================== GET =====================
public record GetProjectQuery(Guid Id) : IRequest<ProjectDto>;
public class GetProjectQueryHandler(IApplicationDbContext db) : IRequestHandler<GetProjectQuery, ProjectDto>
{
public async Task<ProjectDto> Handle(GetProjectQuery request, CancellationToken ct)
{
var x = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == request.Id, ct)
?? throw new NotFoundException("Project", request.Id);
return new ProjectDto(x.Id, x.Code, x.Name, x.StartDate, x.EndDate, x.ManagerUserId, x.BudgetTotal, x.Note, x.CreatedAt, x.UpdatedAt);
}
}
// ===================== CREATE =====================
public record CreateProjectCommand(
string Code, string Name, DateTime? StartDate, DateTime? EndDate,
Guid? ManagerUserId, decimal? BudgetTotal, string? Note) : IRequest<Guid>;
public class CreateProjectCommandValidator : AbstractValidator<CreateProjectCommand>
{
public CreateProjectCommandValidator()
{
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.BudgetTotal).GreaterThanOrEqualTo(0).When(x => x.BudgetTotal.HasValue);
RuleFor(x => x).Must(x => !x.StartDate.HasValue || !x.EndDate.HasValue || x.EndDate >= x.StartDate)
.WithMessage("Ngày kết thúc phải sau ngày bắt đầu");
}
}
public class CreateProjectCommandHandler(IApplicationDbContext db) : IRequestHandler<CreateProjectCommand, Guid>
{
public async Task<Guid> Handle(CreateProjectCommand request, CancellationToken ct)
{
if (await db.Projects.AnyAsync(x => x.Code == request.Code, ct))
throw new ConflictException($"Mã dự án '{request.Code}' đã tồn tại.");
var entity = new Project
{
Code = request.Code, Name = request.Name,
StartDate = request.StartDate, EndDate = request.EndDate,
ManagerUserId = request.ManagerUserId, BudgetTotal = request.BudgetTotal, Note = request.Note,
};
db.Projects.Add(entity);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
// ===================== UPDATE =====================
public record UpdateProjectCommand(
Guid Id, string Code, string Name, DateTime? StartDate, DateTime? EndDate,
Guid? ManagerUserId, decimal? BudgetTotal, string? Note) : IRequest;
public class UpdateProjectCommandValidator : AbstractValidator<UpdateProjectCommand>
{
public UpdateProjectCommandValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.BudgetTotal).GreaterThanOrEqualTo(0).When(x => x.BudgetTotal.HasValue);
RuleFor(x => x).Must(x => !x.StartDate.HasValue || !x.EndDate.HasValue || x.EndDate >= x.StartDate)
.WithMessage("Ngày kết thúc phải sau ngày bắt đầu");
}
}
public class UpdateProjectCommandHandler(IApplicationDbContext db) : IRequestHandler<UpdateProjectCommand>
{
public async Task Handle(UpdateProjectCommand request, CancellationToken ct)
{
var entity = await db.Projects.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("Project", request.Id);
if (entity.Code != request.Code && await db.Projects.AnyAsync(x => x.Code == request.Code && x.Id != request.Id, ct))
throw new ConflictException($"Mã dự án '{request.Code}' đã tồn tại.");
entity.Code = request.Code;
entity.Name = request.Name;
entity.StartDate = request.StartDate;
entity.EndDate = request.EndDate;
entity.ManagerUserId = request.ManagerUserId;
entity.BudgetTotal = request.BudgetTotal;
entity.Note = request.Note;
await db.SaveChangesAsync(ct);
}
}
// ===================== DELETE =====================
public record DeleteProjectCommand(Guid Id) : IRequest;
public class DeleteProjectCommandHandler(IApplicationDbContext db) : IRequestHandler<DeleteProjectCommand>
{
public async Task Handle(DeleteProjectCommand request, CancellationToken ct)
{
var entity = await db.Projects.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("Project", request.Id);
db.Projects.Remove(entity);
await db.SaveChangesAsync(ct);
}
}

View File

@ -0,0 +1,64 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Master;
namespace SolutionErp.Application.Master.Suppliers.Commands.CreateSupplier;
public record CreateSupplierCommand(
string Code,
string Name,
SupplierType Type,
string? TaxCode,
string? Phone,
string? Email,
string? Address,
string? ContactPerson,
string? Note) : IRequest<Guid>;
public class CreateSupplierCommandValidator : AbstractValidator<CreateSupplierCommand>
{
public CreateSupplierCommandValidator()
{
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Type).IsInEnum();
RuleFor(x => x.TaxCode).MaximumLength(20);
RuleFor(x => x.Phone).MaximumLength(30);
RuleFor(x => x.Email).MaximumLength(100).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.Email));
RuleFor(x => x.Address).MaximumLength(500);
RuleFor(x => x.ContactPerson).MaximumLength(200);
RuleFor(x => x.Note).MaximumLength(1000);
}
}
public class CreateSupplierCommandHandler : IRequestHandler<CreateSupplierCommand, Guid>
{
private readonly IApplicationDbContext _db;
public CreateSupplierCommandHandler(IApplicationDbContext db) => _db = db;
public async Task<Guid> Handle(CreateSupplierCommand request, CancellationToken ct)
{
if (await _db.Suppliers.AnyAsync(x => x.Code == request.Code, ct))
throw new ConflictException($"Mã NCC '{request.Code}' đã tồn tại.");
var entity = new Supplier
{
Code = request.Code,
Name = request.Name,
Type = request.Type,
TaxCode = request.TaxCode,
Phone = request.Phone,
Email = request.Email,
Address = request.Address,
ContactPerson = request.ContactPerson,
Note = request.Note,
};
_db.Suppliers.Add(entity);
await _db.SaveChangesAsync(ct);
return entity.Id;
}
}

View File

@ -0,0 +1,23 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
namespace SolutionErp.Application.Master.Suppliers.Commands.DeleteSupplier;
public record DeleteSupplierCommand(Guid Id) : IRequest;
public class DeleteSupplierCommandHandler : IRequestHandler<DeleteSupplierCommand>
{
private readonly IApplicationDbContext _db;
public DeleteSupplierCommandHandler(IApplicationDbContext db) => _db = db;
public async Task Handle(DeleteSupplierCommand request, CancellationToken ct)
{
var entity = await _db.Suppliers.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("Supplier", request.Id);
_db.Suppliers.Remove(entity); // AuditingInterceptor convert sang soft delete
await _db.SaveChangesAsync(ct);
}
}

View File

@ -0,0 +1,61 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Master;
namespace SolutionErp.Application.Master.Suppliers.Commands.UpdateSupplier;
public record UpdateSupplierCommand(
Guid Id,
string Code,
string Name,
SupplierType Type,
string? TaxCode,
string? Phone,
string? Email,
string? Address,
string? ContactPerson,
string? Note) : IRequest;
public class UpdateSupplierCommandValidator : AbstractValidator<UpdateSupplierCommand>
{
public UpdateSupplierCommandValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Type).IsInEnum();
RuleFor(x => x.Email).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.Email));
}
}
public class UpdateSupplierCommandHandler : IRequestHandler<UpdateSupplierCommand>
{
private readonly IApplicationDbContext _db;
public UpdateSupplierCommandHandler(IApplicationDbContext db) => _db = db;
public async Task Handle(UpdateSupplierCommand request, CancellationToken ct)
{
var entity = await _db.Suppliers.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("Supplier", request.Id);
if (entity.Code != request.Code &&
await _db.Suppliers.AnyAsync(x => x.Code == request.Code && x.Id != request.Id, ct))
throw new ConflictException($"Mã NCC '{request.Code}' đã tồn tại.");
entity.Code = request.Code;
entity.Name = request.Name;
entity.Type = request.Type;
entity.TaxCode = request.TaxCode;
entity.Phone = request.Phone;
entity.Email = request.Email;
entity.Address = request.Address;
entity.ContactPerson = request.ContactPerson;
entity.Note = request.Note;
await _db.SaveChangesAsync(ct);
}
}

View File

@ -0,0 +1,17 @@
using SolutionErp.Domain.Master;
namespace SolutionErp.Application.Master.Suppliers.Dtos;
public record SupplierDto(
Guid Id,
string Code,
string Name,
SupplierType Type,
string? TaxCode,
string? Phone,
string? Email,
string? Address,
string? ContactPerson,
string? Note,
DateTime CreatedAt,
DateTime? UpdatedAt);

View File

@ -0,0 +1,23 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Master.Suppliers.Dtos;
namespace SolutionErp.Application.Master.Suppliers.Queries.GetSupplier;
public record GetSupplierQuery(Guid Id) : IRequest<SupplierDto>;
public class GetSupplierQueryHandler : IRequestHandler<GetSupplierQuery, SupplierDto>
{
private readonly IApplicationDbContext _db;
public GetSupplierQueryHandler(IApplicationDbContext db) => _db = db;
public async Task<SupplierDto> Handle(GetSupplierQuery request, CancellationToken ct)
{
var x = await _db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == request.Id, ct)
?? throw new NotFoundException("Supplier", request.Id);
return new SupplierDto(x.Id, x.Code, x.Name, x.Type, x.TaxCode, x.Phone, x.Email, x.Address, x.ContactPerson, x.Note, x.CreatedAt, x.UpdatedAt);
}
}

View File

@ -0,0 +1,55 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.Master.Suppliers.Dtos;
using SolutionErp.Domain.Master;
namespace SolutionErp.Application.Master.Suppliers.Queries.ListSuppliers;
public record ListSuppliersQuery(SupplierType? Type = null) : PagedRequest, IRequest<PagedResult<SupplierDto>>;
public class ListSuppliersQueryHandler : IRequestHandler<ListSuppliersQuery, PagedResult<SupplierDto>>
{
private readonly IApplicationDbContext _db;
public ListSuppliersQueryHandler(IApplicationDbContext db) => _db = db;
public async Task<PagedResult<SupplierDto>> Handle(ListSuppliersQuery request, CancellationToken ct)
{
var query = _db.Suppliers.AsNoTracking();
if (request.Type is not null)
query = query.Where(x => x.Type == request.Type);
if (!string.IsNullOrWhiteSpace(request.Search))
{
var s = request.Search.Trim();
query = query.Where(x =>
x.Code.Contains(s) || x.Name.Contains(s) ||
(x.TaxCode != null && x.TaxCode.Contains(s)));
}
query = (request.SortBy, request.SortDesc) switch
{
("code", true) => query.OrderByDescending(x => x.Code),
("code", false) => query.OrderBy(x => x.Code),
("name", true) => query.OrderByDescending(x => x.Name),
("name", false) => query.OrderBy(x => x.Name),
("type", true) => query.OrderByDescending(x => x.Type),
("type", false) => query.OrderBy(x => x.Type),
_ => query.OrderByDescending(x => x.CreatedAt),
};
var total = await query.CountAsync(ct);
var items = await query
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.Select(x => new SupplierDto(
x.Id, x.Code, x.Name, x.Type, x.TaxCode, x.Phone, x.Email, x.Address, x.ContactPerson, x.Note,
x.CreatedAt, x.UpdatedAt))
.ToListAsync(ct);
return new PagedResult<SupplierDto>(items, total, request.Page, request.PageSize);
}
}

View File

@ -0,0 +1,35 @@
namespace SolutionErp.Application.Permissions.Dtos;
public record MenuNodeDto(
string Key,
string Label,
string? ParentKey,
int Order,
string? Icon,
bool CanRead,
bool CanCreate,
bool CanUpdate,
bool CanDelete,
List<MenuNodeDto> Children);
public record PermissionDto(
Guid Id,
Guid RoleId,
string MenuKey,
bool CanRead,
bool CanCreate,
bool CanUpdate,
bool CanDelete);
public record RoleDto(
Guid Id,
string Name,
string? Description,
DateTime CreatedAt);
public record MenuItemDto(
string Key,
string Label,
string? ParentKey,
int Order,
string? Icon);

View File

@ -0,0 +1,121 @@
using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Permissions.Dtos;
using SolutionErp.Domain.Identity;
namespace SolutionErp.Application.Permissions;
// ========== List menu items (for matrix page) ==========
public record ListMenuItemsQuery : IRequest<List<MenuItemDto>>;
public class ListMenuItemsQueryHandler(IApplicationDbContext db) : IRequestHandler<ListMenuItemsQuery, List<MenuItemDto>>
{
public async Task<List<MenuItemDto>> Handle(ListMenuItemsQuery request, CancellationToken ct)
{
return await db.MenuItems.AsNoTracking()
.OrderBy(m => m.Order)
.Select(m => new MenuItemDto(m.Key, m.Label, m.ParentKey, m.Order, m.Icon))
.ToListAsync(ct);
}
}
// ========== List permissions by role (matrix edit) ==========
public record ListPermissionsByRoleQuery(Guid RoleId) : IRequest<List<PermissionDto>>;
public class ListPermissionsByRoleQueryHandler(IApplicationDbContext db) : IRequestHandler<ListPermissionsByRoleQuery, List<PermissionDto>>
{
public async Task<List<PermissionDto>> Handle(ListPermissionsByRoleQuery request, CancellationToken ct)
{
return await db.Permissions.AsNoTracking()
.Where(p => p.RoleId == request.RoleId)
.Select(p => new PermissionDto(p.Id, p.RoleId, p.MenuKey, p.CanRead, p.CanCreate, p.CanUpdate, p.CanDelete))
.ToListAsync(ct);
}
}
// ========== Upsert permission (admin update matrix) ==========
public record UpsertPermissionCommand(
Guid RoleId,
string MenuKey,
bool CanRead,
bool CanCreate,
bool CanUpdate,
bool CanDelete) : IRequest;
public class UpsertPermissionCommandValidator : AbstractValidator<UpsertPermissionCommand>
{
public UpsertPermissionCommandValidator()
{
RuleFor(x => x.RoleId).NotEmpty();
RuleFor(x => x.MenuKey).NotEmpty().MaximumLength(50);
}
}
public class UpsertPermissionCommandHandler(
IApplicationDbContext db,
RoleManager<Role> roleManager,
ICurrentUser currentUser) : IRequestHandler<UpsertPermissionCommand>
{
public async Task Handle(UpsertPermissionCommand request, CancellationToken ct)
{
var role = await roleManager.FindByIdAsync(request.RoleId.ToString())
?? throw new NotFoundException("Role", request.RoleId);
// Guard: không cho tự xóa quyền của role Admin (nếu người đang edit là Admin)
if (role.Name == AppRoles.Admin && currentUser.Roles.Contains(AppRoles.Admin))
{
if (!(request.CanRead && request.CanCreate && request.CanUpdate && request.CanDelete))
throw new ForbiddenException("Không thể giảm quyền của role Admin khi bạn đang là Admin.");
}
var menu = await db.MenuItems.FirstOrDefaultAsync(m => m.Key == request.MenuKey, ct)
?? throw new NotFoundException("MenuItem", request.MenuKey);
var existing = await db.Permissions.FirstOrDefaultAsync(
p => p.RoleId == request.RoleId && p.MenuKey == request.MenuKey, ct);
if (existing is null)
{
db.Permissions.Add(new Permission
{
RoleId = request.RoleId,
MenuKey = request.MenuKey,
CanRead = request.CanRead,
CanCreate = request.CanCreate,
CanUpdate = request.CanUpdate,
CanDelete = request.CanDelete,
});
}
else
{
existing.CanRead = request.CanRead;
existing.CanCreate = request.CanCreate;
existing.CanUpdate = request.CanUpdate;
existing.CanDelete = request.CanDelete;
}
await db.SaveChangesAsync(ct);
}
}
// ========== List all roles ==========
public record ListRolesQuery : IRequest<List<RoleDto>>;
public class ListRolesQueryHandler(RoleManager<Role> roleManager) : IRequestHandler<ListRolesQuery, List<RoleDto>>
{
public async Task<List<RoleDto>> Handle(ListRolesQuery request, CancellationToken ct)
{
return await roleManager.Roles
.OrderBy(r => r.Name)
.Select(r => new RoleDto(r.Id, r.Name!, r.Description, r.CreatedAt))
.ToListAsync(ct);
}
}

View File

@ -0,0 +1,72 @@
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Permissions.Dtos;
using SolutionErp.Domain.Identity;
namespace SolutionErp.Application.Permissions.Queries.GetMyMenuTree;
public record GetMyMenuTreeQuery : IRequest<List<MenuNodeDto>>;
public class GetMyMenuTreeQueryHandler(
IApplicationDbContext db,
ICurrentUser currentUser,
UserManager<User> userManager,
RoleManager<Role> roleManager) : IRequestHandler<GetMyMenuTreeQuery, List<MenuNodeDto>>
{
public async Task<List<MenuNodeDto>> Handle(GetMyMenuTreeQuery request, CancellationToken ct)
{
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
throw new UnauthorizedException();
var user = await userManager.FindByIdAsync(currentUser.UserId.Value.ToString())
?? throw new UnauthorizedException();
var roleNames = await userManager.GetRolesAsync(user);
var roleIds = new List<Guid>();
foreach (var name in roleNames)
{
var role = await roleManager.FindByNameAsync(name);
if (role is not null) roleIds.Add(role.Id);
}
var menus = await db.MenuItems.AsNoTracking().OrderBy(m => m.Order).ToListAsync(ct);
var perms = await db.Permissions.AsNoTracking()
.Where(p => roleIds.Contains(p.RoleId))
.ToListAsync(ct);
// Union CRUD flags qua các role
var resolved = perms
.GroupBy(p => p.MenuKey)
.ToDictionary(g => g.Key, g => (
Read: g.Any(p => p.CanRead),
Create: g.Any(p => p.CanCreate),
Update: g.Any(p => p.CanUpdate),
Delete: g.Any(p => p.CanDelete)));
// Build tree
List<MenuNodeDto> BuildChildren(string? parentKey) => menus
.Where(m => m.ParentKey == parentKey)
.Select(m =>
{
var flags = resolved.TryGetValue(m.Key, out var f) ? f : (false, false, false, false);
return new MenuNodeDto(m.Key, m.Label, m.ParentKey, m.Order, m.Icon,
flags.Item1, flags.Item2, flags.Item3, flags.Item4,
BuildChildren(m.Key));
})
.ToList();
var tree = BuildChildren(null);
// Filter: chỉ trả về node có CanRead=true (hoặc có child CanRead=true)
static bool HasAccess(MenuNodeDto n) => n.CanRead || n.Children.Any(HasAccess);
List<MenuNodeDto> Filter(List<MenuNodeDto> nodes) => nodes
.Where(HasAccess)
.Select(n => n with { Children = Filter(n.Children) })
.ToList();
return Filter(tree);
}
}

View File

@ -8,6 +8,7 @@
<PackageReference Include="AutoMapper" Version="16.1.1" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="MediatR" Version="12.4.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.6" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" />
</ItemGroup>