[CLAUDE] Move nested-type menu → fe-user; Admin workflow config page
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m41s

User clarified: menu loại HĐ 3-level (Danh sách/Thao tác/Duyệt) thuộc
fe-user. Admin có page riêng để config quy trình per loại HĐ.

fe-admin Layout:
- filterForAdmin() drops Ct_* entries (hide nested type menu).
- Admin sidebar giờ về lại đơn giản: Dashboard / Master / Hợp đồng
  (leaf) / Forms / Reports / System.

fe-user Layout:
- Dynamic menu tree từ /menus/me (thay fixed USER_MENU hardcoded).
- Recursive MenuNodeRenderer (top-level expanded, nested collapsed).
- resolvePath user-specific: Ct_*_List → /my-contracts?type=X,
  Ct_*_Create → /contracts/new?type=X, Ct_*_Pending → /inbox?type=X.
- filterForUser drops admin-only entries (Master/System/Forms/Reports).
- Static USER_FIXED_TOP prepends "Hộp thư" leaf → /inbox.
- MyContractsPage + InboxPage đọc ?type=X param, filter client-side.

Workflow config (Admin side):
- Domain: WorkflowTypeAssignment entity (ContractType → PolicyName
  override). Registry.ForContractWithOverrides() prefer DB override
  else default.
- Infrastructure: EF config + migration AddWorkflowTypeAssignments,
  unique index trên ContractType. ContractWorkflowService load
  overrides dict mỗi transition. ContractFeatures load overrides khi
  build WorkflowSummaryDto.
- Application: GetWorkflowAdminOverviewQuery returns 7 types × current
  policy + available policies. SetWorkflowAssignmentCommand validate
  policy name tồn tại; nếu = default thì delete override (no stale row).
- Api: GET /api/workflows + PUT /api/workflows/{contractType}
  với policy "Workflows.Read" + "Workflows.Update".
- Menu: new key `Workflows` dưới System, label "Quy trình HĐ".
- FE /system/workflows: 7 card per type, dropdown Standard/SkipCcm +
  'Đã override' badge khi khác default, phase sequence timeline,
  explanation banner ở top. Iteration 2 note: admin-authored custom
  policies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-21 22:41:05 +07:00
parent 48e91fe7ca
commit 5e0f3801a1
20 changed files with 1737 additions and 48 deletions

View File

@ -337,6 +337,8 @@ public class GetContractQueryHandler(
var supplier = await db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == c.SupplierId, ct);
var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == c.ProjectId, ct);
var workflowOverrides = await db.WorkflowTypeAssignments.AsNoTracking()
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
var department = c.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == c.DepartmentId, ct);
// Resolve user names
@ -376,14 +378,16 @@ public class GetContractQueryHandler(
att.Id, att.FileName, att.StoragePath, att.FileSize,
att.ContentType, att.Purpose, att.Note, att.CreatedAt))
.ToList(),
BuildWorkflowSummary(c));
BuildWorkflowSummary(c, workflowOverrides));
}
// FE uses this to render next-phase buttons dynamically — no more hardcoded
// NEXT_PHASES map that silently drifts from the BE policy.
private static WorkflowSummaryDto BuildWorkflowSummary(Contract c)
private static WorkflowSummaryDto BuildWorkflowSummary(
Contract c,
IReadOnlyDictionary<ContractType, string>? overrides)
{
var policy = WorkflowPolicyRegistry.ForContract(c);
var policy = WorkflowPolicyRegistry.ForContractWithOverrides(c, overrides);
return new WorkflowSummaryDto(
PolicyName: policy.Name,
PolicyDescription: policy.Description,

View File

@ -0,0 +1,119 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Application.Contracts;
// Admin UI /system/workflows — list current policy assignment per ContractType
// + change via dropdown. Iteration 2: let admin define custom policies; for
// now they pick from WorkflowPolicyRegistry.AvailablePolicyNames.
public record WorkflowPhaseDto(int Phase, int? SlaDays, List<string> AllowedRolesAnyDir);
public record WorkflowPolicyDto(string Name, string Description, List<int> ActivePhases);
public record WorkflowTypeAssignmentDto(
int ContractType,
string ContractTypeLabel,
string CurrentPolicy,
string DefaultPolicy,
WorkflowPolicyDto Policy);
public record WorkflowAdminOverviewDto(
List<WorkflowPolicyDto> AvailablePolicies,
List<WorkflowTypeAssignmentDto> Assignments);
public record GetWorkflowAdminOverviewQuery : IRequest<WorkflowAdminOverviewDto>;
public class GetWorkflowAdminOverviewQueryHandler(IApplicationDbContext db)
: IRequestHandler<GetWorkflowAdminOverviewQuery, WorkflowAdminOverviewDto>
{
private static readonly Dictionary<ContractType, string> TypeLabels = new()
{
[ContractType.HopDongThauPhu] = "HĐ Thầu phụ",
[ContractType.HopDongGiaoKhoan] = "HĐ Giao khoán",
[ContractType.HopDongNhaCungCap] = "HĐ Nhà cung cấp",
[ContractType.HopDongDichVu] = "HĐ Dịch vụ",
[ContractType.HopDongMuaBan] = "HĐ Mua bán",
[ContractType.HopDongNguyenTacNCC] = "HĐ Nguyên tắc NCC",
[ContractType.HopDongNguyenTacDichVu] = "HĐ Nguyên tắc Dịch vụ",
};
public async Task<WorkflowAdminOverviewDto> Handle(GetWorkflowAdminOverviewQuery request, CancellationToken ct)
{
var overrides = await db.WorkflowTypeAssignments.AsNoTracking()
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
var availablePolicies = WorkflowPolicyRegistry.AvailablePolicyNames
.Select(WorkflowPolicyRegistry.ByName)
.Select(p => new WorkflowPolicyDto(p.Name, p.Description, p.ActivePhases.Select(x => (int)x).ToList()))
.ToList();
var assignments = Enum.GetValues<ContractType>()
.Select(t =>
{
var defaultName = WorkflowPolicyRegistry.DefaultPolicyNameFor(t);
var currentName = overrides.TryGetValue(t, out var n) ? n : defaultName;
var policy = WorkflowPolicyRegistry.ByName(currentName);
return new WorkflowTypeAssignmentDto(
(int)t,
TypeLabels.GetValueOrDefault(t, t.ToString()),
currentName,
defaultName,
new WorkflowPolicyDto(policy.Name, policy.Description, policy.ActivePhases.Select(x => (int)x).ToList()));
})
.ToList();
return new WorkflowAdminOverviewDto(availablePolicies, assignments);
}
}
public record SetWorkflowAssignmentCommand(ContractType ContractType, string PolicyName) : IRequest;
public class SetWorkflowAssignmentCommandValidator : AbstractValidator<SetWorkflowAssignmentCommand>
{
public SetWorkflowAssignmentCommandValidator()
{
RuleFor(x => x.ContractType).IsInEnum();
RuleFor(x => x.PolicyName).NotEmpty()
.Must(name => WorkflowPolicyRegistry.AvailablePolicyNames.Contains(name))
.WithMessage(x => $"Policy '{x.PolicyName}' không tồn tại. Cho phép: {string.Join(",", WorkflowPolicyRegistry.AvailablePolicyNames)}.");
}
}
public class SetWorkflowAssignmentCommandHandler(IApplicationDbContext db)
: IRequestHandler<SetWorkflowAssignmentCommand>
{
public async Task Handle(SetWorkflowAssignmentCommand request, CancellationToken ct)
{
var existing = await db.WorkflowTypeAssignments
.FirstOrDefaultAsync(a => a.ContractType == request.ContractType, ct);
// If user sets policy back to the hardcoded default, delete the override
// row so the registry uses the code-level default (no stale DB noise).
var isDefault = request.PolicyName == WorkflowPolicyRegistry.DefaultPolicyNameFor(request.ContractType);
if (existing is null)
{
if (isDefault) return; // nothing to persist
db.WorkflowTypeAssignments.Add(new WorkflowTypeAssignment
{
ContractType = request.ContractType,
PolicyName = request.PolicyName,
});
}
else if (isDefault)
{
db.WorkflowTypeAssignments.Remove(existing);
}
else
{
existing.PolicyName = request.PolicyName;
}
await db.SaveChangesAsync(ct);
}
}