[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
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:
@ -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)
|
||||
|
||||
@ -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 mã <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ự dùng quy trình của nhóm khác.
|
||||
⚠ Chưa dùng được quy trình mã <strong>{wantWorkflowCode}</strong>: phải có
|
||||
phiên bản <strong>đang áp dụng</strong> và <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">
|
||||
|
||||
@ -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 S164 — GỠ 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 S182 — D3=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ý kết HĐ: một số ô bị khoá vì 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 có tác dụng
|
||||
thật là <span className="font-semibold">"Trả về Drafter"</span> (KHKK luôn trả về
|
||||
người soạn) và <span className="font-semibold">"Duyệt là KẾT THÚC"</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>
|
||||
|
||||
Reference in New Issue
Block a user