[CLAUDE] FE-User: tab strip 4 giai doan tren phieu PE + menu 4-GD + danh so 01-07 nhom HD (UAT anh Kiet)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m44s

- PePipelineStrip moi (mirror x2 app md5-identical): Duyet NCC | Ke hoach ky ket HD | Duyet hop dong | Hop dong cung — chen dau PeDetailTabs (GD1 active)
- Menu 4-GD: rename 2 root (PurchaseEvaluations->Duyet NCC, Contracts->Duyet hop dong) + 2 root placeholder KeHoachKyKet/HopDongCung (ngoai MenuKeys.All, staticMap -> /dashboard) — prod da SQL truc tiep cung ngay, seeder+labelBackfill giu hoi tu (gotcha #11)
- Danh so 01.-07. nhom HD: prod prepend-only 0-VN-over-wire (renamed=7), seeder typeLabels + labelBackfill 7 entry
- Dong bo nhan 4-GD tren dashboard (3 title + 3 group aria)
- reviewKeys += 2 placeholder CanRead-only; test 562 PASS; build fe x2 PASS

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-29 12:06:19 +07:00
parent b2a86feab6
commit 1a47a61c21
8 changed files with 190 additions and 18 deletions

View File

@ -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 (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
{/* [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) */}
<PePipelineStrip current={1} />
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
<div>
<div className="flex items-center gap-2">

View File

@ -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 (
<div
role="list"
aria-label="Toàn trình 4 giai đoạn"
className="flex items-stretch gap-1 overflow-x-auto rounded-t-lg border-b border-slate-200 bg-slate-50/70 px-2 py-1.5"
>
{STAGES.map((s, i) => {
const active = s.n === current
return (
<div key={s.n} role="listitem" className="flex min-w-0 flex-1 items-center gap-1">
<div
aria-current={active ? 'step' : undefined}
title={
active
? 'Giai đoạn hiện tại'
: s.soon
? 'Sắp triển khai'
: 'Sẽ nối khi giai đoạn trước hoàn tất'
}
className={cn(
'flex w-full min-w-0 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-center text-[11px] font-medium leading-tight',
active
? 'bg-brand-600 text-white shadow-sm'
: 'border border-dashed border-slate-300 bg-white text-slate-500',
)}
>
{active && <Check aria-hidden className="h-3 w-3 shrink-0" />}
<span className="truncate">{s.label}</span>
{s.soon && !active && (
<span className="shrink-0 rounded bg-slate-100 px-1 text-[9px] font-semibold uppercase tracking-wide text-slate-400">
Sắp
</span>
)}
</div>
{i < STAGES.length - 1 && (
<span aria-hidden className="shrink-0 px-0.5 text-slate-300">
</span>
)}
</div>
)
})}
</div>
)
}

View File

@ -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.

View File

@ -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 (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
{/* [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) */}
<PePipelineStrip current={1} />
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
<div>
<div className="flex items-center gap-2">

View File

@ -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 (
<div
role="list"
aria-label="Toàn trình 4 giai đoạn"
className="flex items-stretch gap-1 overflow-x-auto rounded-t-lg border-b border-slate-200 bg-slate-50/70 px-2 py-1.5"
>
{STAGES.map((s, i) => {
const active = s.n === current
return (
<div key={s.n} role="listitem" className="flex min-w-0 flex-1 items-center gap-1">
<div
aria-current={active ? 'step' : undefined}
title={
active
? 'Giai đoạn hiện tại'
: s.soon
? 'Sắp triển khai'
: 'Sẽ nối khi giai đoạn trước hoàn tất'
}
className={cn(
'flex w-full min-w-0 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-center text-[11px] font-medium leading-tight',
active
? 'bg-brand-600 text-white shadow-sm'
: 'border border-dashed border-slate-300 bg-white text-slate-500',
)}
>
{active && <Check aria-hidden className="h-3 w-3 shrink-0" />}
<span className="truncate">{s.label}</span>
{s.soon && !active && (
<span className="shrink-0 rounded bg-slate-100 px-1 text-[9px] font-semibold uppercase tracking-wide text-slate-400">
Sắp
</span>
)}
</div>
{i < STAGES.length - 1 && (
<span aria-hidden className="shrink-0 px-0.5 text-slate-300">
</span>
)}
</div>
)
})}
</div>
)
}

View File

@ -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() {
<StageArrow />
{/* GĐ2 — sắp triển khai */}
<StageCard index={2} title="Đề xuất ký kết hợp đồng" stepRange="Bước 7 → 12" icon={FileSignature}>
<StageCard index={2} title="Kế hoạch ký kết HĐ" stepRange="Bước 7 → 12" icon={FileSignature}>
<StageSoon
description="Phiếu nối tiếp từ phiếu Duyệt NCC đã duyệt — chốt giá trị ký kết."
hint={
@ -489,14 +489,14 @@ export function UserDashboardPage() {
{/* GĐ3 — LIVE */}
<StageCard
index={3}
title="Duyệt Hợp đồng"
title="Duyệt hợp đồng"
stepRange="Bước 13 → 18"
icon={FileText}
tone="teal"
>
<div className="space-y-0.5">
<StageStatRow
group="Duyệt Hợp đồng"
group="Duyệt hợp đồng"
label="Tổng hợp đồng"
value={recent.data?.total}
tone="teal"
@ -505,7 +505,7 @@ export function UserDashboardPage() {
onClick={() => navigate('/my-contracts')}
/>
<StageStatRow
group="Duyệt Hợp đồng"
group="Duyệt hợp đồng"
label="Chờ tôi duyệt"
value={s?.pendingMyApproval}
tone="teal"
@ -515,7 +515,7 @@ export function UserDashboardPage() {
onClick={() => navigate('/inbox')}
/>
<StageStatRow
group="Duyệt Hợp đồng"
group="Duyệt hợp đồng"
label="Đã quá hạn"
value={s?.overdue}
tone="teal"
@ -531,7 +531,7 @@ export function UserDashboardPage() {
<StageArrow />
{/* GĐ4 — sắp triển khai */}
<StageCard index={4} title="Ký cứng & phát hành" stepRange="Bước 19 → 21" icon={Stamp}>
<StageCard index={4} title="Hợp đồng cứng" stepRange="Bước 19 → 21" icon={Stamp}>
<StageSoon description="Tải lên bản hợp đồng đã ký cứng." />
</StageCard>
</div>

View File

@ -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";

View File

@ -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<string, string>
{
["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();