From ea440da990941a1c718474a926f48bc3b2ea663d Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Wed, 27 May 2026 13:39:10 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20Domain+App+Api+Infra+FE-Admin+FE-Use?= =?UTF-8?q?r:=20S34=20Plan=202=20G-O1=20Danh=20b=E1=BA=A1=20n=E1=BB=99i=20?= =?UTF-8?q?b=E1=BB=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .claude/agent-memory/implementer/MEMORY.md | 9 +- fe-admin/src/App.tsx | 3 + fe-admin/src/components/Layout.tsx | 3 + fe-admin/src/lib/menuKeys.ts | 3 + .../pages/office/InternalDirectoryPage.tsx | 236 ++++++++++++++++++ fe-admin/src/types/directory.ts | 22 ++ fe-user/src/App.tsx | 3 + fe-user/src/components/Layout.tsx | 3 + fe-user/src/lib/menuKeys.ts | 3 + .../pages/office/InternalDirectoryPage.tsx | 236 ++++++++++++++++++ fe-user/src/types/directory.ts | 22 ++ .../Controllers/DirectoryController.cs | 22 ++ .../Office/DirectoryFeatures.cs | 97 +++++++ .../SolutionErp.Domain/Identity/MenuKeys.cs | 10 + .../Persistence/DbInitializer.cs | 5 + 15 files changed, 676 insertions(+), 1 deletion(-) create mode 100644 fe-admin/src/pages/office/InternalDirectoryPage.tsx create mode 100644 fe-admin/src/types/directory.ts create mode 100644 fe-user/src/pages/office/InternalDirectoryPage.tsx create mode 100644 fe-user/src/types/directory.ts create mode 100644 src/Backend/SolutionErp.Api/Controllers/DirectoryController.cs create mode 100644 src/Backend/SolutionErp.Application/Office/DirectoryFeatures.cs diff --git a/.claude/agent-memory/implementer/MEMORY.md b/.claude/agent-memory/implementer/MEMORY.md index fb08114..8d7ddc4 100644 --- a/.claude/agent-memory/implementer/MEMORY.md +++ b/.claude/agent-memory/implementer/MEMORY.md @@ -162,7 +162,7 @@ Pattern reusable: test PE workflow → 1 Step + 2 Levels + N approvers per Level Tránh API surface bloat. Reusable cho future guard / helper internal cần test. -### Pattern 16-bis: 4-place mirror checklist khi cookie-cutter copy page CROSS-APP (S29 Plan CA Hotfix 1 — gotcha #50) +### Pattern 16-bis: 4-place mirror checklist khi cookie-cutter copy page CROSS-APP (S29 Plan CA Hotfix 1 — gotcha #50, reinforced 5× cumulative qua S33+S34) Khi spec yêu cầu "move page X từ fe-admin → fe-user" hoặc ngược lại (Implementer Case 2 cookie-cutter mirror page), MUST mirror 4 places (NOT just 3): @@ -175,6 +175,13 @@ Khi spec yêu cầu "move page X từ fe-admin → fe-user" hoặc ngược lạ **Verification post-fix:** Reviewer Cat 1 "Wire claim verify" SHOULD add to checklist: "Sidebar menu visible end-to-end test post-build" — curl `/api/menus/me` + grep MenuLeaf render output. Smart Friend prevent silent drop. +**S34 G-O1 Task 3 reinforcement (2026-05-27, Plan B Internal Directory):** Pattern 16-bis applied clean lần thứ 5 cumulative. Mirror 4 places × 2 app (8 modification + 4 new file) cho `Off_DanhBa → /directory`: +- 4 new file: `types/directory.ts` × 2 (SHA256 `7349d9f64e78`) + `pages/office/InternalDirectoryPage.tsx` × 2 (SHA256 `2aa7e0eed2c8`) — both MATCH identical hash +- 6 modified: App.tsx × 2 (+route), menuKeys.ts × 2 (+Off/OffDanhBa const), Layout.tsx × 2 (+staticMap Off_DanhBa) +- npm build × 2 app: fe-admin 21.99s clean (bundle 1436.71 kB / gzip 364.54 kB), fe-user 9.37s clean (bundle 1350.28 kB / gzip 349.01 kB) — 0 TS error +- Token cost ~20k (under budget 25k). Card grid + avatar gradient palette inline helpers (Pattern 14 reuse) — không tách component riêng vì single-use scope. +- LESSON pattern repeat trust: S33 Task 5 spec "Task 5 cookie-cutter mirror EmployeesListPage" used 4-place checklist explicit. S34 G-O1 Task 3 spec follow same template → execute 0 ambiguity. Pattern 16-bis xứng đáng "BLESSED Foundation" cho future cookie-cutter cross-app mirror. + ### Pattern 12-bis: Cross-module entity cookie-cutter mirror (S29 Plan B Chunk C — Mig 33) Khi spec yêu cầu "mirror entity X từ PE module sang Contract module" (vd LevelOpinions / DepartmentApproval / ManualBudgetFields): diff --git a/fe-admin/src/App.tsx b/fe-admin/src/App.tsx index 06bfabf..455d47f 100644 --- a/fe-admin/src/App.tsx +++ b/fe-admin/src/App.tsx @@ -28,6 +28,7 @@ import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPa import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage' import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage' import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage' +import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage' function App() { return ( @@ -73,6 +74,8 @@ function App() { {/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */} } /> } /> + {/* Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1) */} + } /> } /> } /> + (await api.get>('/departments', { params: { page: 1, pageSize: 200 } })).data.items, + }) + + const list = useQuery({ + queryKey: ['directory', { search, departmentId }], + queryFn: async () => { + const res = await api.get('/directory', { + params: { + search: search || undefined, + departmentId: departmentId || undefined, + }, + }) + return res.data + }, + }) + + function setParam(key: string, value: string | null) { + const next = new URLSearchParams(sp) + if (value == null || value === '') next.delete(key) + else next.set(key, value) + setSp(next, { replace: true }) + } + + function applySearch(e: React.FormEvent) { + e.preventDefault() + setParam('q', localSearch.trim() || null) + } + + const total = list.data?.length ?? 0 + + return ( +
+ + + {/* Filter bar sticky top */} +
+
+ + setLocalSearch(e.target.value)} + onBlur={() => setParam('q', localSearch.trim() || null)} + placeholder="Tìm tên / email / SĐT / mã NV..." + className="pl-8" + /> + + +
+ + {/* Card grid */} + {list.isLoading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ ) : total === 0 ? ( + + ) : ( +
+ {list.data!.map(item => ( + + ))} +
+ )} +
+ ) +} + +function DirectoryCard({ item }: { item: DirectoryItem }) { + return ( +
+ {/* Top row: avatar + name + code */} +
+ {item.photoUrl ? ( + {item.fullName} + ) : ( +
+ {initials(item.fullName)} +
+ )} +
+
+

+ {item.fullName} +

+ {item.employeeCode && ( + + {item.employeeCode} + + )} +
+ {item.position && ( +

+ {item.position} +

+ )} + {item.departmentName && ( + + {item.departmentName} + + )} +
+
+ + {/* Contact rows */} +
+ {item.email ? ( + + + {item.email} + + ) : ( +
+ + +
+ )} + + {item.phone ? ( + + + {formatPhone(item.phone)} + + ) : ( +
+ + +
+ )} + + {item.internalPhone && ( +
+ + + Ext: {item.internalPhone} + +
+ )} +
+
+ ) +} diff --git a/fe-admin/src/types/directory.ts b/fe-admin/src/types/directory.ts new file mode 100644 index 0000000..c7d32c8 --- /dev/null +++ b/fe-admin/src/types/directory.ts @@ -0,0 +1,22 @@ +// Danh bạ nội bộ (Internal Directory) — Phase 10.2 G-O1 (S34 2026-05-27). +// Mirror BE DirectoryItemDto (Application/Office/DirectoryFeatures.cs). +// File này MIRROR SHA256 identical với fe-admin/src/types/directory.ts. +export type DirectoryItem = { + userId: string + fullName: string + position: string | null + photoUrl: string | null + departmentId: string | null + departmentName: string | null + employeeCode: string | null + email: string | null + phone: string | null + internalPhone: string | null + personalEmail: string | null + workLocation: string | null +} + +export type DirectoryQuery = { + search?: string + departmentId?: string +} diff --git a/fe-user/src/App.tsx b/fe-user/src/App.tsx index 3437871..81bc7d4 100644 --- a/fe-user/src/App.tsx +++ b/fe-user/src/App.tsx @@ -21,6 +21,7 @@ import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPa import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage' import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage' import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage' +import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage' function App() { return ( @@ -56,6 +57,8 @@ function App() { {/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */} } /> } /> + {/* Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1) */} + } /> } /> + (await api.get>('/departments', { params: { page: 1, pageSize: 200 } })).data.items, + }) + + const list = useQuery({ + queryKey: ['directory', { search, departmentId }], + queryFn: async () => { + const res = await api.get('/directory', { + params: { + search: search || undefined, + departmentId: departmentId || undefined, + }, + }) + return res.data + }, + }) + + function setParam(key: string, value: string | null) { + const next = new URLSearchParams(sp) + if (value == null || value === '') next.delete(key) + else next.set(key, value) + setSp(next, { replace: true }) + } + + function applySearch(e: React.FormEvent) { + e.preventDefault() + setParam('q', localSearch.trim() || null) + } + + const total = list.data?.length ?? 0 + + return ( +
+ + + {/* Filter bar sticky top */} +
+
+ + setLocalSearch(e.target.value)} + onBlur={() => setParam('q', localSearch.trim() || null)} + placeholder="Tìm tên / email / SĐT / mã NV..." + className="pl-8" + /> + + +
+ + {/* Card grid */} + {list.isLoading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ ) : total === 0 ? ( + + ) : ( +
+ {list.data!.map(item => ( + + ))} +
+ )} +
+ ) +} + +function DirectoryCard({ item }: { item: DirectoryItem }) { + return ( +
+ {/* Top row: avatar + name + code */} +
+ {item.photoUrl ? ( + {item.fullName} + ) : ( +
+ {initials(item.fullName)} +
+ )} +
+
+

+ {item.fullName} +

+ {item.employeeCode && ( + + {item.employeeCode} + + )} +
+ {item.position && ( +

+ {item.position} +

+ )} + {item.departmentName && ( + + {item.departmentName} + + )} +
+
+ + {/* Contact rows */} +
+ {item.email ? ( + + + {item.email} + + ) : ( +
+ + +
+ )} + + {item.phone ? ( + + + {formatPhone(item.phone)} + + ) : ( +
+ + +
+ )} + + {item.internalPhone && ( +
+ + + Ext: {item.internalPhone} + +
+ )} +
+
+ ) +} diff --git a/fe-user/src/types/directory.ts b/fe-user/src/types/directory.ts new file mode 100644 index 0000000..c7d32c8 --- /dev/null +++ b/fe-user/src/types/directory.ts @@ -0,0 +1,22 @@ +// Danh bạ nội bộ (Internal Directory) — Phase 10.2 G-O1 (S34 2026-05-27). +// Mirror BE DirectoryItemDto (Application/Office/DirectoryFeatures.cs). +// File này MIRROR SHA256 identical với fe-admin/src/types/directory.ts. +export type DirectoryItem = { + userId: string + fullName: string + position: string | null + photoUrl: string | null + departmentId: string | null + departmentName: string | null + employeeCode: string | null + email: string | null + phone: string | null + internalPhone: string | null + personalEmail: string | null + workLocation: string | null +} + +export type DirectoryQuery = { + search?: string + departmentId?: string +} diff --git a/src/Backend/SolutionErp.Api/Controllers/DirectoryController.cs b/src/Backend/SolutionErp.Api/Controllers/DirectoryController.cs new file mode 100644 index 0000000..52b1edc --- /dev/null +++ b/src/Backend/SolutionErp.Api/Controllers/DirectoryController.cs @@ -0,0 +1,22 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SolutionErp.Application.Office; + +namespace SolutionErp.Api.Controllers; + +// Phase 10.2 G-O1 (S34) — Danh bạ nội bộ. +// 1 endpoint readonly. Class-level [Authorize] — mọi authenticated user thấy được +// (không restrict admin-only vì danh bạ nội bộ default open cho NV tra cứu nhau). +[ApiController] +[Route("api/directory")] +[Authorize] +public class DirectoryController(IMediator mediator) : ControllerBase +{ + [HttpGet] + public async Task>> Get( + [FromQuery] string? search = null, + [FromQuery] Guid? departmentId = null, + CancellationToken ct = default) + => Ok(await mediator.Send(new GetDirectoryQuery(search, departmentId), ct)); +} diff --git a/src/Backend/SolutionErp.Application/Office/DirectoryFeatures.cs b/src/Backend/SolutionErp.Application/Office/DirectoryFeatures.cs new file mode 100644 index 0000000..6c4df6b --- /dev/null +++ b/src/Backend/SolutionErp.Application/Office/DirectoryFeatures.cs @@ -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>; + +public sealed class GetDirectoryQueryHandler(IApplicationDbContext db) + : IRequestHandler> +{ + public async Task> 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(); + } +} diff --git a/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs b/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs index 5433638..4a2a088 100644 --- a/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs +++ b/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs @@ -85,6 +85,15 @@ public static class MenuKeys public const string Hrm = "Hrm"; // root group public const string HrmHoSo = "Hrm_HoSo"; // Hồ sơ Nhân sự (list + detail + edit) + // ============================================================ + // Module Văn phòng số (Phase 10.2 G-O1+ S34 2026-05-27). + // 1 root group `Off` + leaf con: Off_DanhBa (G-O1 Danh bạ nội bộ). + // Future Phase 10.2+ add: Off_PhongHop (G-O2 booking) + + // workflow apps Off_DeXuat / Off_DonTu / Off_DatXe / Off_ItTicket. + // ============================================================ + public const string Off = "Off"; // root group văn phòng số + public const string OffDanhBa = "Off_DanhBa"; // Danh bạ nội bộ (card grid) + public static readonly string[] PurchaseEvaluationTypeCodes = ["DuyetNcc", "DuyetNccPhuongAn"]; @@ -110,6 +119,7 @@ public static class MenuKeys PurchaseEvaluations, Budgets, BudgetList, BudgetCreate, BudgetPending, Hrm, HrmHoSo, // Mig 34 — Phase 10.1 + Off, OffDanhBa, // Phase 10.2 G-O1 — Văn phòng số System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows, ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22 ]; diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs index 062c812..982ce5d 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs @@ -1483,6 +1483,11 @@ public static class DbInitializer // Phase 1 minimal. Phase 1.5 + G-H2/G-H3 thêm Config/Dashboard. (MenuKeys.Hrm, "Nhân sự", null, 28, "UserCircle"), (MenuKeys.HrmHoSo, "Hồ sơ Nhân sự", MenuKeys.Hrm, 1, "ContactRound"), + + // Module Văn phòng số (Phase 10.2 G-O1+ S34). 1 root + leaf Danh bạ. + // Future leaf: Off_PhongHop (G-O2) + workflow apps Off_DeXuat/DonTu/DatXe/ItTicket. + (MenuKeys.Off, "Văn phòng số", null, 29, "Briefcase"), + (MenuKeys.OffDanhBa, "Danh bạ nội bộ", MenuKeys.Off, 1, "BookUser"), }; // Per-type sub-menu under Contracts: 1 group + 3 leaves each