diff --git a/fe-admin/src/components/khkk/KhkkDetailContent.tsx b/fe-admin/src/components/khkk/KhkkDetailContent.tsx index 81a0f3d..67fb9b6 100644 --- a/fe-admin/src/components/khkk/KhkkDetailContent.tsx +++ b/fe-admin/src/components/khkk/KhkkDetailContent.tsx @@ -21,7 +21,7 @@ import { useState, type ReactNode } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { - Check, ClipboardList, FileCheck, MessageSquare, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users, + ClipboardList, FileCheck, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users, } from 'lucide-react' import { toast } from 'sonner' import { PePipelineStrip } from '@/components/pe/PePipelineStrip' @@ -255,9 +255,10 @@ export function KhkkDetailContent({ // File "đính kèm khi duyệt" thuộc panel 3 (mục 5) — Section 4 chỉ liệt kê hồ-sơ của phiếu. const planAttachments = plan.attachments.filter(a => a.purpose !== KhkkAttachmentPurpose.ApprovalAttachment) - // ── Section 5: ý-kiến cấp duyệt, gom theo BƯỚC (Phòng) → CẤP → NV ────────────────── - const opinions = plan.levelOpinions ?? [] - const steps = plan.workflowSteps ?? [] + // 🧊 [S174 owner] Section 5 "Ý kiến cấp duyệt" ĐÃ GỠ khỏi panel giữa — anh: "bỏ luôn cái + // này ra luôn". Sơ-đồ Bước/Cấp + chữ ký đã sống ở PANEL 3 (`KhkkWorkflowPanel`), để cả hai + // là kể một chuyện hai lần trên cùng màn hình. Dữ-liệu `plan.levelOpinions` / + // `plan.workflowSteps` VẪN có trong DTO (panel 3 dùng) — chỉ bỏ chỗ VẼ ở đây. return (
@@ -569,112 +570,6 @@ export function KhkkDetailContent({ )} - - {/* ─────────────── 5 — Ảnh 3 khoanh đỏ ─────────────── - Gom theo BƯỚC (kèm PHÒNG) → lưới 2 cột các ô ý-kiến của từng CẤP, đúng khuôn - Section "Ý kiến cấp duyệt" V2 của Duyệt NCC (`PeDetailTabs.tsx:705-774`): - forEach Bước → forEach Cấp → 1 ô. Ô chưa ký vẫn HIỆN (chỗ trống có tên người) — - vắng ô là người đọc không biết còn thiếu chữ ký của ai. */} -
} - accent="var(--color-violet-500)" - head="text-violet-700" - chipBg="var(--color-violet-50)" - chipFg="var(--color-violet-700)" - count={opinions.length} - > - {steps.length === 0 ? ( -
- Phiếu chưa pin quy trình duyệt — chưa có cấp nào để ghi ý kiến. -
- ) : ( -
- {steps.map((step) => { - const signedCount = step.levels.filter(lv => - opinions.some(o => o.approvalWorkflowLevelId === lv.id)).length - return ( -
-
- - Bước {step.order} — {step.name} - - {step.departmentName && ( - - {step.departmentName} - - )} - - {signedCount}/{step.levels.length} cấp đã duyệt · {step.levels.length} NV tham gia - -
-
- {step.levels.length === 0 ? ( -
— Bước này chưa cấu hình cấp duyệt.
- ) : ( -
- {step.levels.map((lv) => { - const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id) - return ( -
-
-
-

- {lv.name || `Cấp ${lv.order}`} —{' '} - {lv.approverFullName ?? '(chưa cấu hình)'} -

- {/* Không dùng `signed!` — thu hẹp bằng chính `signed &&` để - khỏi mượn null-forgiving trên dữ-liệu từ API (gotcha #80). */} - {signed && signed.approverUserId - && signed.approverUserId !== signed.signedByUserId && ( -
- ⚠ Admin {signed.signedByFullName} duyệt thay -
- )} -
- {signed ? ( - - Đã duyệt - - ) : ( - - Chưa ký - - )} -
- {signed ? ( - <> -
- {signed.comment?.trim() || '(duyệt — không ý kiến)'} -
-
- {formatDateTime(signed.signedAt)} -
- - ) : ( -
Chưa có ý kiến duyệt.
- )} -
- ) - })} -
- )} -
-
- ) - })} -
- )} -
{/* Dialog thêm/sửa căn cứ */} diff --git a/fe-admin/src/components/khkk/KhkkWorkListPanel.tsx b/fe-admin/src/components/khkk/KhkkWorkListPanel.tsx index d918fdf..e3a4c8f 100644 --- a/fe-admin/src/components/khkk/KhkkWorkListPanel.tsx +++ b/fe-admin/src/components/khkk/KhkkWorkListPanel.tsx @@ -31,6 +31,7 @@ import { cn } from '@/lib/cn' import { KHKK_PHASE_BADGE, KHKK_PHASE_LABELS, + KhkkPhase, type KhkkListItemDto, type KhkkPeCandidateDto, type PagedResult, @@ -78,7 +79,6 @@ export function KhkkWorkListPanel({ onPickPlan, selectedPeId, selectedPlanId, - footer, }: { /** Bấm PE ở tầng trên → host nạp phiếu đó vào form tạo (Ở LẠI màn, không điều hướng). */ onPickPe: (peId: string) => void @@ -87,8 +87,6 @@ export function KhkkWorkListPanel({ onPickPlan: (planId: string) => void selectedPeId?: string | null selectedPlanId?: string | null - /** Nút "+ Thêm mới" dính đáy — mirror `PeListPanel` của màn Duyệt NCC — Thao tác. */ - footer?: React.ReactNode }) { const { can } = usePermission() // 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị). @@ -126,7 +124,14 @@ export function KhkkWorkListPanel({ else byProject.set(key, [pe]) } - const planItems = plans.data?.items ?? [] + // [S174 owner] "đã duyệt bỏ đi luôn" — tầng dưới chỉ giữ kế hoạch CHƯA xong việc + // (nháp / chờ duyệt / trả lại / từ chối). Phiếu `DaDuyet` đã hết việc ở màn Thao tác. + // 🔴 KHAI GIỚI-HẠN (lọc ở FE): endpoint chỉ nhận `phase` ĐƠN, không có tham số "loại trừ" + // ⇒ (1) `plans.data.total` của server ĐÃ GỒM phiếu DaDuyet nên KHÔNG được dùng làm số + // hiển thị — đếm từ chính mảng đã lọc; (2) cửa-sổ 30 phiếu mới nhất có thể TOÀN DaDuyet, + // khi đó tầng dưới rỗng dù còn phiếu nháp cũ hơn. Chấp nhận ở UAT; muốn chuẩn thì BE thêm + // tham số loại-trừ rồi lọc SERVER-SIDE. + const planItems = (plans.data?.items ?? []).filter((k) => k.phase !== KhkkPhase.DaDuyet) return ( ) } diff --git a/fe-admin/src/pages/khkk/KhkkCreatePage.tsx b/fe-admin/src/pages/khkk/KhkkCreatePage.tsx index 7b04bfd..b893b22 100644 --- a/fe-admin/src/pages/khkk/KhkkCreatePage.tsx +++ b/fe-admin/src/pages/khkk/KhkkCreatePage.tsx @@ -23,19 +23,17 @@ import { useState, type FormEvent, type ReactNode } from 'react' import { useMutation, useQuery } from '@tanstack/react-query' import { useNavigate, useSearchParams } from 'react-router-dom' -import { FileCheck, Plus, X } from 'lucide-react' +import { FileCheck, X } from 'lucide-react' import { toast } from 'sonner' import { EmptyState } from '@/components/EmptyState' import { Button } from '@/components/ui/Button' import { Label } from '@/components/ui/Label' -import { Select } from '@/components/ui/Select' import { Textarea } from '@/components/ui/Textarea' import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent' import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel' import { api } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' import { - KHKK_APPROVAL_GROUPS, KHKK_APPROVAL_GROUP_DEFAULT, KHKK_APPROVAL_GROUP_LABELS, khkkGroupMenuLabel, @@ -55,11 +53,9 @@ interface WorkflowDto { isUserSelectable: boolean } -// Nhóm trống ĐẦU TIÊN của một PE (null = đã dùng hết 8 nhóm) — để ô chọn nhóm không đứng -// yên ở một nhóm chắc chắn 409. -function firstFreeGroup(taken: number[]): number | null { - return KHKK_APPROVAL_GROUPS.find((g) => !taken.includes(g)) ?? null -} +// 🧊 [S174] `firstFreeGroup()` ĐÃ GỠ cùng lúc bỏ ô chọn nhóm — nó chỉ tồn tại để tự nhảy +// sang nhóm trống, đúng hành-vi anh vừa cấm ("gán cứng theo từng nhóm, ko cho User điều +// chỉnh"). Giữ lại thành hàm chết thì lượt sau có người gọi lại. export function KhkkCreatePage() { const navigate = useNavigate() @@ -69,21 +65,17 @@ export function KhkkCreatePage() { const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('') - const [approvalWorkflowId, setApprovalWorkflowId] = useState('') - const [approvalGroup, setApprovalGroup] = useState(presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT) const [ghiChu, setGhiChu] = useState('') + + // [S174 owner] "gán cứng luôn theo từng nhóm, ko cho User điều chỉnh chỗ này luôn" + // ⇒ nhóm duyệt KHÔNG còn là state: DẪN XUẤT thẳng từ leaf menu (`?group=n`). Không còn + // state thì không thể lệch, nên khối đồng-bộ-preset cũ (`lastPresetGroup`) cũng bỏ luôn — + // đó là thứ chỉ tồn tại để vá đúng cái lệch này. + const approvalGroup = presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT // [S174] Phiếu KHKK đang XEM ở panel 2. Có giá trị ⇒ panel 2 hiện phiếu (read-only) thay // cho form tạo. `null` ⇒ về form tạo. Đây là thứ thay cho việc `navigate('/khkk/:id')`. const [viewPlanId, setViewPlanId] = useState(null) - // Đổi leaf nhóm ⇒ đồng bộ ô chọn nhóm. CHỈ đổi khi CHÍNH THAM SỐ URL đổi — so với - // `approvalGroup` thì người dùng tự chọn nhóm khác sẽ bị kéo ngược về nhóm của URL. - const [lastPresetGroup, setLastPresetGroup] = useState(presetGroup) - if (presetGroup !== lastPresetGroup) { - setLastPresetGroup(presetGroup) - if (presetGroup !== null) setApprovalGroup(presetGroup) - } - // Cùng query key với `KhkkWorkListPanel` ⇒ dùng CHUNG cache, không gọi mạng 2 lần. const candidates = useQuery({ queryKey: ['khkk-pe-candidates'], @@ -103,6 +95,16 @@ export function KhkkCreatePage() { }, }) + // [S174 owner] Quy trình duyệt cũng GÁN CỨNG theo nhóm — mỗi nhóm đúng MỘT quy trình. + // 🔴 Chuỗi mã lấy từ NGUỒN BE, không suy từ màn hình: `DbInitializer.cs:609` + // `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. + const wantWorkflowCode = `KHKK-N${approvalGroup}` + const autoWorkflow = workflows.data?.find( + (w) => (w.code ?? '').trim().toUpperCase() === wantWorkflowCode, + ) ?? null + // Phiếu đang XEM — chỉ tải khi thật sự có cái để xem. const viewPlan = useQuery({ queryKey: ['khkk-detail', viewPlanId], @@ -113,10 +115,10 @@ 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 (!approvalWorkflowId) throw new Error('Vui lòng chọn Quy trình duyệt') + if (!autoWorkflow) throw new Error(`Chưa có quy trình duyệt ${wantWorkflowCode}`) const body: CreateKhkkInput = { peId: purchaseEvaluationId, // [F-2] BE Command field = PeId - approvalWorkflowId, + approvalWorkflowId: autoWorkflow.id, ghiChu: ghiChu.trim() || null, approvalGroup, // [K2] gửi TƯỜNG MINH, không dựa default ngầm của BE } @@ -127,7 +129,6 @@ export function KhkkCreatePage() { onSuccess: (data) => { toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết') setPurchaseEvaluationId('') - setApprovalWorkflowId('') setGhiChu('') setViewPlanId(data.id) }, @@ -141,21 +142,17 @@ export function KhkkCreatePage() { const missingCreate = [ !purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn', - !approvalWorkflowId && 'Quy trình duyệt', - groupTaken && `Nhóm duyệt còn trống (N${approvalGroup} đã có kế hoạch)`, + !workflows.isLoading && !autoWorkflow && `Quy trình ${wantWorkflowCode} (chưa cấu hình)`, + groupTaken && `Phiếu này đã có kế hoạch nhóm N${approvalGroup}`, ].filter(Boolean) as string[] - const canSubmit = missingCreate.length === 0 && !create.isPending + const canSubmit = missingCreate.length === 0 && !!autoWorkflow && !create.isPending - // Bấm PE ở panel 1: nạp vào form + rời chế-độ xem. Nhóm đang chọn đã bị PE đó dùng thì - // nhảy sang nhóm trống đầu tiên ⇒ người dùng không phải tự dò. + // [S174] Bấm PE ở panel 1 = nạp vào form + rời chế-độ xem. 🧊 Khối "nhảy sang nhóm trống + // đầu tiên" ĐÃ BỎ: nhóm nay gán cứng theo leaf menu, tự nhảy nhóm = đúng thứ anh vừa cấm. + // PE đã có kế hoạch ở nhóm này ⇒ `groupTaken` chặn + nói rõ, người dùng tự sang leaf khác. const pickPe = (peId: string) => { setPurchaseEvaluationId(peId) setViewPlanId(null) - const taken = list.find((p) => p.id === peId)?.existingGroups ?? [] - if (taken.includes(approvalGroup)) { - const free = firstFreeGroup(taken) - if (free !== null) setApprovalGroup(free) - } } const onSubmit = (e: FormEvent) => { @@ -185,22 +182,6 @@ export function KhkkCreatePage() { onPickPlan={setViewPlanId} selectedPeId={purchaseEvaluationId || null} selectedPlanId={viewPlanId} - footer={ - - } />
@@ -294,55 +275,59 @@ export function KhkkCreatePage() { -
+ {/* [S174 owner] "gán cứng luôn theo từng nhóm, ko cho User điều chỉnh chỗ này + luôn" ⇒ CẢ HAI ô thành GIÁ-TRỊ CỐ ĐỊNH, không còn `Select`. Cố ý KHÔNG + dùng ` setApprovalGroup(Number(e.target.value))} - > - {KHKK_APPROVAL_GROUPS.map((g) => { - const used = takenGroups.includes(g) - return ( - - ) - })} - + +
+ + N{approvalGroup} + + + {KHKK_APPROVAL_GROUP_LABELS[approvalGroup] ?? `Nhóm ${approvalGroup}`} + +
{groupTaken ? (

- Phiếu này đã có kế hoạch cho nhóm N{approvalGroup} — chọn nhóm khác. + Phiếu này đã có kế hoạch cho nhóm N{approvalGroup} — chọn phiếu khác, + hoặc sang leaf nhóm khác ở menu bên trái.

) : (

- Mỗi phiếu Duyệt NCC lập được 1 kế hoạch cho MỖI nhóm duyệt + Lấy theo mục đang mở ở menu — đổi nhóm bằng cách sang mục khác

)}
- - - {workflows.data && workflows.data.length === 0 ? ( + +
+ {workflows.isLoading ? ( + Đang tải… + ) : autoWorkflow ? ( + + {autoWorkflow.code} + · + {autoWorkflow.name} + + ) : ( + Không tìm thấy {wantWorkflowCode} + )} +
+ {!workflows.isLoading && !autoWorkflow ? (

- ⚠ Chưa có quy trình duyệt "Kế hoạch ký kết HĐ" nào được ghim cho user chọn. Liên hệ admin tạo trước. + ⚠ Chưa có quy trình mã {wantWorkflowCode} (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ỉ hiển thị quy trình admin đã ghim cho user chọn + Mỗi nhóm duyệt gắn đúng một quy trình — hệ thống tự chọn theo nhóm

)}
@@ -380,7 +365,7 @@ export function KhkkCreatePage() { type="button" variant="ghost" className="text-xs" - onClick={() => { setPurchaseEvaluationId(''); setApprovalWorkflowId(''); setGhiChu('') }} + onClick={() => { setPurchaseEvaluationId(''); setGhiChu('') }} > Hủy diff --git a/fe-user/src/components/khkk/KhkkDetailContent.tsx b/fe-user/src/components/khkk/KhkkDetailContent.tsx index 81a0f3d..67fb9b6 100644 --- a/fe-user/src/components/khkk/KhkkDetailContent.tsx +++ b/fe-user/src/components/khkk/KhkkDetailContent.tsx @@ -21,7 +21,7 @@ import { useState, type ReactNode } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { - Check, ClipboardList, FileCheck, MessageSquare, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users, + ClipboardList, FileCheck, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users, } from 'lucide-react' import { toast } from 'sonner' import { PePipelineStrip } from '@/components/pe/PePipelineStrip' @@ -255,9 +255,10 @@ export function KhkkDetailContent({ // File "đính kèm khi duyệt" thuộc panel 3 (mục 5) — Section 4 chỉ liệt kê hồ-sơ của phiếu. const planAttachments = plan.attachments.filter(a => a.purpose !== KhkkAttachmentPurpose.ApprovalAttachment) - // ── Section 5: ý-kiến cấp duyệt, gom theo BƯỚC (Phòng) → CẤP → NV ────────────────── - const opinions = plan.levelOpinions ?? [] - const steps = plan.workflowSteps ?? [] + // 🧊 [S174 owner] Section 5 "Ý kiến cấp duyệt" ĐÃ GỠ khỏi panel giữa — anh: "bỏ luôn cái + // này ra luôn". Sơ-đồ Bước/Cấp + chữ ký đã sống ở PANEL 3 (`KhkkWorkflowPanel`), để cả hai + // là kể một chuyện hai lần trên cùng màn hình. Dữ-liệu `plan.levelOpinions` / + // `plan.workflowSteps` VẪN có trong DTO (panel 3 dùng) — chỉ bỏ chỗ VẼ ở đây. return (
@@ -569,112 +570,6 @@ export function KhkkDetailContent({ )}
- - {/* ─────────────── 5 — Ảnh 3 khoanh đỏ ─────────────── - Gom theo BƯỚC (kèm PHÒNG) → lưới 2 cột các ô ý-kiến của từng CẤP, đúng khuôn - Section "Ý kiến cấp duyệt" V2 của Duyệt NCC (`PeDetailTabs.tsx:705-774`): - forEach Bước → forEach Cấp → 1 ô. Ô chưa ký vẫn HIỆN (chỗ trống có tên người) — - vắng ô là người đọc không biết còn thiếu chữ ký của ai. */} -
} - accent="var(--color-violet-500)" - head="text-violet-700" - chipBg="var(--color-violet-50)" - chipFg="var(--color-violet-700)" - count={opinions.length} - > - {steps.length === 0 ? ( -
- Phiếu chưa pin quy trình duyệt — chưa có cấp nào để ghi ý kiến. -
- ) : ( -
- {steps.map((step) => { - const signedCount = step.levels.filter(lv => - opinions.some(o => o.approvalWorkflowLevelId === lv.id)).length - return ( -
-
- - Bước {step.order} — {step.name} - - {step.departmentName && ( - - {step.departmentName} - - )} - - {signedCount}/{step.levels.length} cấp đã duyệt · {step.levels.length} NV tham gia - -
-
- {step.levels.length === 0 ? ( -
— Bước này chưa cấu hình cấp duyệt.
- ) : ( -
- {step.levels.map((lv) => { - const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id) - return ( -
-
-
-

- {lv.name || `Cấp ${lv.order}`} —{' '} - {lv.approverFullName ?? '(chưa cấu hình)'} -

- {/* Không dùng `signed!` — thu hẹp bằng chính `signed &&` để - khỏi mượn null-forgiving trên dữ-liệu từ API (gotcha #80). */} - {signed && signed.approverUserId - && signed.approverUserId !== signed.signedByUserId && ( -
- ⚠ Admin {signed.signedByFullName} duyệt thay -
- )} -
- {signed ? ( - - Đã duyệt - - ) : ( - - Chưa ký - - )} -
- {signed ? ( - <> -
- {signed.comment?.trim() || '(duyệt — không ý kiến)'} -
-
- {formatDateTime(signed.signedAt)} -
- - ) : ( -
Chưa có ý kiến duyệt.
- )} -
- ) - })} -
- )} -
-
- ) - })} -
- )} -
{/* Dialog thêm/sửa căn cứ */} diff --git a/fe-user/src/components/khkk/KhkkWorkListPanel.tsx b/fe-user/src/components/khkk/KhkkWorkListPanel.tsx index d918fdf..e3a4c8f 100644 --- a/fe-user/src/components/khkk/KhkkWorkListPanel.tsx +++ b/fe-user/src/components/khkk/KhkkWorkListPanel.tsx @@ -31,6 +31,7 @@ import { cn } from '@/lib/cn' import { KHKK_PHASE_BADGE, KHKK_PHASE_LABELS, + KhkkPhase, type KhkkListItemDto, type KhkkPeCandidateDto, type PagedResult, @@ -78,7 +79,6 @@ export function KhkkWorkListPanel({ onPickPlan, selectedPeId, selectedPlanId, - footer, }: { /** Bấm PE ở tầng trên → host nạp phiếu đó vào form tạo (Ở LẠI màn, không điều hướng). */ onPickPe: (peId: string) => void @@ -87,8 +87,6 @@ export function KhkkWorkListPanel({ onPickPlan: (planId: string) => void selectedPeId?: string | null selectedPlanId?: string | null - /** Nút "+ Thêm mới" dính đáy — mirror `PeListPanel` của màn Duyệt NCC — Thao tác. */ - footer?: React.ReactNode }) { const { can } = usePermission() // 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị). @@ -126,7 +124,14 @@ export function KhkkWorkListPanel({ else byProject.set(key, [pe]) } - const planItems = plans.data?.items ?? [] + // [S174 owner] "đã duyệt bỏ đi luôn" — tầng dưới chỉ giữ kế hoạch CHƯA xong việc + // (nháp / chờ duyệt / trả lại / từ chối). Phiếu `DaDuyet` đã hết việc ở màn Thao tác. + // 🔴 KHAI GIỚI-HẠN (lọc ở FE): endpoint chỉ nhận `phase` ĐƠN, không có tham số "loại trừ" + // ⇒ (1) `plans.data.total` của server ĐÃ GỒM phiếu DaDuyet nên KHÔNG được dùng làm số + // hiển thị — đếm từ chính mảng đã lọc; (2) cửa-sổ 30 phiếu mới nhất có thể TOÀN DaDuyet, + // khi đó tầng dưới rỗng dù còn phiếu nháp cũ hơn. Chấp nhận ở UAT; muốn chuẩn thì BE thêm + // tham số loại-trừ rồi lọc SERVER-SIDE. + const planItems = (plans.data?.items ?? []).filter((k) => k.phase !== KhkkPhase.DaDuyet) return ( ) } diff --git a/fe-user/src/pages/InboxPage.tsx b/fe-user/src/pages/InboxPage.tsx index a675c7b..6474c55 100644 --- a/fe-user/src/pages/InboxPage.tsx +++ b/fe-user/src/pages/InboxPage.tsx @@ -10,7 +10,6 @@ import { useNavigate, useSearchParams } from 'react-router-dom' import { Inbox, AlertTriangle, Clock, FileText, Search, ClipboardList } from 'lucide-react' import { ContractDetailContent } from '@/components/contracts/ContractDetailContent' import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel' -import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel' import { PhaseBadge } from '@/components/PhaseBadge' import { SlaTimer } from '@/components/SlaTimer' import { EmptyState } from '@/components/EmptyState' @@ -339,13 +338,19 @@ export function InboxPage() { {/* Panel 3 — Workflow + history */} diff --git a/fe-user/src/pages/khkk/KhkkCreatePage.tsx b/fe-user/src/pages/khkk/KhkkCreatePage.tsx index 7b04bfd..b893b22 100644 --- a/fe-user/src/pages/khkk/KhkkCreatePage.tsx +++ b/fe-user/src/pages/khkk/KhkkCreatePage.tsx @@ -23,19 +23,17 @@ import { useState, type FormEvent, type ReactNode } from 'react' import { useMutation, useQuery } from '@tanstack/react-query' import { useNavigate, useSearchParams } from 'react-router-dom' -import { FileCheck, Plus, X } from 'lucide-react' +import { FileCheck, X } from 'lucide-react' import { toast } from 'sonner' import { EmptyState } from '@/components/EmptyState' import { Button } from '@/components/ui/Button' import { Label } from '@/components/ui/Label' -import { Select } from '@/components/ui/Select' import { Textarea } from '@/components/ui/Textarea' import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent' import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel' import { api } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' import { - KHKK_APPROVAL_GROUPS, KHKK_APPROVAL_GROUP_DEFAULT, KHKK_APPROVAL_GROUP_LABELS, khkkGroupMenuLabel, @@ -55,11 +53,9 @@ interface WorkflowDto { isUserSelectable: boolean } -// Nhóm trống ĐẦU TIÊN của một PE (null = đã dùng hết 8 nhóm) — để ô chọn nhóm không đứng -// yên ở một nhóm chắc chắn 409. -function firstFreeGroup(taken: number[]): number | null { - return KHKK_APPROVAL_GROUPS.find((g) => !taken.includes(g)) ?? null -} +// 🧊 [S174] `firstFreeGroup()` ĐÃ GỠ cùng lúc bỏ ô chọn nhóm — nó chỉ tồn tại để tự nhảy +// sang nhóm trống, đúng hành-vi anh vừa cấm ("gán cứng theo từng nhóm, ko cho User điều +// chỉnh"). Giữ lại thành hàm chết thì lượt sau có người gọi lại. export function KhkkCreatePage() { const navigate = useNavigate() @@ -69,21 +65,17 @@ export function KhkkCreatePage() { const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('') - const [approvalWorkflowId, setApprovalWorkflowId] = useState('') - const [approvalGroup, setApprovalGroup] = useState(presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT) const [ghiChu, setGhiChu] = useState('') + + // [S174 owner] "gán cứng luôn theo từng nhóm, ko cho User điều chỉnh chỗ này luôn" + // ⇒ nhóm duyệt KHÔNG còn là state: DẪN XUẤT thẳng từ leaf menu (`?group=n`). Không còn + // state thì không thể lệch, nên khối đồng-bộ-preset cũ (`lastPresetGroup`) cũng bỏ luôn — + // đó là thứ chỉ tồn tại để vá đúng cái lệch này. + const approvalGroup = presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT // [S174] Phiếu KHKK đang XEM ở panel 2. Có giá trị ⇒ panel 2 hiện phiếu (read-only) thay // cho form tạo. `null` ⇒ về form tạo. Đây là thứ thay cho việc `navigate('/khkk/:id')`. const [viewPlanId, setViewPlanId] = useState(null) - // Đổi leaf nhóm ⇒ đồng bộ ô chọn nhóm. CHỈ đổi khi CHÍNH THAM SỐ URL đổi — so với - // `approvalGroup` thì người dùng tự chọn nhóm khác sẽ bị kéo ngược về nhóm của URL. - const [lastPresetGroup, setLastPresetGroup] = useState(presetGroup) - if (presetGroup !== lastPresetGroup) { - setLastPresetGroup(presetGroup) - if (presetGroup !== null) setApprovalGroup(presetGroup) - } - // Cùng query key với `KhkkWorkListPanel` ⇒ dùng CHUNG cache, không gọi mạng 2 lần. const candidates = useQuery({ queryKey: ['khkk-pe-candidates'], @@ -103,6 +95,16 @@ export function KhkkCreatePage() { }, }) + // [S174 owner] Quy trình duyệt cũng GÁN CỨNG theo nhóm — mỗi nhóm đúng MỘT quy trình. + // 🔴 Chuỗi mã lấy từ NGUỒN BE, không suy từ màn hình: `DbInitializer.cs:609` + // `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. + const wantWorkflowCode = `KHKK-N${approvalGroup}` + const autoWorkflow = workflows.data?.find( + (w) => (w.code ?? '').trim().toUpperCase() === wantWorkflowCode, + ) ?? null + // Phiếu đang XEM — chỉ tải khi thật sự có cái để xem. const viewPlan = useQuery({ queryKey: ['khkk-detail', viewPlanId], @@ -113,10 +115,10 @@ 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 (!approvalWorkflowId) throw new Error('Vui lòng chọn Quy trình duyệt') + if (!autoWorkflow) throw new Error(`Chưa có quy trình duyệt ${wantWorkflowCode}`) const body: CreateKhkkInput = { peId: purchaseEvaluationId, // [F-2] BE Command field = PeId - approvalWorkflowId, + approvalWorkflowId: autoWorkflow.id, ghiChu: ghiChu.trim() || null, approvalGroup, // [K2] gửi TƯỜNG MINH, không dựa default ngầm của BE } @@ -127,7 +129,6 @@ export function KhkkCreatePage() { onSuccess: (data) => { toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết') setPurchaseEvaluationId('') - setApprovalWorkflowId('') setGhiChu('') setViewPlanId(data.id) }, @@ -141,21 +142,17 @@ export function KhkkCreatePage() { const missingCreate = [ !purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn', - !approvalWorkflowId && 'Quy trình duyệt', - groupTaken && `Nhóm duyệt còn trống (N${approvalGroup} đã có kế hoạch)`, + !workflows.isLoading && !autoWorkflow && `Quy trình ${wantWorkflowCode} (chưa cấu hình)`, + groupTaken && `Phiếu này đã có kế hoạch nhóm N${approvalGroup}`, ].filter(Boolean) as string[] - const canSubmit = missingCreate.length === 0 && !create.isPending + const canSubmit = missingCreate.length === 0 && !!autoWorkflow && !create.isPending - // Bấm PE ở panel 1: nạp vào form + rời chế-độ xem. Nhóm đang chọn đã bị PE đó dùng thì - // nhảy sang nhóm trống đầu tiên ⇒ người dùng không phải tự dò. + // [S174] Bấm PE ở panel 1 = nạp vào form + rời chế-độ xem. 🧊 Khối "nhảy sang nhóm trống + // đầu tiên" ĐÃ BỎ: nhóm nay gán cứng theo leaf menu, tự nhảy nhóm = đúng thứ anh vừa cấm. + // PE đã có kế hoạch ở nhóm này ⇒ `groupTaken` chặn + nói rõ, người dùng tự sang leaf khác. const pickPe = (peId: string) => { setPurchaseEvaluationId(peId) setViewPlanId(null) - const taken = list.find((p) => p.id === peId)?.existingGroups ?? [] - if (taken.includes(approvalGroup)) { - const free = firstFreeGroup(taken) - if (free !== null) setApprovalGroup(free) - } } const onSubmit = (e: FormEvent) => { @@ -185,22 +182,6 @@ export function KhkkCreatePage() { onPickPlan={setViewPlanId} selectedPeId={purchaseEvaluationId || null} selectedPlanId={viewPlanId} - footer={ - - } />
@@ -294,55 +275,59 @@ export function KhkkCreatePage() { -
+ {/* [S174 owner] "gán cứng luôn theo từng nhóm, ko cho User điều chỉnh chỗ này + luôn" ⇒ CẢ HAI ô thành GIÁ-TRỊ CỐ ĐỊNH, không còn `Select`. Cố ý KHÔNG + dùng ` setApprovalGroup(Number(e.target.value))} - > - {KHKK_APPROVAL_GROUPS.map((g) => { - const used = takenGroups.includes(g) - return ( - - ) - })} - + +
+ + N{approvalGroup} + + + {KHKK_APPROVAL_GROUP_LABELS[approvalGroup] ?? `Nhóm ${approvalGroup}`} + +
{groupTaken ? (

- Phiếu này đã có kế hoạch cho nhóm N{approvalGroup} — chọn nhóm khác. + Phiếu này đã có kế hoạch cho nhóm N{approvalGroup} — chọn phiếu khác, + hoặc sang leaf nhóm khác ở menu bên trái.

) : (

- Mỗi phiếu Duyệt NCC lập được 1 kế hoạch cho MỖI nhóm duyệt + Lấy theo mục đang mở ở menu — đổi nhóm bằng cách sang mục khác

)}
- - - {workflows.data && workflows.data.length === 0 ? ( + +
+ {workflows.isLoading ? ( + Đang tải… + ) : autoWorkflow ? ( + + {autoWorkflow.code} + · + {autoWorkflow.name} + + ) : ( + Không tìm thấy {wantWorkflowCode} + )} +
+ {!workflows.isLoading && !autoWorkflow ? (

- ⚠ Chưa có quy trình duyệt "Kế hoạch ký kết HĐ" nào được ghim cho user chọn. Liên hệ admin tạo trước. + ⚠ Chưa có quy trình mã {wantWorkflowCode} (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ỉ hiển thị quy trình admin đã ghim cho user chọn + Mỗi nhóm duyệt gắn đúng một quy trình — hệ thống tự chọn theo nhóm

)}
@@ -380,7 +365,7 @@ export function KhkkCreatePage() { type="button" variant="ghost" className="text-xs" - onClick={() => { setPurchaseEvaluationId(''); setApprovalWorkflowId(''); setGhiChu('') }} + onClick={() => { setPurchaseEvaluationId(''); setGhiChu('') }} > Hủy