[CLAUDE] Domain+App+Api+Infra+FE-Admin+FE-User: S34 Plan 2 G-O1 Danh bạ nội bộ
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m46s

Phase 10.2 Văn phòng số — Internal Directory (1 endpoint reuse Users +
EmployeeProfiles + Departments, FE card grid avatar/dept/email/phone/Ext).

BE Task 1+2 (em main solo):
- Application/Office/DirectoryFeatures.cs — GetDirectoryQuery + DirectoryItemDto
  12 field LEFT JOIN Users.IsActive + Departments + EmployeeProfiles
- Api/Controllers/DirectoryController.cs — GET /api/directory?search=&departmentId=
  class-level [Authorize] (mọi authenticated NV tra cứu danh bạ nội bộ)
- MenuKeys.cs +Off+OffDanhBa const + All[] update
- DbInitializer.SeedMenuTreeAsync Off Order=29 + OffDanhBa Order=1 dưới Off

FE Task 3 (Implementer Case 2 Pattern 16-bis 4-place mirror cross-app — 5×):
- types/directory.ts SHA256 7349d9f64e78 × 2 app IDENTICAL
- pages/office/InternalDirectoryPage.tsx SHA256 2aa7e0eed2c8 × 2 app IDENTICAL
  Card grid responsive 1/2/3/4 col + filter dept dropdown + search input
  Avatar 14×14 initials gradient PALETTE 6 màu (Pattern 14 Tailwind JIT)
  EmployeeCode badge + Department emerald badge + email mailto + phone tel
  Internal phone Ext: amber badge + empty/loading state Vietnamese 100%
- App.tsx route /directory × 2 app
- lib/menuKeys.ts Off+OffDanhBa const × 2 app
- components/Layout.tsx resolvePath staticMap Off_DanhBa:/directory × 2 app
  (gotcha #50 — 5 places mirror crossapp DON'T MISS)

Verify:
- dotnet build PASS (2 warn DocxRenderer existing, 0 error)
- dotnet test 120/120 PASS (58 Domain + 62 Infra baseline preserve)
- npm build × 2 app PASS 0 TS err (fe-admin 1436KB / fe-user 1350KB)

Implementer MEMORY Pattern 16-bis reinforced 5× cumulative (S29 Plan CA HF1 +
S29 Plan B Chunk D + S33 Plan B G-H1 Task 5 + S34 Plan G-O1 Task 3).

Endpoint smoke pending CICD post-deploy Stage 4 (Run #XXX expected ~3m30s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-27 13:39:10 +07:00
parent 7b0781b94e
commit ea440da990
15 changed files with 676 additions and 1 deletions

View File

@ -0,0 +1,97 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
namespace SolutionErp.Application.Office;
// Phase 10.2 G-O1 (S34) — Danh bạ nội bộ.
// 1 endpoint readonly query JOIN Users + EmployeeProfiles + Departments.
// FE card grid hiển thị avatar/name/dept/position/email/phone/internal-phone.
// Reuse existing data — KHÔNG mở schema mới.
public sealed record DirectoryItemDto(
Guid UserId,
string FullName,
string? Position,
string? PhotoUrl,
Guid? DepartmentId,
string? DepartmentName,
string? EmployeeCode,
string? Email,
string? Phone,
string? InternalPhone,
string? PersonalEmail,
string? WorkLocation);
public sealed record GetDirectoryQuery(
string? Search = null,
Guid? DepartmentId = null) : IRequest<List<DirectoryItemDto>>;
public sealed class GetDirectoryQueryHandler(IApplicationDbContext db)
: IRequestHandler<GetDirectoryQuery, List<DirectoryItemDto>>
{
public async Task<List<DirectoryItemDto>> Handle(GetDirectoryQuery request, CancellationToken ct)
{
// Active users only — Department LEFT join (admin/system user có thể null dept).
// EmployeeProfile LEFT join (user chưa được seed EmployeeProfile vẫn hiện trong danh bạ
// với phone/internalPhone null — sau Phase 10.1 G-H1 thì tất cả 33 prod user đều có).
var query =
from u in db.Users.AsNoTracking().Where(x => x.IsActive)
join d in db.Departments.AsNoTracking() on u.DepartmentId equals d.Id into deptJoin
from d in deptJoin.DefaultIfEmpty()
join ep in db.EmployeeProfiles.AsNoTracking() on u.Id equals ep.UserId into epJoin
from ep in epJoin.DefaultIfEmpty()
select new
{
u.Id,
u.FullName,
u.Position,
u.Email,
u.DepartmentId,
DepartmentName = d != null ? d.Name : null,
EmployeeCode = ep != null ? ep.EmployeeCode : null,
Phone = ep != null ? ep.Phone : null,
InternalPhone = ep != null ? ep.InternalPhone : null,
PersonalEmail = ep != null ? ep.PersonalEmail : null,
PhotoUrl = ep != null ? ep.PhotoUrl : null,
WorkLocation = ep != null ? ep.WorkLocation : null,
};
if (request.DepartmentId is Guid deptId)
{
query = query.Where(x => x.DepartmentId == deptId);
}
if (!string.IsNullOrWhiteSpace(request.Search))
{
var term = request.Search.Trim();
query = query.Where(x =>
x.FullName.Contains(term) ||
(x.Email != null && x.Email.Contains(term)) ||
(x.Phone != null && x.Phone.Contains(term)) ||
(x.InternalPhone != null && x.InternalPhone.Contains(term)) ||
(x.EmployeeCode != null && x.EmployeeCode.Contains(term)));
}
var rows = await query
.OrderBy(x => x.DepartmentName)
.ThenBy(x => x.FullName)
.ToListAsync(ct);
return rows
.Select(x => new DirectoryItemDto(
x.Id,
x.FullName,
x.Position,
x.PhotoUrl,
x.DepartmentId,
x.DepartmentName,
x.EmployeeCode,
x.Email,
x.Phone,
x.InternalPhone,
x.PersonalEmail,
x.WorkLocation))
.ToList();
}
}