[CLAUDE] App+FE-User+FE-Admin: Plan AG4 — bổ sung Drafter + Department vào PE List card
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m27s

Anh UAT 2026-05-21: PE card danh sách thiếu người tạo + phòng ban tạo. Bổ sung 4 field
qua BE JOIN Users + Departments LEFT (cả 2 nullable theo PE entity).

BE — 4 file:
- PurchaseEvaluationDtos.cs: +4 fields DrafterUserId/DrafterName/DepartmentId/DepartmentName
- PurchaseEvaluationFeatures.cs ListHandler: JOIN Users + Departments LEFT, projection +4
- PurchaseEvaluationFeatures.cs InboxHandler: mirror JOIN + projection +4
- CreateContractFromEvaluationFeatures.cs ListApproved: mirror JOIN + projection +4

FE — 4 file × 2 app mirror:
- types/purchaseEvaluation.ts: PeListItem +4 fields
- pages/pe/PurchaseEvaluationsListPage.tsx: PE card render thêm dòng "👤 {drafterName} · {departmentName}"
  giữa Mã phiếu và Supplier. Conditional: chỉ render khi có ít nhất 1 field.

Verify:
- dotnet build clean 0 err
- dotnet test SolutionErp.slnx 111/111 PASS (58 Domain + 53 Infra) — no regression
- npm build fe-user PASS 0 TS err 1290.31 KB (gzip 336.79 KB) 1907 modules
- npm build fe-admin PASS 0 TS err 1401.66 KB (gzip 357.30 KB) 1926 modules
- 2 FE PE List file SHA256 IDENTICAL C6996194... (mirror §3.9)
- KHÔNG Mig (chỉ DTO + projection extend)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-21 18:52:04 +07:00
parent fbad4a9251
commit 2bf01184ca
8 changed files with 60 additions and 6 deletions

View File

@ -287,6 +287,8 @@ KHÔNG `*` / `latest`. Critical pins:
## 📅 Recent activity (last 10 FIFO)
- **2026-05-21 (S26 t1, Plan AG Chunk A+B+C PASS — Phase 1 PE List tree view 2-level):** UAT feedback bro Tra Sol "đám rừng" flat list → Outlook folder tree. **3 chunk cumulative 1 commit** `0bf6c7e` 2 file +346/-116 LOC = +115 LOC each. Mirror 2 app §3.9 IDENTICAL post-edit (SHA256 verify match `21001E90...`). Chunk A useMemo group nested: `ProjectGroup{projectId, projectName, goiThauList[], totalCount}` + `GoiThauGroup{displayName, normalizedKey, items[]}`. Normalize trim + toLowerCase group key, display raw đầu tiên trong group. Fallback "(Dự án đã xoá)" empty projectName + "(Chưa phân loại)" empty TenGoiThau. Sort vi locale 2 cấp A-Z. Filter pendingMe → DaGuiDuyet áp dụng TRƯỚC group (empty state đúng). Chunk B UI `<details>/<summary>` HTML native 2-level — fe-user no shadcn Accordion → native browser disclosure widget free. Tailwind v3 named groups `group/proj` + `group/gt` cho chevron rotation `group-open/proj:rotate-90`. `[&::-webkit-details-marker]:hidden` ẩn default disclosure triangle browser. 📁 + 📄 emoji icon inline + count badge `rounded-full bg-slate-200/100`. PE card content preserve nguyên (text + badge + date format + contractId hint — line 209-248 cũ). Chunk C localStorage persist Set<string> key `pe_list_expanded_groups`. Project key: `projectId or '__no_project__'`. Gói thầu key: `${projectId}::${normalizedGoiThau}`. Default empty Set (all collapse) — Outlook-style closed default. `try/catch` defensive cho localStorage (storage quota / private browsing). Header badge `pendingMe ? totalRowCount : list.data?.total` (replace `rows.length`). Empty state check `projectGroups.length === 0` (replace `rows.length === 0`). Import `useMemo, useState` từ 'react' (file pre-existing chỉ import từ tanstack). Build: fe-user PASS 0 TS err 1291.33 KB gzip 337.00 KB 1907 modules 16.05s; fe-admin PASS 0 TS err 1402.68 KB gzip 357.51 KB 1926 modules 6.86s. Pre-existing CSS @import warn + INEFFECTIVE_DYNAMIC_IMPORT realtime.ts unchanged. KHÔNG ops git push (em main verify Reviewer rồi push). Token ~16k (close to ~14k baseline Case 2 mirror 2 app). **Pattern 19 NEW**: HTML native `<details>/<summary>` + Tailwind named groups (`group/<name>`) + localStorage Set<string> persist cho hierarchical UI when no Accordion lib available. Free open/close state native browser (Space/Enter keyboard accessible) + 0 JS state per node + serialize/deserialize Set ↔ JSON array string. Tailwind v3 named groups syntax `group/proj` parent + `group-open/proj:rotate-90` child differs from default unnamed `group` + `group-open:rotate-90` — critical when nested groups cùng level cần distinct event scope. Reusable cho future tree views: Project explorer · Dept hierarchy · Permission tree · Workflow definition step list (vs HTML5 native vs shadcn vs JS library). Anti-pattern: nested same-name `group` would inherit parent state → both rotate sync. **Pattern 5 mirror 2 app §3.9 applied 8th cumulative S20-S26** (proven reliable IDENTICAL hash check sau edit batch — recommend tooling `git diff fe-admin/X fe-user/X` after every multi-file edit batch).
- **2026-05-19 (S25 wrap — Plan AB Chunk A Case 1 + 6 follow-up plans em main solo):** Plan AB Chunk A spawn 1× ~12K Case 1 cookie-cutter mirror. BE refactor ApplyReturnModeAsync Drafter early return → common path (line 280-287 → if/else block) + single Changelog.Add() ở cuối hàm với modeName switch enum + actorName resolve via userManager.FindByIdAsync mirror LogTransitionAsync pattern. FE × 2 app HistoryTab filter relax (PE_ENTITY_HEADER=1 + summary contains 'ngân sách' for Bug 1 + Workflow summary contains 'Trả lại' for Bug 2). KHÔNG TS test (UAT mode skip). KHÔNG migration. KHÔNG endpoint. Commit cdfd542 3 file +146/-95 LOC PASS. **Em main solo từ Plan AC** (cross-stack reasoning + UAT iteration borderline scope — Implementer would REFUSE per criteria #4 tight coupling BE+FE same plan). AC capture pre-call Step/Level + add Approval row Reject branch + skipToFinal comment + FE Decision badge × 2 app. AC2 FE merge synthetic Reject + dedupe timestamp 5s bucket. AD drop phase badges + extractNextTargetHint regex parse. AE BE batch 9 Changelog.Add sites UserName preventive fix. AF FE userMap fallback từ embedded domain data PeDetailBundle. **Pattern 16 NEW** (cumulative S25): Preventive systemic batch fix khi audit phát hiện 9 sites cùng bug pattern — replace_all=true với context-aware key (UserId line + Summary line) — 1 pass cover N sites idempotent. **Pattern 17 NEW**: FE merge synthetic rows từ Changelog cho audit historical recovery — pattern reusable cho Contract V2 + Budget V2 audit visualization without DB write. **Pattern 18 NEW**: FE userMap fallback từ embedded domain data (drafter + approvals + approvalFlow + levelOpinions + departmentOpinions) — no extra API fetch cho historical name resolve.
- **2026-05-19 (S25, Plan AB Chunk A PASS):** Bug 1+2 fix Changelog visibility audit log UAT. Commit `cdfd542` 3 file +146/-95 LOC. **BE** `PurchaseEvaluationWorkflowService.cs` `ApplyReturnModeAsync` lines 215-378 refactor: Drafter early return (line 282-287) → if/else common path, `summary = "Trả về Người soạn thảo"` thay vì return early, SLA reset move bên trong else block 3 mode còn lại (Drafter có riêng `evaluation.SlaDeadline = null`). Single Changelog.Add() ở cuối hàm cover 4 mode uniform: `EntityType=Workflow + Action=Update + Summary=$"Trả lại ({modeName}): {summary}"` với modeName switch ("Người soạn thảo"/"1 Cấp"/"1 Bước"/"Người chỉ định"). `actorName` resolve qua `userManager.FindByIdAsync` mirror pattern existing line 660-667 (LogTransition helper). KHÔNG SaveChangesAsync mới — caller `TransitionAsync` line 100 đã có downstream save. **FE** 2 file `PeDetailTabs.tsx` × 2 app mirror exact: filter extend `if (l.summary?.includes('Trả lại')) return true` (Workflow entity) + `if (l.entityType === PE_ENTITY_HEADER && l.summary?.toLowerCase().includes('ngân sách')) return true` (Header entity new const = 1). Empty placeholder + comment 3-source rewrite (UAT 2026-05-08 + 2026-05-19 + bullet list 5 filter rule). Verify: BE build clean 0 err 2 pre-existing DocxRenderer warn (20.27s), fe-user 1907 modules 16.62s 0 TS err, fe-admin 1926 modules 6.98s 0 TS err. Test SKIP per UAT mode `feedback_uat_skip_verify` Phase 9 (111 baseline preserve). **NEW pattern observed (cumulative)**: `Changelog log common path refactor + FE filter substring summary discrimination`. Reusable cho future audit log derived state (vd Adjust*/Return*/Reset* action): refactor early return → if/else common path để single log call cover N branch, FE filter qua substring summary keyword chứ KHÔNG enum field strict (action verb tiếng Việt "Trả lại"/"ngân sách" dễ maintain hơn enum + cho FE flexibility filter mới mà không cần BE schema migrate). Cross-ref Pattern 4 `feedback_service_hook_vs_endpoint` (state X derived của action Y → log trong handler Y, KHÔNG endpoint /X riêng — Bug 2 ApplyReturnModeAsync log trong service hook KHÔNG endpoint /return-changelog rời). Pattern 5 mirror 2 app §3.9 applied 7th cumulative. Token ~12k. Diff: BE +83/-49 (refactor + new log block ~40 LOC), FE × 2 app +14/-6 each (filter + comment). KHÔNG ops git push (em main verify Reviewer rồi mới push).

View File

@ -287,6 +287,12 @@ export function PurchaseEvaluationsListPage() {
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{p.maPhieu ?? '—'}</span>
</div>
{(p.drafterName || p.departmentName) && (
<div className="mt-0.5 truncate text-[11px] text-slate-500">
<span>👤 {p.drafterName ?? '—'}</span>
{p.departmentName && <span className="text-slate-400"> · {p.departmentName}</span>}
</div>
)}
{p.selectedSupplierName && (
<div className="mt-0.5 truncate text-[11px] text-emerald-600">
{p.selectedSupplierName}

View File

@ -122,6 +122,12 @@ export type PeListItem = {
// update (Tạo / Gửi duyệt / Trả lại) đưa lên đầu. FE list item hiển thị
// "Tạo lúc createdAt" (KHÔNG còn SLA countdown "Còn N ngày").
updatedAt: string | null
// Plan AG4 (2026-05-21): bro UAT yêu cầu bổ sung Người tạo + Phòng ban tạo
// vào PE card. BE JOIN Users + Departments LEFT, cả 2 nullable theo PE entity.
drafterUserId: string | null
drafterName: string | null
departmentId: string | null
departmentName: string | null
}
export type PeSupplier = {

View File

@ -287,6 +287,12 @@ export function PurchaseEvaluationsListPage() {
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{p.maPhieu ?? '—'}</span>
</div>
{(p.drafterName || p.departmentName) && (
<div className="mt-0.5 truncate text-[11px] text-slate-500">
<span>👤 {p.drafterName ?? '—'}</span>
{p.departmentName && <span className="text-slate-400"> · {p.departmentName}</span>}
</div>
)}
{p.selectedSupplierName && (
<div className="mt-0.5 truncate text-[11px] text-emerald-600">
{p.selectedSupplierName}

View File

@ -121,6 +121,12 @@ export type PeListItem = {
// update (Tạo / Gửi duyệt / Trả lại) đưa lên đầu. FE list item hiển thị
// "Tạo lúc createdAt" (KHÔNG còn SLA countdown "Còn N ngày").
updatedAt: string | null
// Plan AG4 (2026-05-21): bro UAT yêu cầu bổ sung Người tạo + Phòng ban tạo
// vào PE card. BE JOIN Users + Departments LEFT, cả 2 nullable theo PE entity.
drafterUserId: string | null
drafterName: string | null
departmentId: string | null
departmentName: string | null
}
export type PeSupplier = {

View File

@ -129,17 +129,24 @@ public class ListApprovedPurchaseEvaluationsQueryHandler(IApplicationDbContext d
public async Task<List<PurchaseEvaluationListItemDto>> Handle(
ListApprovedPurchaseEvaluationsQuery request, CancellationToken ct)
{
// Plan AG4: JOIN Users + Departments LEFT (mirror ListPurchaseEvaluations).
return await (
from e in db.PurchaseEvaluations.AsNoTracking()
join p in db.Projects.AsNoTracking() on e.ProjectId equals p.Id
join s in db.Suppliers.AsNoTracking() on e.SelectedSupplierId equals s.Id into sj
from s in sj.DefaultIfEmpty()
join u in db.Users.AsNoTracking() on e.DrafterUserId equals u.Id into uj
from u in uj.DefaultIfEmpty()
join d in db.Departments.AsNoTracking() on e.DepartmentId equals d.Id into dj
from d in dj.DefaultIfEmpty()
where e.Phase == PurchaseEvaluationPhase.DaDuyet && e.ContractId == null
orderby e.CreatedAt descending
select new PurchaseEvaluationListItemDto(
e.Id, e.MaPhieu, e.TenGoiThau, e.Type, e.Phase,
e.ProjectId, p.Name,
e.SelectedSupplierId, s != null ? s.Name : null,
e.ContractId, e.SlaDeadline, e.CreatedAt, e.UpdatedAt)).ToListAsync(ct);
e.ContractId, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
e.DrafterUserId, u != null ? u.FullName : null,
e.DepartmentId, d != null ? d.Name : null)).ToListAsync(ct);
}
}

View File

@ -20,7 +20,14 @@ public record PurchaseEvaluationListItemDto(
// S23 t2 UAT: bro UI polish PE list — display "Ngày giờ tạo" thay SLA countdown,
// sort theo "phiếu vừa update" (Tạo / Gửi duyệt / Trả lại). UpdatedAt được
// AuditingInterceptor auto-set mỗi SaveChanges → covers cả 3 event tự nhiên.
DateTime? UpdatedAt);
DateTime? UpdatedAt,
// Plan AG4 (2026-05-21): bro UAT yêu cầu bổ sung Người tạo + Phòng ban tạo
// vào PE card list. JOIN Users + Departments (LEFT join — cả 2 nullable theo
// PE entity). FullName resolve từ Users.FullName, Department.Name từ Departments.
Guid? DrafterUserId,
string? DrafterName,
Guid? DepartmentId,
string? DepartmentName);
public record PurchaseEvaluationSupplierDto(
Guid Id,

View File

@ -468,11 +468,16 @@ public class ListPurchaseEvaluationsQueryHandler(
public async Task<PagedResult<PurchaseEvaluationListItemDto>> Handle(
ListPurchaseEvaluationsQuery request, CancellationToken ct)
{
// Plan AG4: JOIN Users + Departments LEFT (cả 2 nullable theo PE entity).
var q = from e in db.PurchaseEvaluations.AsNoTracking()
join p in db.Projects.AsNoTracking() on e.ProjectId equals p.Id
join s in db.Suppliers.AsNoTracking() on e.SelectedSupplierId equals s.Id into sj
from s in sj.DefaultIfEmpty()
select new { e, p, s };
join u in db.Users.AsNoTracking() on e.DrafterUserId equals u.Id into uj
from u in uj.DefaultIfEmpty()
join d in db.Departments.AsNoTracking() on e.DepartmentId equals d.Id into dj
from d in dj.DefaultIfEmpty()
select new { e, p, s, u, d };
// IDOR strict (Plan E S22 — Session 21 +1): non-admin chỉ thấy phiếu khi:
// 1. là Drafter (mình tạo)
@ -527,7 +532,9 @@ public class ListPurchaseEvaluationsQueryHandler(
x.e.Id, x.e.MaPhieu, x.e.TenGoiThau, x.e.Type, x.e.Phase,
x.e.ProjectId, x.p.Name,
x.e.SelectedSupplierId, x.s != null ? x.s.Name : null,
x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt, x.e.UpdatedAt))
x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt, x.e.UpdatedAt,
x.e.DrafterUserId, x.u != null ? x.u.FullName : null,
x.e.DepartmentId, x.d != null ? x.d.Name : null))
.ToListAsync(ct);
return new PagedResult<PurchaseEvaluationListItemDto>(items, total, request.Page, request.PageSize);
@ -587,12 +594,17 @@ public class GetMyPurchaseEvaluationInboxQueryHandler(
? new HashSet<Guid>()
: await ResolveV2InboxIdsAsync(userId, ct);
// Plan AG4: JOIN Users + Departments LEFT (mirror ListPurchaseEvaluations).
var q = from e in db.PurchaseEvaluations.AsNoTracking()
join p in db.Projects.AsNoTracking() on e.ProjectId equals p.Id
join s in db.Suppliers.AsNoTracking() on e.SelectedSupplierId equals s.Id into sj
from s in sj.DefaultIfEmpty()
join u in db.Users.AsNoTracking() on e.DrafterUserId equals u.Id into uj
from u in uj.DefaultIfEmpty()
join d in db.Departments.AsNoTracking() on e.DepartmentId equals d.Id into dj
from d in dj.DefaultIfEmpty()
where eligiblePhases.Contains(e.Phase) || v2InboxIds.Contains(e.Id)
select new { e, p, s };
select new { e, p, s, u, d };
if (request.Type is not null) q = q.Where(x => x.e.Type == request.Type);
if (request.ApprovalWorkflowId is not null)
@ -605,7 +617,9 @@ public class GetMyPurchaseEvaluationInboxQueryHandler(
x.e.Id, x.e.MaPhieu, x.e.TenGoiThau, x.e.Type, x.e.Phase,
x.e.ProjectId, x.p.Name,
x.e.SelectedSupplierId, x.s != null ? x.s.Name : null,
x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt, x.e.UpdatedAt))
x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt, x.e.UpdatedAt,
x.e.DrafterUserId, x.u != null ? x.u.FullName : null,
x.e.DepartmentId, x.d != null ? x.d.Name : null))
.Take(100)
.ToListAsync(ct);
}