diff --git a/fe-admin/src/components/pe/PePipelineStrip.tsx b/fe-admin/src/components/pe/PePipelineStrip.tsx
new file mode 100644
index 0000000..b115479
--- /dev/null
+++ b/fe-admin/src/components/pe/PePipelineStrip.tsx
@@ -0,0 +1,65 @@
+// PePipelineStrip — dải 4 giai đoạn toàn trình, đặt trên đầu detail phiếu Duyệt NCC.
+// [S159 2026-07-29] Owner vẽ tab strip trực tiếp lên ảnh phiếu + chốt tên qua chat:
+// "Duyệt NCC | Kế hoạch ký kết HĐ | Duyệt hợp đồng | Hợp đồng cứng"
+// (2 chỉnh verbatim: "Trình ký Hợp đồng đổi thành Duyệt hợp đồng, ký cứng -> Hợp đồng cứng").
+// MIRROR ×2 app BYTE-IDENTICAL (cùng họ PeDetailTabs md5-parity) — sửa file này
+// thì sửa CẢ HAI, verify md5 trước commit.
+// v1: phiếu PE ⇒ giai đoạn 1 active. GĐ2/GĐ4 chưa có module (badge "Sắp");
+// GĐ3 có module HĐ nhưng CHƯA nối per-phiếu (cầu PE→KHKK→HĐ ship ở wave GĐ2)
+// ⇒ mọi stage không-active đều KHÔNG click được — hiển thị lộ trình, không giả link.
+import { Check } from 'lucide-react'
+import { cn } from '@/lib/cn'
+
+const STAGES = [
+ { n: 1, label: 'Duyệt NCC', soon: false },
+ { n: 2, label: 'Kế hoạch ký kết HĐ', soon: true },
+ { n: 3, label: 'Duyệt hợp đồng', soon: false },
+ { n: 4, label: 'Hợp đồng cứng', soon: true },
+] as const
+
+export function PePipelineStrip({ current = 1 }: { current?: number }) {
+ return (
+
+ {STAGES.map((s, i) => {
+ const active = s.n === current
+ return (
+
+
+ {active && }
+ {s.label}
+ {s.soon && !active && (
+
+ Sắp
+
+ )}
+
+ {i < STAGES.length - 1 && (
+
+ →
+
+ )}
+
+ )
+ })}
+
+ )
+}
diff --git a/fe-user/src/components/Layout.tsx b/fe-user/src/components/Layout.tsx
index 43669e6..c0bfd11 100644
--- a/fe-user/src/components/Layout.tsx
+++ b/fe-user/src/components/Layout.tsx
@@ -57,6 +57,11 @@ function resolvePath(key: string): string | null {
Dashboard: '/dashboard',
Contracts: '/my-contracts',
PurchaseEvaluations: '/purchase-evaluations',
+ // [S159 2026-07-29] 2 root placeholder giai đoạn 2/4 (owner: menu hiển thị đúng 4 GĐ
+ // như tab strip). Module chưa build → trỏ về Tổng quan pipeline (khung "Sắp triển khai").
+ // LESSON gotcha #50: key DB mới PHẢI có route ở đây, không thì sidebar drop SILENT.
+ KeHoachKyKet: '/dashboard',
+ HopDongCung: '/dashboard',
// [Plan CA Hotfix 1 S29 2026-05-22] 4 master + 4 catalog leaf moved từ
// fe-admin → fe-user. resolvePath PHẢI có route mapping nếu không
// MenuLeaf line 238 `if (!path) return null` → sidebar drop silent.
diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx
index a5e6043..4b56187 100644
--- a/fe-user/src/components/pe/PeDetailTabs.tsx
+++ b/fe-user/src/components/pe/PeDetailTabs.tsx
@@ -18,6 +18,7 @@ import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { useAuth } from '@/contexts/AuthContext'
import { AttachmentPreviewDialog, isPreviewable } from './AttachmentPreviewDialog'
+import { PePipelineStrip } from './PePipelineStrip'
import {
PeAttachmentPurpose,
PeAttachmentPurposeLabel,
@@ -272,6 +273,8 @@ export function PeDetailTabs({
return (
+ {/* [S159] dải 4 giai đoạn toàn trình — owner vẽ tab lên ảnh phiếu (phiếu PE ⇒ GĐ1 active) */}
+
diff --git a/fe-user/src/components/pe/PePipelineStrip.tsx b/fe-user/src/components/pe/PePipelineStrip.tsx
new file mode 100644
index 0000000..b115479
--- /dev/null
+++ b/fe-user/src/components/pe/PePipelineStrip.tsx
@@ -0,0 +1,65 @@
+// PePipelineStrip — dải 4 giai đoạn toàn trình, đặt trên đầu detail phiếu Duyệt NCC.
+// [S159 2026-07-29] Owner vẽ tab strip trực tiếp lên ảnh phiếu + chốt tên qua chat:
+// "Duyệt NCC | Kế hoạch ký kết HĐ | Duyệt hợp đồng | Hợp đồng cứng"
+// (2 chỉnh verbatim: "Trình ký Hợp đồng đổi thành Duyệt hợp đồng, ký cứng -> Hợp đồng cứng").
+// MIRROR ×2 app BYTE-IDENTICAL (cùng họ PeDetailTabs md5-parity) — sửa file này
+// thì sửa CẢ HAI, verify md5 trước commit.
+// v1: phiếu PE ⇒ giai đoạn 1 active. GĐ2/GĐ4 chưa có module (badge "Sắp");
+// GĐ3 có module HĐ nhưng CHƯA nối per-phiếu (cầu PE→KHKK→HĐ ship ở wave GĐ2)
+// ⇒ mọi stage không-active đều KHÔNG click được — hiển thị lộ trình, không giả link.
+import { Check } from 'lucide-react'
+import { cn } from '@/lib/cn'
+
+const STAGES = [
+ { n: 1, label: 'Duyệt NCC', soon: false },
+ { n: 2, label: 'Kế hoạch ký kết HĐ', soon: true },
+ { n: 3, label: 'Duyệt hợp đồng', soon: false },
+ { n: 4, label: 'Hợp đồng cứng', soon: true },
+] as const
+
+export function PePipelineStrip({ current = 1 }: { current?: number }) {
+ return (
+
+ {STAGES.map((s, i) => {
+ const active = s.n === current
+ return (
+
+
+ {active && }
+ {s.label}
+ {s.soon && !active && (
+
+ Sắp
+
+ )}
+
+ {i < STAGES.length - 1 && (
+
+ →
+
+ )}
+
+ )
+ })}
+
+ )
+}
diff --git a/fe-user/src/pages/UserDashboardPage.tsx b/fe-user/src/pages/UserDashboardPage.tsx
index 50f38e5..4394178 100644
--- a/fe-user/src/pages/UserDashboardPage.tsx
+++ b/fe-user/src/pages/UserDashboardPage.tsx
@@ -2,9 +2,9 @@
// [S159 2026-07-29 — owner chốt qua AskUser] Hero = dải pipeline 4 GIAI ĐOẠN xuyên
// suốt quy trình mua sắm → ký kết hợp đồng:
// GĐ1 Duyệt NCC (bước 1 → 6, CEO chốt) 🟢 LIVE — module PE
-// GĐ2 Đề xuất ký kết hợp đồng (bước 7 → 12) ⏳ sắp triển khai
-// GĐ3 Duyệt Hợp đồng (bước 13 → 18) 🟢 LIVE — module Contract
-// GĐ4 Ký cứng & phát hành (bước 19 → 21) ⏳ sắp triển khai
+// GĐ2 Kế hoạch ký kết HĐ (bước 7 → 12) ⏳ sắp triển khai
+// GĐ3 Duyệt hợp đồng (bước 13 → 18) 🟢 LIVE — module Contract
+// GĐ4 Hợp đồng cứng (bước 19 → 21) ⏳ sắp triển khai
// Dưới hero GIỮ nguyên KPI cá nhân + 2 danh sách gần đây (chỉ redesign cho hài hoà).
// fe-user ONLY — KHÔNG mirror sang fe-admin (trang này là UX riêng của user).
//
@@ -473,7 +473,7 @@ export function UserDashboardPage() {
{/* GĐ2 — sắp triển khai */}
-
+
navigate('/my-contracts')}
/>
navigate('/inbox')}
/>
{/* GĐ4 — sắp triển khai */}
-
+
diff --git a/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs b/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs
index 612d7fa..a1af172 100644
--- a/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs
+++ b/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs
@@ -34,6 +34,13 @@ public static class MenuKeys
public static readonly string[] ContractTypeCodes =
["ThauPhu", "GiaoKhoan", "NhaCungCap", "DichVu", "MuaBan", "NguyenTacNcc", "NguyenTacDv"];
+ // [S159 2026-07-29] 2 root PLACEHOLDER giai đoạn 2/4 (owner: menu hiển thị đúng
+ // 4 giai đoạn như tab strip). CỐ Ý NGOÀI `All` — chưa có module ⇒ không sinh
+ // policy per-action (giữ canonical Menu keys/Policies không drift); FE trỏ về
+ // /dashboard. Wave-1 GĐ2 (spec KHKK) phải TÁI DÙNG/thay root `KeHoachKyKet` này.
+ public const string KeHoachKyKet = "KeHoachKyKet";
+ public const string HopDongCung = "HopDongCung";
+
public static string ContractTypeGroup(string typeCode) => $"Ct_{typeCode}";
public static string ContractTypeList(string typeCode) => $"Ct_{typeCode}_List";
public static string ContractTypeCreate(string typeCode) => $"Ct_{typeCode}_Create";
diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs
index ff0fe76..4df9355 100644
--- a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs
+++ b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs
@@ -1736,15 +1736,19 @@ public static class DbInitializer
private static async Task SeedMenuTreeAsync(ApplicationDbContext db, ILogger logger)
{
+ // [S159 2026-07-29] UAT anh Kiệt: "cho đánh số thứ tự phía trước giúp anh — 01. HĐ thầu phụ"
+ // ⇒ prefix 01.-07. theo THỨ TỰ hiển thị (= thứ tự ContractTypeCodes). Prod đã UPDATE
+ // trực tiếp cùng ngày (prepend-only, 0 ký tự VN qua wire); labelBackfill dưới giữ
+ // các env khác hội tụ (gotcha #11 — sửa tree.Add một mình là vô tác dụng trên DB đang chạy).
var typeLabels = new Dictionary
{
- ["ThauPhu"] = "HĐ Thầu phụ",
- ["GiaoKhoan"] = "HĐ Giao khoán",
- ["NhaCungCap"] = "HĐ Nhà cung cấp",
- ["DichVu"] = "HĐ Dịch vụ",
- ["MuaBan"] = "HĐ Mua bán",
- ["NguyenTacNcc"] = "HĐ Nguyên tắc NCC",
- ["NguyenTacDv"] = "HĐ Nguyên tắc Dịch vụ",
+ ["ThauPhu"] = "01. HĐ Thầu phụ",
+ ["GiaoKhoan"] = "02. HĐ Giao khoán",
+ ["NhaCungCap"] = "03. HĐ Nhà cung cấp",
+ ["DichVu"] = "04. HĐ Dịch vụ",
+ ["MuaBan"] = "05. HĐ Mua bán",
+ ["NguyenTacNcc"] = "06. HĐ Nguyên tắc NCC",
+ ["NguyenTacDv"] = "07. HĐ Nguyên tắc Dịch vụ",
};
var tree = new List<(string Key, string Label, string? Parent, int Order, string Icon)>
@@ -1764,7 +1768,11 @@ public static class DbInitializer
(MenuKeys.CatalogServices, "Dịch vụ", MenuKeys.Catalogs, 3, "Wrench"),
(MenuKeys.CatalogWorkItems,"Hạng mục công việc", MenuKeys.Catalogs, 4, "ListChecks"),
// [S57] Order 30→31: nhường slot 30 cho nhóm "Cá nhân" (đứng ngay sau Văn phòng số = 29, mirror Puro).
- (MenuKeys.Contracts, "Hợp đồng", null, 31, "FileText"),
+ (MenuKeys.Contracts, "Duyệt hợp đồng", null, 31, "FileText"), // [S159] rename theo 4-GĐ (owner tab strip)
+ // [S159] 2 root PLACEHOLDER GĐ2/GĐ4 — menu = 4 giai đoạn (owner). Ngoài MenuKeys.All,
+ // FE staticMap trỏ /dashboard. Prod đã INSERT trực tiếp cùng ngày (idempotent ở đây).
+ (MenuKeys.KeHoachKyKet, "Kế hoạch ký kết HĐ", null, 26, "FileSignature"),
+ (MenuKeys.HopDongCung, "Hợp đồng cứng", null, 32, "Stamp"),
(MenuKeys.Forms, "Biểu mẫu", null, 40, "FileSpreadsheet"),
(MenuKeys.Reports, "Báo cáo", null, 50, "BarChart3"),
(MenuKeys.System, "Hệ thống", null, 90, "Settings"),
@@ -1774,7 +1782,7 @@ public static class DbInitializer
(MenuKeys.MenuVisibility, "Menu eOffice", MenuKeys.System, 94, "Eye"),
(MenuKeys.Workflows, "Quy trình HĐ", MenuKeys.System, 95, "GitBranch"),
// Module Duyệt NCC (tiền-HĐ)
- (MenuKeys.PurchaseEvaluations, "Quy trình chọn Thầu phụ - NCC", null, 25, "ClipboardCheck"),
+ (MenuKeys.PurchaseEvaluations, "Duyệt NCC", null, 25, "ClipboardCheck"), // [S159] rename theo 4-GĐ
(MenuKeys.PeWorkflows, "Quy trình Duyệt NCC", MenuKeys.System, 95, "GitCompareArrows"),
// Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08): schema riêng
// UAT trước khi migrate hoàn toàn. Cấu trúc: Quy trình > Bước (Phòng) > Cấp (NV cụ thể).
@@ -1932,6 +1940,19 @@ public static class DbInitializer
// chạy vì upsert `:1893-1905` chỉ update `Order` cho key đã tồn tại.
[MenuKeys.PurchaseEvaluationPending("DuyetNcc")] = "Đang duyệt",
[MenuKeys.PurchaseEvaluationPending("DuyetNccPhuongAn")] = "Đang duyệt",
+ // [S159] 🔴 UAT anh Kiệt: đánh số 01.-07. nhóm HĐ (verbatim "01 . HĐ thầu phụ").
+ // Prod đã prepend trực tiếp cùng ngày ⇒ trên prod các entry này NO-OP (equal);
+ // dev/fresh-DB hội tụ qua đây. Nguồn số = thứ tự ContractTypeCodes (= [Order]).
+ // [S159] 2 root rename theo 4-GĐ (prod đã SQL cùng ngày ⇒ NO-OP trên prod).
+ [MenuKeys.PurchaseEvaluations] = "Duyệt NCC",
+ [MenuKeys.Contracts] = "Duyệt hợp đồng",
+ [MenuKeys.ContractTypeGroup("ThauPhu")] = "01. HĐ Thầu phụ",
+ [MenuKeys.ContractTypeGroup("GiaoKhoan")] = "02. HĐ Giao khoán",
+ [MenuKeys.ContractTypeGroup("NhaCungCap")] = "03. HĐ Nhà cung cấp",
+ [MenuKeys.ContractTypeGroup("DichVu")] = "04. HĐ Dịch vụ",
+ [MenuKeys.ContractTypeGroup("MuaBan")] = "05. HĐ Mua bán",
+ [MenuKeys.ContractTypeGroup("NguyenTacNcc")] = "06. HĐ Nguyên tắc NCC",
+ [MenuKeys.ContractTypeGroup("NguyenTacDv")] = "07. HĐ Nguyên tắc Dịch vụ",
};
var updatedLabels = 0;
foreach (var (key, expectedLabel) in labelBackfill)
@@ -2113,6 +2134,9 @@ public static class DbInitializer
var reviewKeys = MenuKeys.All.Where(InReviewScope)
.Concat(peKeys)
+ // [S159] 2 root placeholder GĐ2/GĐ4 — CanRead-only mọi role (IsPeKey=false
+ // ⇒ nhánh read-only). Prod đã INSERT trực tiếp; đây giữ fresh-DB hội tụ.
+ .Concat(new[] { MenuKeys.KeHoachKyKet, MenuKeys.HopDongCung })
.Distinct()
.ToArray();
var roles = await roleManager.Roles.ToListAsync();