diff --git a/.claude/agent-memory/cicd-monitor/MEMORY.md b/.claude/agent-memory/cicd-monitor/MEMORY.md
index 18d3577..afea072 100644
--- a/.claude/agent-memory/cicd-monitor/MEMORY.md
+++ b/.claude/agent-memory/cicd-monitor/MEMORY.md
@@ -68,6 +68,7 @@ BE (test+build) ~90s · FE × 2 ~60s/app · deploy ~30s · **total ~3min code /
## 📅 Recent runs (FIFO — older → archive/git)
+- **2026-06-17 S69b Run #307 (run_number 307, id421) sha=`1f8947e` PASS ~4m33s (BE-ONLY GOLIVE security hướng-ra-prod "Văn phòng số" public Read+Create mọi role — NEW `SeedAllRolesOfficeModulePermissionsAsync` DbInitializer.cs chạy lúc API boot SAU `RevokeTemporarilyHiddenModulesAsync` để THẮNG revoke (mirror S65 HRM/Hồ-sơ-NS pattern), allow-list 16 Office key grant CanRead+CanCreate=true upgrade-only (row tồn tại→nâng, chưa có→tạo R+C=true U/D=false, KHÔNG hạ KHÔNG đụng Update/Delete); +6 test `OfficeModulePermissionSeedTests.cs` (286→292). KHÔNG FE KHÔNG migration. deploy 11/11 session after #297–#306 all PASS):** Push `c556f6c..1f8947e` (1 commit, 5 files: DbInitializer.cs + 1 test + 3 agent-memory `.md` cicd/reviewer/test-spec). Diff `git diff --name-only c556f6c 1f8947e -- '*Migrations*' '*Persistence/Migrations*'` = EMPTY ✓ (DbInitializer.cs ở `Persistence/` NOT `Persistence/Migrations/` — seed runtime-idempotent NOT EF-migration). `.cs` non-ignored → full pipeline RAN. GITEA_TOKEN+PROD_DB_PW env empty trong bash-scope (env:GITEA_TOKEN có ở PS-scope; PROD_DB_PW thật rỗng) → anon Gitea API + DB pw từ prod `appsettings.Production.json`→`ConnectionStrings.Default` (path `C:\inetpub\solution-erp\api`, uid `vrapp` len24). Run IN-PROGRESS at spawn (running 10:33:40→10:38) — pre-deploy bundle baseline captured BEFORE poll-loop (anti#3): admin `Wt54PHYl` + user `B99fMU6X` — both == S70 #306 spec baseline (still live, deploy not yet shipped). Polled iter5 status=success (created 10:33:40 → success-update 10:38:13 ≈4m33s). CI gate (both proj pre-deploy ⟹ status=success ⟹ test **292** baseline (45D+247I; +6 OfficeModulePermissionSeedTests) passed; `conclusion` empty — `tasks` endpoint terminal=`status:success` không populate `conclusion`, trust success; 292 INFERRED gate-passes-pre-build invariant NOT log-confirmed numeric). **★ BUNDLE BOTH FROZEN (BE-only ⟹ both MUST stay = pre-deploy live; verified AFTER status=success): admin `Wt54PHYl` UNCHANGED** ✓ **+ user `B99fMU6X` UNCHANGED** ✓ (no FE touch → no content-hash rotation; rotate-w/o-FE-change = anomaly, did NOT happen). Smoke **4×200**: api `/health/ready`+`/health/live` + admin root + eoffice root. **NO migration** — prod `__EFMigrationsHistory` top5 = `AddHoSoLinkToPurchaseEvaluation`(Mig52)→`AddDepartmentParentId`(51)→`ReplaceBudgetModuleWithPeWorkItemBudgets`(50)→`AddWorkItemToPurchaseEvaluation`(49)→`AddProjectMasterFields`(48) == repo HEAD GIỮ NGUYÊN ✓. **sys.tables=88** (sqlcmd COUNT excl mighist — unchanged, seed inserts/updates ROWS not tables). **🔑★ GOLIVE DB VERIFIED THỰC SỰ ÁP PROD (seed chạy lúc boot ⟹ proof = prod Permissions, NOT chỉ binary-shipped): sqlcmd `Roles` (13 total: Accounting/Admin/AuthorizedSigner/CatalogManager/CostControl/DeptManager/Director/Drafter/Equipment/Finance/HrAdmin/Procurement/ProjectManager). (1) ALLOW-LIST 16/16 R=1∧C=1 cho Drafter (non-admin demo-role) — Off,Off_Dashboard,Off_DanhBa,Off_PhongHop(+View+Book),Off_DeXuat(+List+Create+Inbox),Off_DonTu(+Leave+Ot+Travel),Off_DatXe,Off_ItTicket ✓. (2) CROSS-ROLE: MỌI 13 role count=16 R∧C (golive ÁP TOÀN BỘ không Drafter-fluke) ✓. (3) EXCLUDED-3 (Off_PhongHop_Manage/Off_AttendanceReport/Off_ChamCong) Drafter R=0∧C=0 ✓ + LEAK-check: CHỈ Admin có R=1 trên 3 key này (0 non-admin leak) ✓. (4) HRM/Personal Drafter KHÔNG mở: Hrm_Dashboard=0, all 7 Hrm_Config*=0, Personal=0 ✓; Hrm=1 + Hrm_HoSo=1 (S65 public-read GIỮ NGUYÊN không đổi) ✓. (5) Admin KHÔNG hạ: full Office incl 3 excluded all R=1∧C=1 ✓.** ⟹ boot-time seed CHẠY THẬT trên prod (app pool recycled w/ new binary post-deploy; nếu seed CHƯA chạy thì non-admin sẽ vẫn 0 — nhưng 16/16 across 13 roles = đã chạy). Menu-tree non-admin via API NOT tested (DB-query mục 4 đủ proof per spec). 0 regression. NO prod-data mutation ngoài chính golive seed (read-only curls + sqlcmd SELECT-only; seed-write là chủ đích của deploy). Visual menu-render = anh UAT. **LESSON: BE-only GOLIVE security-seed verify = both bundles MUST stay FROZEN (no FE → no rotate; rotate=anomaly) + Mig-top + sys.tables MUST stay prev (DbInitializer seed = runtime row insert/update, NOT EF-migration → KHÔNG advance __EFMigrationsHistory NOR sys.tables) + **CORE proof = prod Permissions DB query**, NOT bundle-frozen alone (frozen chỉ chứng minh FE không đổi, KHÔNG chứng minh API binary mới deploy + seed chạy — phải query Permissions cho non-admin role thấy allow-list grant + cross-role để loại fluke + leak-check excluded chỉ-Admin). upgrade-only seed THẮNG revoke vì chạy SAU trong SeedAsync. golive-state AMBIENT-after-this-deploy (parent commit c556f6c chưa có method này → pre-1f8947e non-admin Office = 0; bằng chứng "đã chạy" = 16/16). TOOLING (re-confirmed S70): Bash=POSIX (`$var`/`$env:` mangle through bash→ssh→PS layers) → write PS to `/tmp/x.ps1` + `powershell.exe -NoProfile -File "C:/.../Temp/x.ps1"` (⚠️ heredoc em-dash/non-ASCII corrupts → ASCII-only in PS source); jq ABSENT + system python3 broken → Invoke-RestMethod cho Gitea `tasks` (match `head_sha -eq sha`, `?limit=N` URI-direct); SSH→PS base64 `-EncodedCommand` (UTF-16LE `[Text.Encoding]::Unicode`) cho BOTH appsettings-read AND sqlcmd; sqlcmd pw-literal qua single-quote in B64-payload (no `$` survives clean), `-U/-P` direct + `-W -s '|'` pipe-delim + `-h -1` no-header; read DB pw from prod appsettings when `$PROD_DB_PASSWORD` empty. NEVER fixed code (READ-only).** Tag `[s69b, run307, pass, be-only-golive-vanphongso-public-read-create-allowlist16, SeedAllRolesOfficeModulePermissionsAsync-boot-after-revoke, bundle-BOTH-frozen-Wt54PHYl-B99fMU6X, no-mig-top-stays-mig52, tables88, GOLIVE-DB-VERIFIED-16of16-across-13-roles, excluded3-canread0-only-admin-leak0, hrm-personal-still-0, hrm-hoso-public-unchanged, admin-not-downgraded, test292-inferred, deploy11of11, seed-not-migration-no-history-advance, no-regression]`.
- **2026-06-17 S70 Run #306 (run_number 306, id420) sha=`c556f6c` PASS ~4m42s (FE-ONLY re-skin "Văn phòng số" TOÀN MODULE 10 page PURO layout — PageHeader/KpiCard/WidgetCard apply + CSS Hồ sơ NS; presentation-only "phẫu thuật giữ nguyên logic", byte-identical logic. 20 .tsx: fe-admin 11 {AttendanceReport,InternalDirectory,ItTickets,MeetingCalendar,MeetingRooms,ProposalCreate,ProposalDetail,ProposalsList,WorkflowAppDetail,WorkflowAppsList}Page + fe-user 9 mirror (no AttendanceReport — admin-only) + 3 agent-memory .md (cicd/fe-designer/reviewer). NO BE/NO migration/NO new menu key. deploy 10/10 session after #297–#305 all PASS):** Push `a8bbdae..c556f6c` (1 commit, 23 files). `git diff --name-only a8bbdae c556f6c -- '*Migrations*' '*Persistence*'` = EMPTY ✓. `.tsx` non-ignored → full pipeline RAN (3 `.md` agent-memory match paths-ignore but co-mixed w/ 20 tsx → Discovery#3 range any-non-ignored ⟹ whole build). GITEA_TOKEN+PROD_DB_PW empty → anon Gitea API + DB pw từ prod `appsettings.Production.json`→`ConnectionStrings.Default` (path `C:\inetpub\solution-erp\api`, uid `vrapp` len24). Run IN-PROGRESS at spawn (status=running 09:57:54→09:58:40) — pre-deploy baseline captured BEFORE poll-loop: admin `Bl2o_kUq` (S69 #305 live) + user `BImrKQNn` (S69 #305 live) — both == spec baseline (still live, deploy not yet shipped, anti#3 honored no mid-flight verify). Polled status=success (started 09:57:54 → upd 10:02:36 ≈4m42s). CI gate (both proj pre-deploy ⟹ status=success ⟹ test **286** baseline (45D+241I; FE-only ⟹ 0 BE call-site risk) passed; `conclusion` empty — `tasks` endpoint terminal=`status:success` không populate `conclusion`, trust success; 286 INFERRED gate-passes-pre-build invariant NOT log-confirmed). **★ BUNDLE BOTH ROTATE (FE-both-app 10-page re-skin ⟹ both MUST rotate; verified AFTER status=success + re-confirm STABLE 2nd-fetch identical no-transient — anti#3): admin ROTATE `Bl2o_kUq→Wt54PHYl` (css `CKzdDktL→BpHtX3vS`)** ✓ **+ user ROTATE `BImrKQNn→B99fMU6X` (css `eoxUcs8v→DXRSCQW7`)** ✓. BOTH required → BOTH did. Asset reachable 200: admin js 1,588,241b + user js 1,493,955b (not white). ⚠️ Prod CI hashes ≠ spec local-build (fe-user `C8-p69Kn`/`DezuRkK9`, fe-admin `yFhLO2Wp`/`Dd2WiO6n`) — EXPECTED CI-rebuild content-hash divergence (only matters NEW≠baseline, BOTH rotated confirmed). Smoke **4×200** health: api `/health/ready`+`/health/live` + admin root + eoffice root. **Office-backed API live (admin bearer): GET /api/proposals 200 · /api/it-tickets 200 · /api/meeting-rooms 200 · /api/employees 200** (data layer healthy, not 500). `/api/workflow-apps`→404 = WRONG-route-guess NOT regression (FE-only can't change BE routing; real route differs — leave-requests/don-tu; FE uses api-client wrapper, route grep didn't land). **SPA deep-link 6 Office routes admin all 200** (/proposals, /it-tickets, /workflow-apps/leave, /directory, /meeting-calendar, /office/dashboard serve index.html — not 404/white). **NO migration** — prod `__EFMigrationsHistory` top = `20260616035929_AddHoSoLinkToPurchaseEvaluation` (Mig 52) == repo HEAD GIỮ NGUYÊN ✓ (prev-2 = AddDepartmentParentId Mig51 + ReplaceBudgetModuleWithPeWorkItemBudgets Mig50 chain intact). **sys.tables=88 verified** (sqlcmd COUNT excl mighist — unchanged). **★ OFFICE-HIDDEN CONFIRMED (non-admin Drafter=nv.test): sqlcmd `Off_Dashboard` perm row ONLY for Admin (1/13 roles) ✓ + Drafter has ALL 17 Off_* keys present but EVERY one CanRead=0 ✓ → menu gates on CanRead=1 ⟹ non-admin sees NO Office menu.** ⚠️ **LESSON-CORRECTION vs S69: my coarse `GROUP BY COUNT(*) WHERE MenuKey LIKE 'Off%'` first showed "all 13 roles ~18 Off perms" — MISLEADING; those are perm-ROWS-with-CanRead=0 (existence ≠ access). Must filter `CanRead=1` OR inspect per-key. MenuItems.IsVisible=1 on all Off keys = admin-side global-visible flag; user-visibility = IsVisible AND role.CanRead. Office-hidden state is AMBIENT (FE-only commit cannot touch Permissions/MenuItems seed — git diff zero BE/DbInitializer) = identical pre-c556f6c.** 0 regression. NO prod-data mutation (read-only curls + sqlcmd SELECT-only). Visual per-page render = anh user UAT (em chỉ confirm bundle shipped + route không 500/trắng per spec). **TOOLING (re-confirmed + NEW): Bash tool = POSIX bash → ⚠️ system `python3` BROKEN (ZKBioTime Python311 on PATH → `SRE module mismatch` fatal) AND `jq` NOT installed → MUST use `powershell.exe -NoProfile -File "C:/.../tmp/x.ps1"` + `Invoke-RestMethod` for ALL JSON parse (Gitea tasks match `head_sha -eq sha`); poll-loop in pure-bash w/ jq SILENTLY returns empty (no error) — verify parser works before trusting loop. SSH→PS base64 `-EncodedCommand` (UTF-16LE) for appsettings-read AND sqlcmd; sqlcmd string-LITERAL via `[char]39` concat (NOT doubled-quote); `-U/-P` direct (no -ConnectionString flag); `Roles`/`Permissions(RoleId,MenuKey,CanRead)`/`MenuItems([Key],IsVisible)`. NEVER fixed code (READ-only).** Tag `[s70, run306, pass, fe-only-vanphongso-reskin-10page-PURO, fe-both-app-bundle-BOTH-rotate-Wt54PHYl-B99fMU6X, asset-200-reachable, office-api-200-proposals-ittickets-meetingrooms-employees, workflow-apps-404-wrong-route-not-regression, spa-deeplink-6route-200, no-mig-top-stays-mig52, tables88-verified, office-hidden-confirmed-Off_Dashboard-admin-only-drafter-canread0, lesson-canread0-rows-not-access, ambient-not-deploy-caused, jq-absent-python3-broken-use-powershell, deploy10of10, no-regression, test286-inferred]`.
- **2026-06-17 S69 Run #305 (run_number 305, id419) sha=`a8bbdae` PASS ~4-5m (FE BOTH-APP foundation "Văn phòng số" + index.css sync + BE menu-seed NO-mig: 3 shared component mới PageHeader/KpiCard/WidgetCard + OfficeDashboardPage landing route `/office/dashboard` 4-place-wire BOTH apps + `fe-admin/src/index.css` SYNCED (Hồ sơ NS accent tokens, rotate admin mạnh) + BE menu key `Off_Dashboard` (MenuKeys.cs L100 + DbInitializer.cs L1825 seed parent=`Off` Order0 LayoutDashboard); deploy 9/9 session after #297–#304 all PASS):** Push `764fe70..a8bbdae` (1 commit, 20 files). Diff: FE 14 files (2× {PageHeader,KpiCard,WidgetCard,OfficeDashboardPage,App.tsx,Layout.tsx,menuKeys.ts} + fe-admin index.css) + BE 2 (MenuKeys.cs + DbInitializer.cs) + 4 agent-memory `.md`. `.tsx`/`.cs`/`.css` non-ignored → full pipeline RAN (the 4 `**/*.md` agent-memory match paths-ignore but co-mixed w/ code → Discovery#3 range any-non-ignored ⟹ whole build). GITEA_TOKEN+PROD_DB_PW empty → anon Gitea API + DB pw từ prod `appsettings.Production.json`→`ConnectionStrings.Default` (path `C:\inetpub\solution-erp\api`, uid `vrapp`). Run IN-PROGRESS first 4 polls (running 09:26→09:29) — correctly did NOT verify-bundle-mid-flight (anti#3); pre-deploy baseline captured BEFORE poll-loop: admin `CNUv1jxY` (S78 #304 live) + user `CpOskeS1` (S78 #304 live) — both == spec S68 baseline (still live, deploy not yet shipped). Polled iter5 status=success (started ~09:25 → success 09:29:50 ≈4-5m). CI gate (both proj pre-deploy ⟹ status=success ⟹ test gate **286** baseline (45D+241I) passed; `conclusion` empty — `tasks` endpoint terminal=`status:success` doesn't populate `conclusion`, trust success; 286 INFERRED from gate-passes-pre-build invariant NOT log-confirmed numerically). **★ BUNDLE BOTH ROTATE (FE-both-app + index.css sync ⟹ both MUST rotate; verified AFTER status=success +re-confirm STABLE 2nd-fetch identical no-transient — anti#3): admin ROTATE `CNUv1jxY→Bl2o_kUq`** ✓ (Văn phòng số + index.css shipped) **+ user ROTATE `CpOskeS1→BImrKQNn`** ✓ (foundation shipped). BOTH required → BOTH did. ⚠️ **Prod CI hashes (`Bl2o_kUq`/`BImrKQNn`) ≠ spec local-build (`TbkadgKd`/`DrxDysO7`) — EXPECTED divergence (CI rebuilds w/ different Node/npm/dep-resolution → different content-hash; only matters NEW≠baseline, BOTH rotated confirmed ship). Spec assumption "prod khớp local nếu source-identical" holds ONLY if byte-identical build-env — it is NOT (S77 same lesson).** Smoke **4×200**: api `/health/ready`+`/health/live` + admin root + eoffice root. **NO migration** — prod `__EFMigrationsHistory` top = `20260616035929_AddHoSoLinkToPurchaseEvaluation` (Mig 52) == repo HEAD GIỮ NGUYÊN ✓ (`git diff --name-only 764fe70 a8bbdae -- '*Migrations*'` = EMPTY; menu-seed is runtime-idempotent DbInitializer NOT EF-migration → top did NOT advance). **sys.tables=88 verified** (sqlcmd COUNT excl mighist — unchanged, menu-seed inserts rows not tables). **★ MENU-SEED VERIFIED (NEW check this run — DbInitializer seed ungated reaches prod by design): sqlcmd `SELECT [Key],ParentKey,Label,IsVisible FROM MenuItems WHERE [Key]='Off_Dashboard'` → 1 row Key=`Off_Dashboard` Parent=`Off` Label="Bảng điều khiển Văn phòng số" IsVisible=1 ✓.** **★ OFFICE-HIDDEN CONFIRMED (RevokeTemporarilyHiddenModulesAsync StartsWith("Off") scope — DbInitializer.cs L2172): sqlcmd `SELECT r.Name FROM Permissions p JOIN Roles r ON r.Id=p.RoleId WHERE p.MenuKey='Off_Dashboard'` → ONLY `Admin` (CanRead=1), 1 row / 13 roles total ✓ → non-admin NO Off_Dashboard perm → Office stays hidden (revoke executed, admin auto via All[]).** 0 regression. NO prod-data mutation (read-only curls + sqlcmd SELECT-only). Visual "Dashboard landing render / Hồ sơ NS CSS" NOT verified (anh xem mắt) — only ship+rotate+health+mig-unchanged+tables88+menu-seed+office-hidden. **LESSON: FE-both-app + index.css-sync + BE-menu-seed (NO-EF-mig) verify = both bundles MUST ROTATE + Mig-top + sys.tables MUST stay prev (menu-seed = DbInitializer runtime row-insert, NOT a migration → does NOT advance __EFMigrationsHistory NOR sys.tables; verify seed via direct MenuItems SELECT not via mig-count). Office-hidden = query Permissions-by-role: temporarily-hidden module has perm row ONLY for Admin (All[] auto-grant) after RevokeTemporarilyHidden runs. ⚠️ Prod CI bundle-hash ≠ local-build-hash is NORMAL — never FAIL on hash-mismatch-to-local, only FAIL if NOT-rotated-from-baseline. TOOLING (re-confirmed S78): Bash=POSIX (`$var`/`$env:`/`''` literal-quote mangle through bash→ssh→PS→sqlcmd layers) → write PS to `.ps1` + `powershell -File "ABS/FWD/SLASH.ps1"`; Gitea `tasks` via `Invoke-RestMethod` (match `head_sha -eq sha`, `?limit=N` honored URI-direct); SSH→PS base64 `-EncodedCommand` (UTF-16LE iconv) for BOTH appsettings-read AND sqlcmd; ⚠️ **sqlcmd string-LITERAL in query: doubled `''x''` BREAKS (PS single-quote-string closes early) → build literal via `[char]39 + "x" + [char]39` concat (NOT `''`)**; this sqlcmd build supports `-U/-P` direct (no -ConnectionString flag); table name `Roles` NOT `AspNetRoles`, `Permissions(RoleId,MenuKey,CanRead)`; CLIXML/Progress stdout-noise grep-filter out. NEVER fixed code (READ-only).** Tag `[s69, run305, pass, fe-both-app-vanphongso-foundation, shared-PageHeader-KpiCard-WidgetCard, index-css-sync-admin, bundle-BOTH-rotate-Bl2o_kUq-BImrKQNn, prod-ci-hash-NE-local-EXPECTED, be-menu-seed-Off_Dashboard-NO-mig, no-mig-top-stays-mig52, tables88-verified, menu-seed-verified-sqlcmd, office-hidden-confirmed-admin-only-1of13, sqlcmd-char39-literal-not-doubled-quote, deploy9of9, no-regression, test286-inferred]`.
- **2026-06-16 S78 Run #304 (run_number 304, id418) sha=`37752eb` PASS ~4m (FE BOTH-APP 1-line CSS-precedence fix: "Hồ sơ NS" employee-detail banner NAME đen→trắng — `
` rendered BLACK do unlayered `h1,h2,h3,h4{color:#0b1220}` index.css thắng `text-white` (Tailwind v4 @layer precedence); fix = `text-white`→`text-white!` important-modifier + `text-xl font-extrabold`→`text-lg font-bold`. 2 file `EmployeesListPage.tsx` fe-user+fe-admin SHA256-IDENTICAL `8BBAEC34…`, 1-line each, NO BE/mig/index.css; deploy 8/8 session after #297–#303 all PASS):** Push `6983609..37752eb` (1 commit). Diff 2 files: both `.tsx` only → not in paths-ignore → full pipeline RAN. GITEA_TOKEN+PROD_DB_PW empty → anon Gitea API + DB pw từ prod `appsettings.Production.json`→`ConnectionStrings.Default` (path `C:\inetpub\solution-erp\api`, uid `vrapp` len24). Run IN-PROGRESS first 7 polls (running 19:55→19:58) — correctly did NOT verify-bundle-mid-flight (anti#3); pre-deploy baseline captured BEFORE poll-loop: admin `D532XZKG` (S77 #303 live) + user `CuFaBoWt` (S77 #303 live) — both == spec baseline (still live, deploy not yet shipped). Polled iter8 status=success (started ~19:55 → success 19:59:26 ≈4m). CI gate (both proj pre-deploy ⟹ status=success ⟹ test gate **286** baseline (45D+241I; FE-only ⟹ 0 BE call-site risk) passed; `conclusion` empty — `tasks` endpoint terminal=`status:success` doesn't populate `conclusion`, trust success; exact CI count NOT log-confirmed numerically — Gitea logs web-UI-only anon, 286 INFERRED from gate-passes-pre-build invariant). **★ BUNDLE BOTH ROTATE (the change-point — FE-BOTH-app, both EmployeesListPage changed ⟹ both bundles MUST rotate; verified AFTER status=success +re-confirm STABLE 2nd-fetch identical no transient — anti#3): admin ROTATE `D532XZKG→CNUv1jxY`** ✓ (`text-white!` name-color fix shipped) **+ user ROTATE `CuFaBoWt→CpOskeS1`** ✓ (same page shipped). BOTH required per spec → BOTH did (mirror S74/S75/S76/S77 pure-FE-both-app pattern — both same-SHA256 file ⟹ both rotate; frozen sibling here = ship-fail flag). Smoke **4×200**: api `/health/ready`+`/health/live` + admin root + eoffice root. **NO migration** — prod `__EFMigrationsHistory` top = `20260616035929_AddHoSoLinkToPurchaseEvaluation` (Mig 52) == repo HEAD GIỮ NGUYÊN ✓ (`git diff --name-only 6983609 37752eb -- '*Migrations*' '*Persistence*'` = EMPTY; FE cannot alter schema → top did NOT advance; prev-2 = `AddDepartmentParentId` Mig51 + `ReplaceBudgetModuleWithPeWorkItemBudgets` Mig50 chain intact). **sys.tables=88 verified** (sqlcmd COUNT excl mighist — unchanged, no schema touch). 0 regression. NO prod-data mutation (read-only curls + sqlcmd SELECT-only). Visual "tên NV trắng render đúng" NOT verified (anh xem mắt) — only ship+rotate+health+mig-unchanged+tables88; SHA256-identical-between-2-apps is a SOURCE-claim (git), not runtime DOM-equality; CSS-precedence-actually-fixed is render-time, NOT provable by curling bundle (the `!important` text is in dist css per spec-claim, but visual-black-vs-white is browser-render). **LESSON: pure-FE-BOTH-app 1-line CSS-fix verify (8th consecutive deploy this session, both apps same component SHA256-identical) = both bundles MUST ROTATE (≠ asymmetric one-app where sibling frozen; ≠ BE-only where both frozen). Even a 1-line `text-white`→`text-white!` change → new content-hash both apps. Migration top + sys.tables MUST stay = prev (FE-only). No BE call-site/DTO/endpoint smoke (no API surface — render-only). TOOLING (re-confirmed S77): Bash tool is POSIX bash (`Select-String`/`$var`/`$env:` unavailable inline) → write PowerShell to `.ps1` + invoke `powershell -File "ABSOLUTE/FORWARD/SLASH.ps1"` (Bash EATS backslash in `tmp\x.ps1`→`tmpx.ps1`); parse Gitea `tasks` via `Invoke-RestMethod`+native object (match `head_sha -like sha*`, `limit=N` ignored); SSH→PS nested-quote mangles → base64 `-EncodedCommand` (UTF-16LE) BOTH the appsettings-read AND the sqlcmd-query (no `$` in B64 passes bash+ssh clean); CLIXML progress-stream on stdout harmless (data rows clean below); read DB pw from prod appsettings when PROD_DB_PW empty. NEVER fixed code (READ-only).** Tag `[s78, run304, pass, fe-both-app-hoso-name-color-fix-text-white-important, fe-both-2files-sha256-identical-8BBAEC34, bundle-BOTH-rotate-CNUv1jxY-CpOskeS1, css-precedence-tailwind-v4-layer, no-mig-top-stays-mig52, tables88-verified, deploy8of8, ssh-encodedcommand-base64, no-regression, test286-inferred]`.
diff --git a/.claude/agent-memory/investigator-codebase/MEMORY.md b/.claude/agent-memory/investigator-codebase/MEMORY.md
index dddb73d..79eb2c7 100644
--- a/.claude/agent-memory/investigator-codebase/MEMORY.md
+++ b/.claude/agent-memory/investigator-codebase/MEMORY.md
@@ -70,6 +70,8 @@ Bearer từ `POST api.solutions.com.vn/api/auth/login` → status matrix expecte
## 📅 Recent activity (FIFO — older → archive/git)
+- **2026-06-17 (PE-workflow recon for FDC feature-plan — urgent flag + value-threshold routing, on-disk):** ⭐ **PE VALUE: NO stored "giá trị gói thầu" column.** Best-fit = winner-quote-total `SUM(Quote.ThanhTien WHERE supplier==SelectedSupplierId)` — COMPUTED (submit-guard `PurchaseEvaluationWorkflowService.cs:188-190` + `CurrentProposalTotal` in `PeBudgetSummaryDto`). Other amounts: `PE.BudgetPeriodAmount`(:40 drafter NS kỳ này)/`ExpectedRemainingAmount`(:41)/`PeWorkItemBudget.FullAmount`=(Initial??0)+(Adjustment??0) (`PeWorkItemBudget.cs:29-30`) — all budgets, not deal-value. **ROLES PRO/CCM/CEO = domain shorthand NOT constants** (`AppRoles.cs` has Procurement/CostControl/Director; PRO=Procurement CCM=CostControl CEO=Director). **V2 routing IGNORES roles** — approvers = specific `ApproverUserId` (`ApprovalWorkflow.cs:80`), OR-of-N = N Level rows same `Order` (GroupBy :687). "Phòng CCM" = seed Step NAME + non-strict DeptId hint only (`:67`). **CEO = positional (last level/last step), NOT conditional.** **ROUTING 100% LINEAR** (level→step, `DaDuyet` when `nextIdx>=steps.Count`). ZERO value/threshold/conditional config anywhere (grep 0 on AW/Step/Level/PEType). ⭐ **HOOK B (value-threshold) = `ApproveV2Async` advance block lines 816-845** (`:817` levelOrder++ / `:828-837` terminal DaDuyet / `:838-845` next step). Precedent: `skipToFinal :773-814` already "jump pointer to last step+level" — reuse mechanic conditioned on value. **HOOK A (urgent):** add `IsUrgent bit`/`PePriority` enum (mirror `ItTicketPriority{Low,Medium,High,Urgent}` `Office/Enums.cs:48-54`) AddColumn no-new-table; notify `INotificationService.NotifyAsync(userId,type,title,desc?,href?,refId?)` (`INotificationService.cs:10`)+SignalR interceptor; LogTransition notifies DRAFTER-only on terminal (`:960-980`), NO approver-notify yet. Badge DTOs: `PurchaseEvaluationListItemDto`(`PurchaseEvaluationDtos.cs:6`)+`DetailBundleDto`(:201). Type A/B (`PurchaseEvaluationType.cs:6-10`) constrains pinnable ApplicableType only — ZERO type-conditional routing. ⚠️ "Từ chối" REMOVED S60 hard-guard `:80-85` (throws even Admin; only Duyệt/Trả lại). ⚠️ drafter-in-chain bypass `:543` auto-approves drafter's own step-1 levels on submit (interacts w/ value-finalize). Tag `[pe-workflow-recon, value-threshold-hook, urgent-flag, fdc-feature-plan]`.
+
- **2026-06-17 (S69 recon — Office-module inventory + Hồ sơ-NS CSS-contract, on-disk):** ⭐ **PART A Office:** 21 `Off_*` keys (`MenuKeys.cs:99-121`): root `Off` + DanhBa(card-grid), `Off_PhongHop`{View=cal/Manage=room-CRUD-admin/Book}, `Off_DeXuat`{List/Create/Inbox=Proposal-V2}, `Off_DonTu`{Leave/Ot/Travel}, `Off_DatXe`, `Off_ItTicket`, `Off_ChamCong`(re-parent→Personal S57), `Off_AttendanceReport`(admin). 10 office pages `{fe-admin,fe-user}/src/pages/office/` ALL SHA256-MIRROR except **MyAttendancePage DIFFERS** + AttendanceReportPage ADMIN-ONLY. Routes `App.tsx` user:70-80/admin:88-100; staticMap `Layout.tsx:87-103` (workflow-apps :kind `/workflow-apps/{leave,ot,travel,vehicle}`); menuKeys.ts:45-63. **HIDE-FLAG** `RevokeTemporarilyHiddenModulesAsync` (`DbInitializer.cs:2157-2190` called :2040 LAST) wipes CRUD on `MenuKey.StartsWith("Off")||"Hrm"||==Personal` non-Admin, idempotent. **Golive flip:** remove :2040 call (+ re-add prefix InReviewScope grant). Office already S55-shell polished NOT bare. **PART B Hồ sơ-NS CSS:** layout=3-col flex (`EmployeesListPage.tsx` SHA256-identical x2, 1597 LOC): cây-tổ-chức TRÁI(:178) + NV-list MID(:244) + detail PHẢI = avatar-header `app-gradient-brand`(:643)+`text-white!`(:653)+initials chip bg-white/15 → 5-TAB(:507 Tổng quan/Thân nhân/Trình độ/Kinh nghiệm/Hợp đồng) → `Card`(:1526 left-rail+icon-chip) w/ `Field`(:1572 label uppercase accent-tint + value `font-medium text-brand-800`, empty=`text-slate-300 —`). `ACCENT` map :497-503 Record<5,{chipBg/chipFg/head/rail/labelText}> accent∈{brand,teal,violet,amberx,greenx}, palettes stops 50/100/500/600/700 only no-800→headings -700 (brand -800 OK). Tokens `index.css`: brand-600=#1f7dc1 brand-800=#175685 @theme:5-55, font Be-Vietnam-Pro:53; classes `.app-gradient-brand`(:105 120deg b600→700→800),`.card-accent`(:112),`.icon-chip`(:128 --chip-bg/--chip-fg),`.stat-value`(:140),`.label-eyebrow`(:89). ⚠️ **GOTCHA #66 = `index.css:79-83` `h1,h2,h3,h4{color:#0b1220;font-weight:700}` OUTSIDE @layer** → TW-v4 unlayered wins → heading-tag inside gradient MUST `text-white!`. ⚠️ **CROSS-APP DRIFT:** fe-user=S68 (h1-4 #0b1220/700, label-eyebrow brand-600, 175L); **fe-admin STILL OLD** (h1-4 #0f172a/600, label-eyebrow #64748b slate, 167L) — fe-admin NOT synced S66-68 heading bump → mirror Office to fe-admin needs index.css sync. Tag `[s69, office-inventory, hoso-css-contract, gotcha66, fe-admin-css-drift]`.
- **[→ git pre-S60]** S60 recon#2 V2-engine-map (ApprovalWorkflow.cs Step/Level Order 1-based per-step; OR-of-N=N rows cùng Order service GroupBy:475; ApproveV2Async:446-634 guard+UPSERT+advance; notify DRAFTER-only:748; skipToFinal F2:561-602 = precedent advance-không-ghi-opinion) · S60 PE Section-3 submit-guard (submit path POST/pe/{id}/transitions→TransitionAsync:38 ROLE-only guard NO data-check; Section-3 mục a/b/c/d map — SUPERSEDED bởi S65ter post-Mig50 Budget-drop; test mirror PurchaseEvaluationWorkflowServiceGuardTests). Full text git.
diff --git a/.claude/agent-memory/test-specialist/MEMORY.md b/.claude/agent-memory/test-specialist/MEMORY.md
index 76a2c4e..ae73dd3 100644
--- a/.claude/agent-memory/test-specialist/MEMORY.md
+++ b/.claude/agent-memory/test-specialist/MEMORY.md
@@ -15,7 +15,7 @@ WRITE specialist độc quyền `tests/**`. xUnit + FluentAssertions 7.2 + EF SQ
- ❌ NOT: production code `src/Backend/**` + `fe-*/**` → test reveal bug → REPORT em main, KHÔNG fix
- ❌ NOT: decide WHAT to test (test plan) → em main + reviewer chốt priority
-## 📊 Baseline 292 tests = 292 PASS (45 Domain + 247 Infra) ← S69 +6 Office golive permission-seed (`OfficeModulePermissionSeedTests.cs`, test-after, mirror HrmProfilePermissionSeedTests S67). Prev 286 ← S67 +23 HRM test-after [DepartmentTreeTests 8 cycle-guard/rollup/orphan + PeHoSoLinkTests 9 absolute-set (⚠️spec-drift: HoSoLink gửi null=CLEAR, KHÔNG null-safe như Budget*/WorkItemId) + HrmProfilePermissionSeedTests 6 reflection private-static revoke→seed chain]. **em main PROXY-RECORD** — return truncated #53 (chết lúc update MEMORY), 3 file delivered + `dotnet test` 286 PASS verify-on-disk. Prev 263 (S61 +22 PeWorkItemBudget −14 BudgetPolicy; Domain 58→45 drop Budget module). Pre = 254 (S60).
+## 📊 Baseline 306 tests = 306 PASS (45 Domain + 261 Infra) ← S69b +14 PE 2 feature anh Kiệt FDC (test-before-merge SECURITY/FINANCIAL): `PeCcmThresholdFinalizeTests.cs` (5, Services ns, value-threshold CCM-finalize ApproveV2Async) + `PeUrgentToggleAuthzTests.cs` (9, Application ns, urgent-toggle role authz). Prev 292 ← S69 +6 Office golive permission-seed (`OfficeModulePermissionSeedTests.cs`, test-after, mirror HrmProfilePermissionSeedTests S67). Prev 286 ← S67 +23 HRM test-after [DepartmentTreeTests 8 cycle-guard/rollup/orphan + PeHoSoLinkTests 9 absolute-set (⚠️spec-drift: HoSoLink gửi null=CLEAR, KHÔNG null-safe như Budget*/WorkItemId) + HrmProfilePermissionSeedTests 6 reflection private-static revoke→seed chain]. **em main PROXY-RECORD** — return truncated #53 (chết lúc update MEMORY), 3 file delivered + `dotnet test` 286 PASS verify-on-disk. Prev 263 (S61 +22 PeWorkItemBudget −14 BudgetPolicy; Domain 58→45 drop Budget module). Pre = 254 (S60).
> Pattern S67: private-static seed/init → invoke qua REFLECTION (`GetMethod(name, NonPublic|Static)` + `Invoke(null, [db, roleManager, NullLogger.Instance])`); seed MenuItem rows TRƯỚC Permission (FK MenuKey→MenuItem.Key Cascade, SQLite Error 19 nếu thiếu). Cycle-guard test: SqliteDbFixture đủ (no User); rollup-count test cần IdentityFixture (đếm User.DepartmentId active).
Run: `dotnet test SolutionErp.slnx --nologo --verbosity minimal -p:BuildInParallel=false -maxcpucount:1` (MSBuild OOM → serialize build)
@@ -54,6 +54,8 @@ Test theo CODE (single source truth), document mismatch header comment + report.
## 📅 Recent activity (last 10 FIFO)
+- **2026-06-17 (S69b PE 2 feature anh Kiệt FDC — test-before-merge SECURITY+FINANCIAL workflow):** +14 test → **292→306 PASS** (45 Domain + Infra 247→261, 0 fail). BE done+builds, mirror harness PeSubmitGuardAndBypassTests/PeWorkItemGuardTests. **FEATURE B value-threshold CCM-finalize (`PeCcmThresholdFinalizeTests.cs` 5, Services ns, ApproveV2Async line 816-854):** NV duyệt role=CostControl + `aw.CeoApprovalThreshold!=null` + `winnerQuoteTotal < ngưỡng` + chưa-slot-cuối → Phase=DaDuyet bỏ CEO + pointers/SLA null. **⭐ BOUNDARY load-bearing: predicate `winnerQuoteTotal < ceoThreshold` STRICT-less-than (line 838)** → gói==đúng-ngưỡng = KHÔNG finalize = advance. Cover: (1)⭐LOAD-BEARING CCMngưỡng→advance / (3) threshold-null→advance kể-cả-CCM+gói-1đ (backward-compat) / (4) non-CCM(PRO)PRO>CCM intentional). FakeCurrentUser configurable-roles ctor. Reuse NoOpNotificationService internal qua `using ...Tests.Services`. Tag [s69b, pe-ccm-threshold-finalize, value-threshold, strict-less-than-boundary, role-based-routing, urgent-toggle-authz, forbidden-no-mutation, else-if-short-circuit, test-before-merge].
+
- **2026-06-17 (S69 Office golive permission-seed regression — test-after SECURITY invariant, public Văn phòng số):** +6 test `tests/.../Application/OfficeModulePermissionSeedTests.cs` → **286→292 PASS** (45 Domain + Infra 241→247, 0 fail). Mirror `HrmProfilePermissionSeedTests` (S67) — SAME reflection harness (invoke 2 private-static `RevokeTemporarilyHiddenModulesAsync` + `SeedAllRolesOfficeModulePermissionsAsync` qua `GetMethod(name, NonPublic|Static).Invoke(null, [db, rm, NullLogger.Instance])`; SqliteDbFixture/IdentityFixture; seed MenuItem rows TRƯỚC Permission FK Cascade). **KHÁC HRM:** Office grant mở **CanRead AND CanCreate** (HRM read-only) trên allow-list **16 key**; HRM chỉ 2 key. Chain = revoke (StartsWith("Off")→all false non-Admin) → office-grant (allow-list→read+create, upgrade-only). **Cover:** (1) chain non-Admin allow-list-16 → read+create=true + **excluded-3 stay hidden** (`OffPhongHopManage`/`OffAttendanceReport`/`OffChamCong` ⭐ LOAD-BEARING security assert) / (2) allow-list Update+Delete stay false / (3) no-leak HRM-dashboard+Personal stay hidden / (4) Admin not-revoked keeps all incl excluded-3 / (5) create-missing-row read+create=true update/delete=false + excluded NOT created / (6) **upgrade-only preserves admin-raised Update/Delete=true** (office-grant chỉ đụng Read/Create, KHÔNG hạ). **No prod bug** — seed logic đúng spec (excluded-3 confirmed hidden, upgrade-only không phá quyền admin). Tag [s69, office-golive, permission-seed, security-invariant, excluded-3-hidden, read+create-grant, upgrade-only, reflection-private-static, test-after].
- **2026-06-12 (S60 UAT anh Kiệt — 2 feature PE submit branch, test-after build PASS):** +14 test `tests/.../Services/PeSubmitGuardAndBypassTests.cs` → **240→254 PASS** (58 Domain + Infra 182→196, 0 fail). Mirror `PurchaseEvaluationWorkflowServiceGuardTests` (IdentityFixture+SQLite, reuse `NoOpNotificationService` internal). **F1 Section 3 guard (8):** submit branch (DangSoanThao/TraLai→ChoDuyet) build `missing` list 4 mục → ConflictException msg gộp prefix `'Chưa đủ thông tin mục 3 "Đơn vị NCC/TP được chọn"...'` + join `' · '`. Cover: thiếu cả 4 / winner-only / winner+quote=0 / budget (cả null+manual=0) / comparison / **attachment gắn NCC (PES_Id!=null) KHÔNG đếm bảng so sánh = vẫn Conflict** (predicate PES_Id==null) / đủ-4-manual-budget→ChoDuyet / đủ-4-BudgetId→ChoDuyet. **F2 drafter-bypass (6, V2-only `ApplyDrafterBypassOnSubmitAsync`):** k=drafterSlots.Max(Order) bước đầu → auto Cấp 1..k. Cover: drafter=TP(2/2)+2bước→StepIdx=1/Lvl=1+opinion 1 row slot TP+2 AutoApprove / drafter=NV(1/2)→Lvl=2 cùng bước+opinion slot NV / drafter ngoài bước đầu→KHÔNG bypass StepIdx=0 Lvl=1 0-auto / 1-bước+drafter cấp cuối→DaDuyet pointers null SLA null / V1(awId null)→submit OK no-bypass no-crash / TraLai-resubmit→bypass áp lại opinion UPSERT 1 row + approval cộng dồn 2 vết. **⚠️ GUARD-FIRST:** mọi bypass-test PHẢI dựng PE đủ 4 ĐK Section 3 (winner+quote>0+manual-budget+comparison-attach) qua guard. **Seed pattern S60:** `SeedWinnerWithQuoteAsync`(PES+Detail+Quote ThanhTien) map winner→quote sum · `SeedComparisonAttachment`(PES_Id=null) · `SeedWorkflowAsync(Guid[][] stepApprovers)` build multi-step V2 1-NV/cấp. **Opinion-only-ownSlot invariant:** bypass cấp NV skip KHÔNG ghi opinion (chỉ Approval AutoApprove + Changelog vết); assert `opinions.HaveCount(1)` + `ApprovalWorkflowLevelId==drafterSlot.Id`. **No prod bug** — code đúng spec, test theo CODE (S34 rule). Tag [s60, pe-submit-guard, section3-completeness, drafter-bypass, v2-only, guard-first, opinion-ownslot-only, test-after].
@@ -66,8 +68,6 @@ Test theo CODE (single source truth), document mismatch header comment + report.
- **2026-06-08 (S52 P11-E + P11-F WorkflowApps/Attendance test-after):** +5 test → **191 PASS** (Infra 128→133). 2 file `tests/.../Application/`: **ItTicketCodeGenTests** (3 — MaTicket regex `^IT/\d{4}/\d{3}$` + sequential 001→002 cùng prefix `IT/{year}` LastSeq++ + per-year-prefix 2027 reset 001) + **AttendanceReportTests** (2 — full aggregate day-type/weighted + DepartmentId filter). **⭐ Serializable-on-SQLite GOTCHA = NON-ISSUE (confirmed):** `WorkflowAppCodeGen.GenerateMaDonTuAsync` dùng `BeginTransactionAsync(IsolationLevel.Serializable)` chạy SẠCH trên SQLite — provider map isolation level gracefully (no throw), format+seq+per-year đều hold KHÔNG cần try/skip. Đã proven sẵn bởi WorkflowAppApproveV2Tests (DT/LR path). Handler `CreateItTicketHandler(db, cu, clock)` = 3 dep MediatR. **Day-type test pattern (P11-E core):** holiday check chạy TRƯỚC weekend/weekday → seed 2026-06-01 (thứ Hai) vào holidaySet → assert phân **Holiday** dù là weekday (override day-of-week). Holiday.Date=DateOnly → `BuildHoliday` dùng `DateOnly.FromDateTime`. OtWeighted = 2×1.5+3×2.0+1×3.0=12.0m. DepartmentId filter: seed 2 Department row + 2 user khác dept → query deptA chỉ trả 1 row (handler join Users `u.DepartmentId==deptId`, userMeta dùng `DefaultIfEmpty` nên dept row optional nhưng seed cho DepartmentName assert). No prod bug. **⚠️ MSBuild OOM** chạy full parallel → dùng `-maxcpucount:1 -p:BuildInParallel=false` (env resource, KHÔNG test fail). Tag [s52, p11-e, p11-f, codegen, day-type, serializable-sqlite-ok, test-after].
- **2026-06-08 (S51 P11-C HMW Wave2 filtered-unique gotcha #57):** +4 test `tests/.../Application/HrmConfigFilteredUniqueTests.cs` → **185 total = 183 PASS + 2 RED** (Infra 123→127). Mirror HolidayTests Case 7 (seed soft-deleted Code-slot → Create same Code → assert success + active==1 + all==2). **2 GREEN** Vehicle+Driver (Mig 44 config ĐÃ filtered → 2 catalog mới đúng). **2 RED INTENTIONAL = gotcha #57 REPRODUCED** (test-before): `CreateLeaveType_OnSoftDeletedCodeSlot...` → `SQLite Error 19 UNIQUE constraint failed: LeaveTypes.Code` + `CreateShift_OnSoftDeletedCodeSlot...` → `ShiftPatterns.Code` (bare `.IsUnique()` đếm cả row soft-deleted; handler app-check `!IsDeleted` PASS → Add+SaveChanges → DbUpdateException). NOT test lỗi — REPORTED em main fix Mig 45 `.HasFilter("[IsDeleted]=0")` cho 2 config → flip GREEN. **⚠️ Soft-delete trong test (giống Holiday):** AuditingInterceptor (prod soft-delete Deleted→Modified+IsDeleted=true) KHÔNG wire trong SqliteDbFixture → `Remove+SaveChanges` = HARD delete (không test được). PHẢI seed row `IsDeleted=true` thủ công để mô phỏng slot bị chiếm. Handlers chỉ cần IApplicationDbContext → `new CreateXxxHandler(db)`. Tag [s51, p11-c, gotcha-57, filtered-unique, test-before].
-- **2026-05-30 (S43 P11-B Wave3 LeaveBalance):** +8 test `tests/.../Application/LeaveBalanceTests.cs` → **152 PASS** (Infra 86→94). Deduction hook (ApproveLeaveRequestHandler terminal) full: deduct single-level (create row from DaysPerYear), only-at-terminal multi-level (advance no-deduct + 1× terminal), accumulate UPSERT (5+2=7 no new row), negative allowed (Used20>Entitled12 → Remaining−8 no throw), Reject+Return no-deduct (split 5a/5b), GetMyLeaveBalances lazy synth (2 active type filter inactive), AdjustLeaveBalance upsert. **⚠️ FOUND + FIXED 2 pre-existing RED** in S42 template (`Approve_LastLevel_TransitionsToDaDuyet` + `Approve_EmptyComment_StoresPlaceholder`): Wave 1 deduction hook (uncommitted, prod) làm terminal insert LeaveBalance FK→LeaveTypes Restrict FAIL vì BuildLeave dùng `LeaveTypeId=Guid.NewGuid()`. **NOT prod bug** (prod đơn luôn pin LeaveType thật) — fix tại test: BuildLeave +optional leaveTypeId, seed LeaveType ở 2 test đó. Baseline thật trước S43 = 142-pass/2-RED (KHÔNG phải 144-green). REPORTED em main.
-
---
## ⚠️ Anti-patterns (DO NOT)
diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx
index 46215c1..5c605c3 100644
--- a/fe-admin/src/components/pe/PeDetailTabs.tsx
+++ b/fe-admin/src/components/pe/PeDetailTabs.tsx
@@ -121,6 +121,13 @@ export function PeDetailTabs({
// Mig 28 (S21 t4) — F3: Approver edit Section 2 (Hạng mục + NCC + Báo giá).
const { user: currentUser } = useAuth()
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
+ // S69 — cờ gấp: role quyết định nút nào hiện. PRO (Procurement) → cờ ĐỎ
+ // (isUrgentByPro), CCM (CostControl) → cờ XANH (isUrgentByCcm), Admin → cả 2.
+ // BE chặn Forbidden role khác → FE chỉ ẩn nút (UX), không phải security.
+ const isPro = currentUser?.roles?.includes('Procurement') ?? false
+ const isCcm = currentUser?.roles?.includes('CostControl') ?? false
+ const canToggleProUrgent = isAdmin || isPro
+ const canToggleCcmUrgent = isAdmin || isCcm
const v2Approvers = evaluation.currentApproval?.approvers ?? []
const actorMatchesLevel = isAdmin
|| (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id))
@@ -155,6 +162,19 @@ export function PeDetailTabs({
onError: e => toast.error(getErrorMessage(e)),
})
+ // S69 — toggle cờ gấp (PUT /urgent { isUrgent }). BE role-aware: PRO flip cờ ĐỎ,
+ // CCM flip cờ XANH, Admin set CẢ 2. FE optimistic + invalidate detail + list.
+ const toggleUrgent = useMutation({
+ mutationFn: async (isUrgent: boolean) =>
+ api.put(`/purchase-evaluations/${evaluation.id}/urgent`, { isUrgent }),
+ onSuccess: (_d, isUrgent) => {
+ toast.success(isUrgent ? 'Đã đánh dấu phiếu GẤP.' : 'Đã bỏ đánh dấu gấp.')
+ qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
+ qc.invalidateQueries({ queryKey: ['pe-list'] })
+ },
+ onError: e => toast.error(getErrorMessage(e)),
+ })
+
const forwardPhase = evaluation.workflow.nextPhases.find(p =>
p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
@@ -223,6 +243,17 @@ export function PeDetailTabs({
({PurchaseEvaluationPhaseLabel[evaluation.phase]})
+ {/* S69 — badge cờ gấp: ĐỎ (PRO) / XANH-lá (CCM). Hiển thị độc lập. */}
+ {evaluation.isUrgentByPro && (
+
+ 🔴 GẤP (PRO)
+
+ )}
+ {evaluation.isUrgentByCcm && (
+
+ 🟢 GẤP (CCM)
+
+ )}
{readOnly && (
chế độ duyệt
@@ -239,6 +270,56 @@ export function PeDetailTabs({
{evaluation.workItemName && <>–{evaluation.workItemName}>}
{evaluation.drafterName && <>·Soạn: {evaluation.drafterName}>}
+ {/* S69 — nút bật/tắt cờ gấp (theo role) + hint giá trị gói vs ngưỡng CEO. */}
+ {(canToggleProUrgent || canToggleCcmUrgent || evaluation.ceoApprovalThreshold != null) && (
+
+ {canToggleProUrgent && (
+
+ )}
+ {canToggleCcmUrgent && (
+
+ )}
+ {/* Hint giá trị gói vs ngưỡng CEO (chỉ khi workflow có set ngưỡng). */}
+ {evaluation.ceoApprovalThreshold != null && (
+
+ Giá trị gói: {fmtMoney(evaluation.winnerQuoteTotal)}đ
+ {' — '}
+ {evaluation.winnerQuoteTotal < evaluation.ceoApprovalThreshold ? (
+ CCM duyệt là xong
+ ) : (
+ Cần CEO duyệt
+ )}
+ (ngưỡng {fmtMoney(evaluation.ceoApprovalThreshold)}đ)
+
+ )}
+
+ )}
{/* Header bar actions: User 2026-05-07 chốt bỏ "Sửa header" + "Xóa" +
"Đóng" (workspace mode actions chuyển xuống bottom action bar). Vẫn
diff --git a/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx b/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx
index 10f02aa..95d2a83 100644
--- a/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx
+++ b/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx
@@ -349,7 +349,12 @@ export function PurchaseEvaluationsListPage() {
>
{/* Plan AG6 — compact card 3 row: title+badge / mã+time / drafter+dept+contract */}
-
{p.tenGoiThau}
+
+ {/* S69 — chấm gấp: ĐỎ (PRO) / XANH-lá (CCM) cạnh tên gói. */}
+ {p.isUrgentByPro && 🔴}
+ {p.isUrgentByCcm && 🟢}
+ {p.tenGoiThau}
+
)}
+ {/* S69 — badge ngưỡng gói CEO (nếu có set) */}
+ {def.ceoApprovalThreshold != null && (
+
+ Ngưỡng CEO: {def.ceoApprovalThreshold.toLocaleString('vi-VN')}đ
+
+ )}
{def.description && {def.description}
}
@@ -498,6 +510,10 @@ function Designer({
const [code, setCode] = useState(cloneFrom?.code ?? defaultCode)
const [name, setName] = useState(cloneFrom ? cloneFrom.name : `Quy trình ${applicableTypeLabel}`)
const [description, setDescription] = useState(cloneFrom?.description ?? '')
+ // S69 — Ngưỡng gói CEO (nullable). String form, '' = null. Clone giữ ngưỡng cũ.
+ const [ceoThreshold, setCeoThreshold] = useState(
+ cloneFrom?.ceoApprovalThreshold != null ? String(cloneFrom.ceoApprovalThreshold) : '',
+ )
const [steps, setSteps] = useState(initialSteps)
// Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — 7 Allow* options
@@ -550,6 +566,8 @@ function Designer({
code,
name,
description: description || null,
+ // S69 — ngưỡng gói CEO: '' → null (không áp ngưỡng), else parse số.
+ ceoApprovalThreshold: ceoThreshold.trim() === '' ? null : Number(ceoThreshold.replace(/[^\d]/g, '')),
steps: steps.map((s, i) => ({
order: i + 1,
name: s.name,
@@ -628,6 +646,20 @@ function Designer({