[CLAUDE] Workflow: KHKK 8 panel nhóm mirror DuyetNcc + BE guards (0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m24s

YC-013 (S182/L13). 8 leaf "Duyệt KHKK — Nhóm 1..8" ra đúng khuôn trang
DuyetNcc: slice per-Code + lockedCode + Sửa-tại-chỗ/Tạo-version/Ghim/Xoá.
D3=C (YC-014): 6 ô chưa-wire disabled-giữ-state-thật + tooltip trên label
(Chromium không hiện title trên input disabled — MAJOR-1); ô #4 thêm chữ
nhìn thấy được; #7 giữ; #8 Finalize 0-chạm (prod 8/88 dựa nó); ô CEO
disable type-10 + POST null.

BE 5 guard (0 migration):
- W1a deactivate-per-Code cho type-10 (Code+Type+IsActive — cấm regex);
  Code normalize Trim().ToUpperInvariant() tại đường ghi + IgnoreCase belt
  trong GroupCodePattern (MAJOR-2: SQLite test case-sensitive nguoc prod
  CI_AS collation)
- W1b ContractSigningPlanWorkflowGuards (public static, Application) —
  rào nhóm 3 cửa Create/UpdateDraft/Submit, giữ cho-qua-code-ngoài-họ
- W1b2 (D6) pin-check IsActive+IsUserSelectable 2 cửa, 2 message tách
- W1c changelog PUT type-10 vào ContractSigningPlanChangelogs
- W1d DELETE usage-check 409 đích danh (8 module pin, IgnoreQueryFilters
  cho PE/Contract/KHKK; 5 module office pin loose-0FK — guard là rào duy
  nhất chống dangling) + 404 NotFoundException + D5 rào version-cuối nhóm
  (+ ApplicableType — MINOR-3)

Test 645 → 660 (+14 spec + T15 D6-Create MAJOR-3); MINOR-1 pin nguyên câu
thứ-tự 1 ca. File test thứ 3 (GroupAndCatalog) đổi 1 assertion theo câu
409 mới của W1b — spec-change, không phải nới bar.

Khai accepted (reviewer PASS_WITH_FIXES 0C/3M+6m, đã vá 3M+2m):
- MINOR-4: panel nhóm tạo-version-mới ⇒ 6 cờ khoá đóng băng default FE
  (vô hại — BE không đọc 6 cờ đó cho type-10)
- MINOR-5: bỏ ghim/bỏ active bản cuối nhóm = đóng cửa tạo phiếu nhóm đó
  (răng thật của nút Ghim — không rào đợt này)
- race 2-admin cùng Code → 2601/500 thô (UNIQUE đỡ data, chấp nhận)

Evidence: .claude/workflows/runs/2026-08-08-S182-khkk-awf-mirror-ncc/
(3 fable-invest + 2 fable-review + spec FINAL + 4 lane + precheck prod
12 phiếu/pin-lệch-0 + reviewer pre-commit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-08 12:14:37 +07:00
parent 18f8f76bac
commit 706134daa9
13 changed files with 1604 additions and 89 deletions

View File

@ -30,6 +30,21 @@ export const MenuKeys = {
ApprovalWorkflowsV2: 'ApprovalWorkflowsV2',
AwV2_DuyetNcc: 'AwV2_DuyetNcc',
AwV2_DuyetNccPhuongAn: 'AwV2_DuyetNccPhuongAn',
// [W2e S182] 8 leaf Designer theo NHÓM DUYỆT của Kế hoạch ký kết HĐ.
// Mirror BE `MenuKeys.cs:127-134` (8 key này VÀO `All` ⇒ 32 policy AwV2_KhkkN{n}.{R,C,U,D}).
// ⚠️ File này là bản chép TAY của BE và hiện chỉ dùng cho `can(menuKey, action)`;
// 8 key dưới đây CHƯA có call-site nào — chép vào để bảng đối chiếu FE↔BE không
// khuyết, tránh lượt sau đọc chỗ này rồi kết luận "BE chưa có key nhóm".
// Đường vào sidebar KHÔNG đi qua đây: sidebar dựng từ `/api/menus/me` rồi
// `Layout.tsx:188` (`/^KhkkN[1-8]$/`) mới đổi key thành đường dẫn.
AwV2_KhkkN1: 'AwV2_KhkkN1',
AwV2_KhkkN2: 'AwV2_KhkkN2',
AwV2_KhkkN3: 'AwV2_KhkkN3',
AwV2_KhkkN4: 'AwV2_KhkkN4',
AwV2_KhkkN5: 'AwV2_KhkkN5',
AwV2_KhkkN6: 'AwV2_KhkkN6',
AwV2_KhkkN7: 'AwV2_KhkkN7',
AwV2_KhkkN8: 'AwV2_KhkkN8',
// [S61 Mig 50] Module Ngân sách cũ (Budgets + Bg_*) XÓA — thay bằng bảng
// "Tổng hợp ngân sách trình ký" per (Dự án, Hạng mục) trong phiếu PE.
// Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26)

View File

@ -59,6 +59,11 @@ interface WorkflowDto {
code: string
name: string
isUserSelectable: boolean
// [W2g S182] Cùng một mã `KHKK-N{n}` có NHIỀU phiên bản (admin bấm "Tạo phiên bản
// mới" thì bản cũ chuyển sang lưu trữ). `history` mà BE trả về là DANH SÁCH ĐẦY ĐỦ
// — cả bản đang áp dụng lẫn bản đã lưu trữ — nên thiếu cờ này thì màn tạo phiếu
// không phân biệt được, xem thêm chỗ dùng bên dưới.
isActive: boolean
}
// 🧊 [S174] `firstFreeGroup()` ĐÃ GỠ cùng lúc bỏ ô chọn nhóm — nó chỉ tồn tại để tự nhảy
@ -110,9 +115,15 @@ export function KhkkCreatePage() {
// `var code = $"KHKK-N{n}"` (vòng `for n = 1..8`, seed idempotent per-Code).
// Không khớp ⇒ KHÔNG tự rơi về quy trình khác: chặn tạo + nói rõ thiếu mã nào, vì gán
// nhầm quy trình là phiếu đi sai đường duyệt — hỏng nặng hơn là không tạo được.
// [W2g S182] 🔴 Phải lọc thêm `isActive`. Danh sách trả về gồm CẢ phiên bản đã lưu
// trữ, mà `find` lấy phần tử ĐẦU TIÊN khớp mã — hễ admin tạo phiên bản mới cho nhóm
// này là phiếu mới có thể bị gắn vào bản CŨ (thứ tự do BE trả, không do màn này
// quyết), đi theo đội hình duyệt đã bị thay. Gắn sai quy trình thì lúc phát hiện
// phiếu đã chạy được vài trạm rồi. Ràng buộc `isUserSelectable` ở query trên vẫn giữ
// (admin ghim bản nào thì user mới được dùng bản đó).
const wantWorkflowCode = `KHKK-N${approvalGroup}`
const autoWorkflow = workflows.data?.find(
(w) => (w.code ?? '').trim().toUpperCase() === wantWorkflowCode,
(w) => w.isActive && (w.code ?? '').trim().toUpperCase() === wantWorkflowCode,
) ?? null
// Phiếu đang XEM — chỉ tải khi thật sự có cái để xem.
@ -125,7 +136,11 @@ export function KhkkCreatePage() {
const create = useMutation({
mutationFn: async () => {
if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn')
if (!autoWorkflow) throw new Error(`Chưa có quy trình duyệt ${wantWorkflowCode}`)
// [W2g S182] Câu này nói ĐIỀU KIỆN, không nói "không tồn tại": quy trình có thể
// đang nằm đó nhưng ở phiên bản đã lưu trữ hoặc chưa được ghim.
if (!autoWorkflow) {
throw new Error(`Chưa có quy trình duyệt ${wantWorkflowCode} đang áp dụng và được ghim cho user chọn`)
}
const body: CreateKhkkInput = {
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
approvalWorkflowId: autoWorkflow.id,
@ -349,9 +364,10 @@ export function KhkkCreatePage() {
</div>
{!workflows.isLoading && !autoWorkflow ? (
<p className="mt-1 text-[11px] text-amber-700">
Chưa có quy trình <strong>{wantWorkflowCode}</strong> (hoặc chưa đưc ghim
cho user chọn). Nhờ admin mở trong màn Quy trình duyệt rồi quay lại hệ thống
KHÔNG tự ng quy trình của nhóm khác.
Chưa dùng đưc quy trình <strong>{wantWorkflowCode}</strong>: phải
phiên bản <strong>đang áp dụng</strong> <strong>đã ghim</strong> cho user chọn.
Nhờ admin mở trong màn Quy trình duyệt rồi quay lại hệ thống KHÔNG tự dùng
phiên bản đã lưu trữ, cũng KHÔNG tự dùng quy trình của nhóm khác.
</p>
) : (
<p className="mt-1 text-[11px] text-slate-500">

View File

@ -31,6 +31,9 @@ import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import type { Department, Paged } from '@/types/master'
// [W2 S182] 2 hàm dùng chung với màn nghiệp vụ (fe-user) — KHÔNG chép chuỗi
// `KHKK-N{n}` / nhãn nhóm vào file này, để 1 nguồn duy nhất khi BE đổi.
import { khkkGroupMenuLabel, khkkGroupWorkflowCode } from '@/types/khkk'
// ===== Types (mirror BE AwAdminOverviewDto) =====
@ -286,7 +289,21 @@ function isLevelEnabled(step: EditStep, order: LevelOrder): boolean {
export function ApprovalWorkflowsV2Page() {
const qc = useQueryClient()
const { typeCode } = useParams<{ typeCode?: string }>()
const selectedTypeInt = typeCode ? TYPE_CODE_TO_INT[typeCode] : null
// [W2a S182] 8 leaf `AwV2_KhkkN1..8` deep-link về CHÍNH trang này với typeCode
// `KhkkN{1..8}` (`Layout.tsx:188` — chỗ-thứ-4 Pattern 16-bis đã nối từ K3 S164).
// `KhkkN{n}` KHÔNG phải một ApplicableType riêng: cả 8 nhóm đều là type 10
// (Kế hoạch ký kết HĐ), phân biệt nhau bằng MÃ quy trình `KHKK-N{n}`. Nên chỗ này
// tách 2 khái niệm:
// · `khkkGroup` = nhóm duyệt 1..8 (null nếu không vào từ leaf nhóm)
// · `selectedTypeInt` = ApplicableType gửi BE — nhóm nào cũng là 10.
// Hệ quả CÓ CHỦ ĐÍCH: queryKey của 8 panel nhóm + panel `/ContractSigningPlan`
// TRÙNG NHAU ⇒ 1 lần tải dùng chung, và invalidate sau khi lưu làm tươi cả 9 màn.
const khkkGroupMatch = typeCode?.match(/^KhkkN([1-8])$/) ?? null
const khkkGroup = khkkGroupMatch ? Number(khkkGroupMatch[1]) : null
const selectedTypeInt = khkkGroup !== null
? TYPE_INT_CONTRACT_SIGNING_PLAN
: typeCode ? TYPE_CODE_TO_INT[typeCode] : null
const overview = useQuery({
queryKey: ['approval-workflow-v2-overview', selectedTypeInt],
@ -296,25 +313,60 @@ export function ApprovalWorkflowsV2Page() {
},
})
const currentType = selectedTypeInt
? overview.data?.types.find(t => t.applicableType === selectedTypeInt)
const typeBucket = selectedTypeInt
? overview.data?.types.find(t => t.applicableType === selectedTypeInt) ?? null
: null
// [W2b S182] Lát cắt TỔNG HỢP cho panel nhóm. BE trả 1 bucket cho CẢ type 10
// (`active` = 1-trong-8 quy trình nhóm, tuỳ bản nào kích hoạt sau cùng), nên
// panel nhóm KHÔNG được đọc `bucket.active` — sẽ hiện nhầm quy trình của nhóm khác.
// Cắt lại từ `history`:
// 🔴 `history` là DANH SÁCH ĐẦY ĐỦ, KỂ CẢ bản đang áp dụng (verified BE
// `ApprovalWorkflowV2AdminFeatures.cs:206-211` — không lọc `!IsActive`);
// chính `TypePanel` mới lọc `!isActive` lúc render mục "Lịch sử versions".
// · `active` suy TỪ chính lát cắt (`find(v => v.isActive)`), không mượn ô ngoài.
// Giữ nguyên shape `TypeSummaryDto` ⇒ `TypePanel`/`Designer` không cần biết
// chúng đang xem một nhóm hay cả loại.
const currentType: TypeSummaryDto | null = useMemo(() => {
if (khkkGroup === null) return typeBucket
if (!typeBucket) return null
const wantCode = khkkGroupWorkflowCode(khkkGroup)
const versions = typeBucket.history.filter(d => d.code === wantCode)
return {
applicableType: typeBucket.applicableType,
applicableTypeLabel: typeBucket.applicableTypeLabel,
active: versions.find(v => v.isActive) ?? null,
history: versions,
}
}, [khkkGroup, typeBucket])
// [W2c S182] Mã quy trình của panel nhóm là KHOÁ, không phải gợi ý: mỗi nhóm đúng
// MỘT chuỗi phiên bản `KHKK-N{n}`. Gõ mã khác trên panel nhóm = đẻ quy trình mồ côi
// (phiếu nhóm đó vẫn tìm `KHKK-N{n}` — `KhkkCreatePage.tsx:113`) ⇒ khoá thẳng ô Mã.
const lockedCode = khkkGroup !== null ? khkkGroupWorkflowCode(khkkGroup) : null
return (
<div className="p-6">
<PageHeader
// [W2h/D4 S182] Vào từ leaf nhóm thì tiêu đề phải nói ĐÚNG nhóm nào —
// `applicableTypeLabel` của BE chỉ có một chuỗi chung cho cả 8 nhóm
// ("Kế hoạch ký kết HĐ"), 8 panel sẽ trông y hệt nhau.
title={
<span className="flex items-center gap-2">
<Workflow className="h-5 w-5" />
{currentType
? `Quy trình duyệt (Mới): ${currentType.applicableTypeLabel}`
: 'Quy trình duyệt (Mới)'}
{khkkGroup !== null
? `Quy trình duyệt (Mới): ${khkkGroupMenuLabel(khkkGroup)}`
: currentType
? `Quy trình duyệt (Mới): ${currentType.applicableTypeLabel}`
: 'Quy trình duyệt (Mới)'}
</span>
}
description={
currentType
? 'Mỗi Bước = 1 Phòng. Mỗi Cấp trong Bước = 1 nhân viên cụ thể duyệt. Tuần tự: Cấp 1 → Cấp 2 → ... → Bước kế.'
: 'Schema mới UAT — chọn loại quy trình từ menu bên trái.'
khkkGroup !== null
? `${currentType?.applicableTypeLabel ?? 'Kế hoạch ký kết HĐ'} — nhóm duyệt N${khkkGroup}. Mã quy trình khoá theo nhóm: ${lockedCode}; mỗi lần lưu là một phiên bản mới của CHÍNH nhóm này, 7 nhóm còn lại không đổi.`
: currentType
? 'Mỗi Bước = 1 Phòng. Mỗi Cấp trong Bước = 1 nhân viên cụ thể duyệt. Tuần tự: Cấp 1 → Cấp 2 → ... → Bước kế.'
: 'Schema mới UAT — chọn loại quy trình từ menu bên trái.'
}
/>
@ -344,7 +396,11 @@ export function ApprovalWorkflowsV2Page() {
{currentType && (
<TypePanel
// [W2b S182] Panel nhóm và panel loại dùng CHUNG component: khác nhau duy
// nhất ở lát cắt truyền vào + `lockedCode` (null = panel loại, tự do đặt mã).
key={typeCode ?? 'all'}
type={currentType}
lockedCode={lockedCode}
onSaved={() => qc.invalidateQueries({ queryKey: ['approval-workflow-v2-overview'] })}
/>
)}
@ -354,7 +410,17 @@ export function ApprovalWorkflowsV2Page() {
// ===== Per-type panel =====
function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => void }) {
function TypePanel({
type,
// [W2c S182] `null` = panel theo LOẠI (admin tự đặt mã, hành vi cũ).
// Khác null = panel theo NHÓM KHKK: mã bị khoá, mọi version đều thuộc chuỗi đó.
lockedCode,
onSaved,
}: {
type: TypeSummaryDto
lockedCode: string | null
onSaved: () => void
}) {
const [designerOpen, setDesignerOpen] = useState(false)
const [cloneFrom, setCloneFrom] = useState<DefinitionDto | null>(null)
// [F-7 S155] Khác null ⇒ Designer mở ở chế độ SỬA TẠI CHỖ (PUT, giữ nguyên Id +
@ -448,6 +514,7 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
<Designer
applicableType={type.applicableType}
applicableTypeLabel={type.applicableTypeLabel}
lockedCode={lockedCode}
cloneFrom={cloneFrom}
editTarget={editTarget}
onClose={() => { setDesignerOpen(false); setCloneFrom(null); setEditTarget(null) }}
@ -620,6 +687,7 @@ function DefinitionCard({
function Designer({
applicableType,
applicableTypeLabel,
lockedCode,
cloneFrom,
editTarget,
onClose,
@ -627,6 +695,8 @@ function Designer({
}: {
applicableType: number
applicableTypeLabel: string
// [W2c S182] Mã bị KHOÁ theo nhóm KHKK (`KHKK-N{n}`), null = tự do như cũ.
lockedCode: string | null
cloneFrom: DefinitionDto | null
// [F-7 S155] Khác null ⇒ chế độ SỬA TẠI CHỖ (PUT). Loại trừ nhau với cloneFrom.
editTarget: DefinitionDto | null
@ -638,16 +708,38 @@ function Designer({
const isEdit = editTarget !== null
const source = editTarget ?? cloneFrom
// [K3 S164GỠ KHOÁ] `earlyFinalizeLocked` (W1 S161) ĐÃ BỎ HẲN: 2 ô "duyệt sớm"
// nay sống cho MỌI loại quy trình, kể cả Kế hoạch ký kết HĐ (type 10) — BE đã port
// đường finalize ở K3 cùng wave, và owner chốt TP.CCM là người quyết lên CEO hay
// kết thúc. Lý do đầy đủ ở khối ghi chú đầu file (gần `TYPE_INT_CONTRACT_SIGNING_PLAN`).
// [W2d/W2f S182D3=C] Kế hoạch ký kết HĐ (type 10, gồm cả 8 panel nhóm) chưa
// dùng tới một phần cờ quyền của khuôn Duyệt NCC. Gom về ĐÚNG MỘT cờ để 8 ô
// (7 ô quyền + ô Ngưỡng CEO) không mỗi chỗ tự viết lại điều kiện rồi lệch nhau.
const isKhkk = applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN
// Câu dùng chung cho các ô CHƯA có đường chạy ở KHKK. Ô nào có lý do RIÊNG thì
// viết riêng tại chỗ (xem ô "Trả về Drafter") — trạng thái tick vẫn là THẬT.
// 🔴 Câu nhắc phải gắn `title` trên <label>, TUYỆT ĐỐI KHÔNG trên <input disabled>:
// Chromium (Chrome/Edge — trình duyệt đích) nuốt sự kiện chuột của form-control
// disabled ⇒ tooltip KHÔNG bao giờ bật, admin chỉ còn thấy mấy ô xám vô cớ. Label
// không disabled nên rê vào chữ là hiện. Ai dời ngược lên input = làm câm lại.
const khkkNotYetHint = 'Chưa áp dụng cho KHKK'
// Ô "Trả về Drafter" (#4) khác 6 ô kia về NGHĨA, nên câu riêng — và câu này còn
// hiện thành chữ NHÌN THẤY ĐƯỢC ngay dưới ô: đây là thông tin PHÂN BIỆT #4 với 6 ô
// "chưa áp dụng", không được để nó phụ thuộc vào việc người dùng có rê chuột hay không.
// Một chuỗi dùng cho cả tooltip lẫn dòng chữ, để hai chỗ không trôi lệch nhau.
const khkkDrafterHint = 'KHKK hiện luôn trả về người soạn khi Trả lại — cờ chưa phân nhánh'
// [K3 S164 — GỠ KHOÁ · vá chữ W2h S182] Cờ `earlyFinalizeLocked` (W1 S161) ĐÃ BỎ HẲN
// và ô "Duyệt là KẾT THÚC" (#8) nay SỐNG cho mọi loại — BE đã port đường finalize ở K3
// cùng wave, owner chốt TP.CCM là người quyết lên CEO hay kết thúc.
// 🔴 Đính chính câu cũ ("2 ô duyệt sớm sống cho MỌI loại"): chỉ có MỘT trong hai ô đó
// sống cho KHKK. Ô còn lại — "Duyệt thẳng Cấp cuối" (#7) — vẫn KHOÁ cho type 10 từ K3
// S165 (service KHKK chưa port nhánh SkipToFinal, BE Create còn ép-false), xem ngay
// tại ô đó. Lý do đầy đủ ở khối ghi chú đầu file (gần `TYPE_INT_CONTRACT_SIGNING_PLAN`).
const initialSteps: EditStep[] = useMemo(
() => (source ? copyFromDefinition(source) : [makeEmptyStep(1)]),
[source],
)
const defaultCode = DEFAULT_CODE_BY_TYPE[applicableType] ?? 'QT-V2-001'
// Panel nhóm: mã đã khoá thì ô Mã phải hiện ĐÚNG mã sẽ gửi đi, không phải mã gợi ý
// chung của loại (`QT-KHKK-V2-001`) — hiện một đằng gửi một nẻo là kiểu bẫy tệ nhất.
const defaultCode = lockedCode ?? DEFAULT_CODE_BY_TYPE[applicableType] ?? 'QT-V2-001'
const [code, setCode] = useState(source?.code ?? defaultCode)
const [name, setName] = useState(source ? source.name : `Quy trình ${applicableTypeLabel}`)
const [description, setDescription] = useState(source?.description ?? '')
@ -795,11 +887,19 @@ function Designer({
// ============ ĐƯỜNG 2 — TẠO PHIÊN BẢN MỚI (POST, sinh Id mới) ============
await api.post('/approval-workflows-v2', {
applicableType,
code,
// [W2c S182] Panel nhóm: mã lấy từ prop khoá, KHÔNG lấy từ state ô nhập.
// Ô nhập đang disabled nên state không đổi được qua UI — nhưng gửi thẳng
// `lockedCode` là bất-biến theo CẤU TRÚC, không phụ thuộc kỷ luật của ô input.
code: lockedCode ?? 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, '')),
// [W2f S182] KHKK KHÔNG dùng ngưỡng máy (người được uỷ quyền quyết lên CEO hay
// không, qua ô "Duyệt là KẾT THÚC") ⇒ gửi thẳng null, không để giá trị lỡ tay
// nằm lại trong state rồi âm thầm bật một nhánh rẽ mà quy trình KHKK không chạy.
ceoApprovalThreshold: isKhkk
? null
: ceoThreshold.trim() === '' ? null : Number(ceoThreshold.replace(/[^\d]/g, '')),
steps: steps.map((s, i) => ({
order: i + 1,
name: s.name,
@ -924,12 +1024,16 @@ function Designer({
required
className="font-mono"
// Mã là khoá của chuỗi phiên bản ⇒ sửa tại chỗ KHÔNG đổi được (BE :521 → 409).
disabled={isEdit}
// [W2c S182] Panel nhóm KHKK cũng khoá: đổi mã ở đây = đẻ quy trình mồ côi
// (phiếu của nhóm vẫn đi tìm đúng `KHKK-N{n}`).
disabled={isEdit || lockedCode !== null}
/>
<div className="text-[11px] text-slate-400">
{isEdit
? 'Không đổi được khi sửa tại chỗ — mã là khoá của chuỗi phiên bản.'
: 'Vd QT-DN-V2-001. Version auto-tăng mỗi lần lưu.'}
: lockedCode !== null
? 'Mã khoá theo nhóm — version tự tăng.'
: 'Vd QT-DN-V2-001. Version auto-tăng mỗi lần lưu.'}
</div>
</div>
<div className="space-y-1.5">
@ -951,12 +1055,17 @@ function Designer({
// 🔴 Sửa tại chỗ KHÔNG đổi được ngưỡng: phiếu đang chờ sẽ đổi nhánh
// cần/không-cần CEO giữa chừng (BE :529 → 409). Khoá ô để người dùng
// không gõ vào rồi mới nhận lỗi. Giá trị gửi lên lấy thẳng từ bản đang lưu.
disabled={isEdit}
// [W2f S182] KHKK khoá VÔ ĐIỀU KIỆN (kể cả tạo mới): quy trình KHKK không
// rẽ nhánh theo số tiền — người được uỷ quyền quyết. Tiền lệ cùng cách làm
// là ô "Duyệt thẳng Cấp cuối" bên dưới.
disabled={isEdit || isKhkk}
/>
<div className="text-[11px] leading-relaxed text-slate-400">
{isEdit
? 'Không đổi được khi sửa tại chỗ — đổi ngưỡng làm phiếu đang chờ nhảy nhánh cần/không-cần CEO giữa chừng. Muốn đổi: tạo phiên bản mới.'
: 'Gói < ngưỡng: CCM duyệt là xong, không cần CEO. ≥ ngưỡng: phải lên CEO. Để trống = luôn theo đủ quy trình.'}
{isKhkk
? 'KHKK không dùng ngưỡng máy — NĐUQ quyết. Ai được kết thúc quy trình thì tick ô "Duyệt là KẾT THÚC" ở đúng NV đó bên dưới.'
: isEdit
? 'Không đổi được khi sửa tại chỗ — đổi ngưỡng làm phiếu đang chờ nhảy nhánh cần/không-cần CEO giữa chừng. Muốn đổi: tạo phiên bản mới.'
: 'Gói < ngưỡng: CCM duyệt là xong, không cần CEO. ≥ ngưỡng: phải lên CEO. Để trống = luôn theo đủ quy trình.'}
</div>
</div>
@ -984,6 +1093,17 @@ function Designer({
<div className="rounded-lg border border-violet-200 bg-violet-50/30 px-3 py-2 text-[11px] leading-relaxed text-violet-800">
Cấu hình quyền duyệt riêng cho từng NV trong slot Approver bên dưới
(Trả lại / Edit Section 2 / Edit Budget / Duyệt thẳng Cấp cuối).
{/* [W2d S182 — D3=C] Nói thẳng lý do các ô bị mờ, thay vì để admin tự đoán
là "mất quyền" hay "lỗi". Ô mờ vẫn hiện ĐÚNG giá trị đang lưu trong DB. */}
{isKhkk && (
<div className="mt-1 text-violet-900">
Riêng Kế hoạch kết : một số ô bị khoá luồng duyệt KHKK chưa dùng tới
(ô vẫn hiển thị đúng giá trị đang lưu, không bị đi). Hai ô đang tác dụng
thật <span className="font-semibold">&quot;Trả về Drafter&quot;</span> (KHKK luôn trả về
người soạn) <span className="font-semibold">&quot;Duyệt KẾT THÚC&quot;</span> tick cho NV
đưc uỷ quyền quyết dừng hay trình tiếp CEO.
</div>
)}
</div>
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
@ -1228,57 +1348,107 @@ function Designer({
<div className="mb-1 text-[10px] font-medium uppercase text-amber-700">
Quyền duyệt {usersList.data?.find(u => u.id === entry.approverUserId)?.fullName ?? 'Chưa chọn NV'}
</div>
{/* [W2d S182 — D3=C] Với KHKK (type 10, gồm 8 panel nhóm) các ô
dưới đây bị KHOÁ vì luồng KHKK chưa chạy tới chúng.
🔴 HAI điều KHÔNG được làm, vì đã có tiền lệ hỏng:
(1) KHÔNG ép `checked={false}`. Ô phải nói đúng thứ ĐANG NẰM
TRONG DB — bản seed có cờ nào bật thì phải thấy nó bật,
nếu không admin sẽ tưởng cờ đã tắt trong khi BE vẫn đọc.
(2) KHÔNG động vào payload (`levelFlagsPayload`): nó tiếp tục
echo NGUYÊN giá trị đang lưu. Khoá là chuyện MÀN HÌNH,
không phải chuyện dữ liệu — khoá bằng cách ghi đè dữ liệu
thì mở lại sau này không còn gì để mở.
Ô nào KHKK có chạy thì để nguyên: "Trả về Drafter" (hành vi thật
là LUÔN trả về người soạn) và "Duyệt là KẾT THÚC" (#8, sống). */}
<div className="grid grid-cols-2 gap-1">
<label className="flex items-center gap-1 text-[11px] text-slate-700">
<label
className={`flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-slate-700'}`}
title={isKhkk ? khkkNotYetHint : undefined}
>
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowReturnOneLevel}
disabled={isKhkk}
onChange={e => updateField('allowReturnOneLevel', e.target.checked)}
/>
<span>Trả về 1 Cấp trước</span>
</label>
<label className="flex items-center gap-1 text-[11px] text-slate-700">
<label
className={`flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-slate-700'}`}
title={isKhkk ? khkkNotYetHint : undefined}
>
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowReturnOneStep}
disabled={isKhkk}
onChange={e => updateField('allowReturnOneStep', e.target.checked)}
/>
<span>Trả về 1 Bước trước</span>
</label>
<label className="flex items-center gap-1 text-[11px] text-slate-700">
<label
className={`flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-slate-700'}`}
title={isKhkk ? khkkNotYetHint : undefined}
>
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowReturnToAssignee}
disabled={isKhkk}
onChange={e => updateField('allowReturnToAssignee', e.target.checked)}
/>
<span>Trả về Người chỉ đnh</span>
</label>
<label className="flex items-center gap-1 text-[11px] text-slate-700">
{/* [W2d S182] Ô này KHÁC 6 ô kia: KHKK có chạy nhánh trả-về-người-soạn,
và chạy KHÔNG ĐIỀU KIỆN — cờ chưa được phân nhánh ở service nên bỏ
tick cũng không tắt được. Vì vậy khoá nhưng KHÔNG làm mờ kiểu
"chưa có": mờ ở đây sẽ đọc thành "tính năng chưa chạy", ngược hẳn
sự thật. Câu nhắc viết riêng, không dùng câu chung. */}
<label
className="flex items-center gap-1 text-[11px] text-slate-700"
title={isKhkk ? khkkDrafterHint : undefined}
>
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowReturnToDrafter}
disabled={isKhkk}
onChange={e => updateField('allowReturnToDrafter', e.target.checked)}
/>
<span>Trả về Drafter (mặc đnh)</span>
</label>
<label className="col-span-2 flex items-center gap-1 text-[11px] text-slate-700">
{/* [W2d S182 — vá MAJOR-1] Câu này là thứ DUY NHẤT phân biệt ô trên
với 6 ô "chưa áp dụng" cùng khối, nên nó phải NHÌN THẤY ĐƯỢC chứ
không nằm sau một cú rê chuột. `col-start-2` để dòng chữ rơi đúng
dưới ô của nó (cột phải), không bị đọc nhầm sang ô bên trái. */}
{isKhkk && (
<div className="col-start-2 -mt-0.5 pl-4 text-[10px] leading-snug text-slate-500">
{khkkDrafterHint}
</div>
)}
<label
className={`col-span-2 flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-slate-700'}`}
title={isKhkk ? khkkNotYetHint : undefined}
>
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowApproverEditDetails}
disabled={isKhkk}
onChange={e => updateField('allowApproverEditDetails', e.target.checked)}
/>
<span>Cho phép chỉnh sửa Section 2 (Hạng mục/NCC/Báo giá) lúc đang duyệt</span>
</label>
<label className="col-span-2 flex items-center gap-1 text-[11px] text-slate-700">
<label
className={`col-span-2 flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-slate-700'}`}
title={isKhkk ? khkkNotYetHint : undefined}
>
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowApproverEditBudget}
disabled={isKhkk}
onChange={e => updateField('allowApproverEditBudget', e.target.checked)}
/>
<span>Cho phép chỉnh sửa Section ngân sách lúc đang duyệt</span>
@ -1289,13 +1459,20 @@ function Designer({
service KHKK CHƯA port nhánh SkipToFinal + BE CREATE vẫn ép-false cờ
này cho type-10 ⇒ ô này DISABLE cho KHKK kẻo tick bị nuốt im lặng
(class "UI nói một đằng DB một nẻo" — đúng thứ W1 S161 cảnh báo). */}
<label className={`col-span-2 flex items-center gap-1 text-[11px] ${applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN ? 'text-slate-400' : 'text-slate-700'}`}>
{/* [W2d S182] GIỮ NGUYÊN hành vi ô này (kể cả `checked` ép false —
khác 6 ô trên, đây là quyết định cũ của K3 S165 và BE Create
ĐANG THẬT SỰ ép-false cờ này cho type 10, nên màn hình đang
khớp DB). Chỉ đổi 3 biểu thức trùng lặp sang cờ dùng chung
`isKhkk` — giá trị y hệt, để 8 chỗ khoá không lệch nhau. */}
<label
className={`col-span-2 flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-slate-700'}`}
title={isKhkk ? 'Kế hoạch ký kết chưa hỗ trợ duyệt vượt cấp — service chưa port nhánh này (K3)' : undefined}
>
<input
type="checkbox"
className="h-3 w-3"
checked={applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN ? false : entry.allowApproverSkipToFinal}
disabled={applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN}
title={applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN ? 'Kế hoạch ký kết chưa hỗ trợ duyệt vượt cấp — service chưa port nhánh này (K3)' : undefined}
checked={isKhkk ? false : entry.allowApproverSkipToFinal}
disabled={isKhkk}
onChange={e => updateField('allowApproverSkipToFinal', e.target.checked)}
/>
<span>Cho phép duyệt thẳng Cấp cuối khi đang duyệt</span>
@ -1317,11 +1494,15 @@ function Designer({
per-Cấp: 1 dòng NV = 1 ô tick riêng. Tick cho NV nào thì
CHỈ NV đó xóa được, và chỉ khi phiếu đang Chờ duyệt ĐÚNG
lượt của họ. Cấp có 3 NV mà tick 1 ⇒ vẫn chỉ 1 người xóa. */}
<label className="col-span-2 flex items-center gap-1 text-[11px] text-rose-700">
<label
className={`col-span-2 flex items-center gap-1 text-[11px] ${isKhkk ? 'text-slate-400' : 'text-rose-700'}`}
title={isKhkk ? khkkNotYetHint : undefined}
>
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowApproverDelete}
disabled={isKhkk}
onChange={e => updateField('allowApproverDelete', e.target.checked)}
/>
<span>Cho phép NV này XÓA phiếu khi đến lượt duyệt của mình</span>