[CLAUDE] Contract: UAT S174 vong-3 — 5 chinh KHKK theo anh chot (FE-only, 0-mig)

1. Ho thu: GO `PipelineTreePanel` khoi `/inbox` — anh: "cho hop thu vut panel nay vao lam j,
   tap trung vao KHKK thoi". Ho thu = hang-doi-viec-cua-toi, cay toan trinh = ban-do-toan-he
   => sai the loai. Cho trong nay tra ve dung vai: cho quy trinh duyet cua HD dang chon.
   🔸 HAI trang GD3/GD4 con lai (`MyContractsPage` · `HardCopiesPage`) GIU NGUYEN — anh chi
   dich danh Ho thu, khong noi go het; khong tu suy rong. (Lineage: panel vao day @S168
   GD3-D theo owner slot (63) + "mo rong = ca 3 trang"; nay owner thu lai DUNG trang nay.)

2. Panel KHKK: BO nut "+ Them moi". Man nay KHONG co duong "tao tu so 0" — ke hoach LUON
   sinh tu mot phieu Duyet NCC, ma cac phieu du dieu kien da nam nguyen o tang tren => nut
   chi xoa form chu khong tao them duoc gi. Khac CO CHU DICH voi `PeListPanel`.

3. Tang duoi CHI giu ke hoach DANG XU LY — an phieu `DaDuyet` (anh: "da duyet bo di luon").
   🔴 KHAI GIOI-HAN: loc o FE vi endpoint chi nhan `phase` DON, khong co tham so "loai tru"
   => (a) KHONG dung `total` cua server lam so hien thi (da gom phieu DaDuyet) — dem tu mang
   da loc; (b) cua-so 30 phieu moi nhat co the TOAN DaDuyet => tang duoi rong du con phieu
   nhap cu hon. Chap nhan o UAT; muon chuan thi BE them tham so loai-tru.
   Doi luon dong chu "x/total" -> "Da an ke hoach Da duyet": ti so cu doc thanh "dang cat
   bot" trong khi that ra la "dang loc bo".

4. Nhom duyet + Quy trinh duyet GAN CUNG theo leaf menu — anh: "gan cung luon theo tung nhom
   ko cho User dieu chinh cho nay luon".
   - `approvalGroup` khong con la state, DAN XUAT thang tu `?group=n`. Bo luon khoi dong-bo
     `lastPresetGroup` + ham `firstFreeGroup()` (chung chi ton tai de tu nhay nhom = dung
     hanh-vi vua bi cam).
   - Quy trinh: match theo ma tu NGUON BE, khong suy tu man hinh — `DbInitializer.cs:609`
     `var code = $"KHKK-N{n}"`. Khong khop => CHAN tao + noi ro thieu ma nao, TUYET DOI
     khong tu roi ve quy trinh nhom khac (gan nham = phieu di sai duong duyet).
   - UI: hien gia-tri co dinh, CO Y khong dung `<Select disabled>` (o xam van trong nhu
     "bam duoc nhung dang khoa" va van trong tab-order).

5. Panel giua: GO Section 5 "Y kien cap duyet" (anh: "bo luon cai nay ra luon"). So-do
   Buoc/Cap + chu ky da song o Panel 3 => de ca hai la ke mot chuyen hai lan tren cung man.
   DTO giu nguyen (`levelOpinions`/`workflowSteps` panel 3 van dung) — chi bo cho VE.

Verify: build 2 app PASS; mirror SHA256 3/3 cap KHOP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-05 15:50:17 +07:00
parent b7c6ee5a66
commit f86f30d291
7 changed files with 207 additions and 416 deletions

View File

@ -21,7 +21,7 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
Check, ClipboardList, FileCheck, MessageSquare, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users, ClipboardList, FileCheck, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PePipelineStrip } from '@/components/pe/PePipelineStrip' 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. // 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) 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 ────────────────── // 🧊 [S174 owner] Section 5kiến cấp duyệt" ĐÃ GỠ khỏi panel giữa — anh: "bỏ luôn cái
const opinions = plan.levelOpinions ?? [] // này ra luôn". Sơ-đồ Bước/Cấp + chữ ký đã sống ở PANEL 3 (`KhkkWorkflowPanel`), để cả hai
const steps = plan.workflowSteps ?? [] // 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 ( return (
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}> <div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
@ -569,112 +570,6 @@ export function KhkkDetailContent({
</ul> </ul>
)} )}
</Section> </Section>
{/* ─────────────── 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. */}
<Section
title="5. Ý kiến cấp duyệt"
icon={<MessageSquare className="h-4 w-4" />}
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 ? (
<div className="text-sm text-muted-foreground">
Phiếu chưa pin quy trình duyệt chưa cấp nào đ ghi ý kiến.
</div>
) : (
<div className="space-y-3">
{steps.map((step) => {
const signedCount = step.levels.filter(lv =>
opinions.some(o => o.approvalWorkflowLevelId === lv.id)).length
return (
<div key={step.id} className="rounded-lg border border-slate-200 bg-slate-50/40">
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-slate-50/80 px-3 py-2">
<span className="text-[12px] font-bold uppercase tracking-wide text-slate-700">
Bước {step.order} {step.name}
</span>
{step.departmentName && (
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
{step.departmentName}
</span>
)}
<span
className="ml-auto text-[10px] text-slate-500"
title={`Tổng ${step.levels.length} NV tham gia (mỗi cấp chỉ cần 1 NV ký — OR-of-N)`}
>
{signedCount}/{step.levels.length} cấp đã duyệt · {step.levels.length} NV tham gia
</span>
</div>
<div className="p-3">
{step.levels.length === 0 ? (
<div className="text-[12px] italic text-slate-400"> Bước này chưa cấu hình cấp duyệt.</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{step.levels.map((lv) => {
const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id)
return (
<div
key={lv.id}
className={cn(
'rounded-lg border bg-white p-3',
signed ? 'border-emerald-200' : 'border-dashed border-slate-200',
)}
>
<div className="mb-2 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h4 className="text-[13px] font-semibold text-slate-700">
{lv.name || `Cấp ${lv.order}`} {' '}
<span className="text-slate-900">{lv.approverFullName ?? '(chưa cấu hình)'}</span>
</h4>
{/* 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 && (
<div className="mt-1 inline-flex items-center gap-1 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
Admin <strong>{signed.signedByFullName}</strong> duyệt thay
</div>
)}
</div>
{signed ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
<Check className="h-3 w-3" /> Đã duyệt
</span>
) : (
<span className="inline-flex shrink-0 items-center rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-500">
Chưa
</span>
)}
</div>
{signed ? (
<>
<div className="whitespace-pre-wrap text-sm text-slate-800">
{signed.comment?.trim() || '(duyệt — không ý kiến)'}
</div>
<div className="mt-2 border-t border-slate-100 pt-1.5 text-[11px] text-slate-500">
{formatDateTime(signed.signedAt)}
</div>
</>
) : (
<div className="text-sm italic text-slate-400">Chưa ý kiến duyệt.</div>
)}
</div>
)
})}
</div>
)}
</div>
</div>
)
})}
</div>
)}
</Section>
</div> </div>
{/* Dialog thêm/sửa căn cứ */} {/* Dialog thêm/sửa căn cứ */}

View File

@ -31,6 +31,7 @@ import { cn } from '@/lib/cn'
import { import {
KHKK_PHASE_BADGE, KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS, KHKK_PHASE_LABELS,
KhkkPhase,
type KhkkListItemDto, type KhkkListItemDto,
type KhkkPeCandidateDto, type KhkkPeCandidateDto,
type PagedResult, type PagedResult,
@ -78,7 +79,6 @@ export function KhkkWorkListPanel({
onPickPlan, onPickPlan,
selectedPeId, selectedPeId,
selectedPlanId, 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). */ /** 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 onPickPe: (peId: string) => void
@ -87,8 +87,6 @@ export function KhkkWorkListPanel({
onPickPlan: (planId: string) => void onPickPlan: (planId: string) => void
selectedPeId?: string | null selectedPeId?: string | null
selectedPlanId?: 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() const { can } = usePermission()
// 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị). // 🔴 ĐÚ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]) 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 ( return (
<aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white"> <aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white">
@ -209,8 +214,9 @@ export function KhkkWorkListPanel({
<div className="shrink-0 border-t-2 border-slate-200" /> <div className="shrink-0 border-t-2 border-slate-200" />
<Tier <Tier
title="Kế hoạch ký kết đã tạo" title="Kế hoạch ký kết đang xử lý"
count={plans.data?.total ?? null} // Đếm từ mảng ĐÃ LỌC, KHÔNG lấy `total` của server (xem khối lọc phía trên).
count={plans.data ? planItems.length : null}
accent="bg-violet-50 text-violet-600" accent="bg-violet-50 text-violet-600"
icon={<FileSignature className="h-3 w-3" />} icon={<FileSignature className="h-3 w-3" />}
> >
@ -228,7 +234,10 @@ export function KhkkWorkListPanel({
</div> </div>
)} )}
{!plans.isLoading && !plans.isError && planItems.length === 0 && ( {!plans.isLoading && !plans.isError && planItems.length === 0 && (
<p className="p-3 text-[11px] italic text-slate-400">Chưa kế hoạch kết nào.</p> <p className="p-3 text-[11px] italic text-slate-400">
Không còn kế hoạch nào đang xử . Phiếu đã duyệt không hiện đây xem tại
menu Kế hoạch kết .
</p>
)} )}
<ul className="divide-y divide-slate-100"> <ul className="divide-y divide-slate-100">
{planItems.map((k) => ( {planItems.map((k) => (
@ -267,19 +276,23 @@ export function KhkkWorkListPanel({
</li> </li>
))} ))}
</ul> </ul>
{plans.data && plans.data.total > planItems.length && ( {/* 🔴 KHÔNG in "x/total" nữa: `total` gồm cả phiếu đã duyệt vừa bị lọc ⇒ tỉ số đó
sẽ đọc thành "đang cắt bớt" trong khi thật ra là "đang lọc bỏ". Nói đúng việc
đang làm, và chỉ nói khi thật sự có cái bị giấu. */}
{plans.data && plans.data.items.length > planItems.length && (
<p className="border-t border-slate-100 px-3 py-1.5 text-[10px] italic text-slate-400"> <p className="border-t border-slate-100 px-3 py-1.5 text-[10px] italic text-slate-400">
Hiện {planItems.length}/{plans.data.total} kế hoạch mới nhất xem đ menu Kế hoạch kết . Đã n kế hoạch trạng thái Đã duyệt xem đ menu Kế hoạch kết .
</p> </p>
)} )}
</Tier> </Tier>
</> </>
)} )}
{/* Nút hành-động dính ĐÁY, ngoài vùng cuộn của 2 tầng — mirror "+ Thêm mới" của {/* 🧊 [S174] KHÔNG có nút "+ Thêm mới" ở đáy — anh chốt bỏ. Lý do đứng vững: màn này
`PeListPanel` (màn Duyệt NCC — Thao tác). Hiện KỂ CẢ khi thiếu quyền KHKK: cột KHÔNG có đường "tạo từ số 0". Một kế hoạch LUÔN sinh ra từ một phiếu Duyệt NCC, mà
không dựng được thì vẫn phải còn đường bấm, không thì màn thành ngõ cụt. */} các phiếu đủ điều kiện đã nằm nguyên ở tầng trên ⇒ nút chỉ xoá form chứ không tạo
{footer && <div className="shrink-0 border-t border-slate-200 p-2">{footer}</div>} thêm được gì. Đây là chỗ khác CÓ CHỦ ĐÍCH với `PeListPanel` (bên đó tạo phiếu PE
mới là việc có thật). Ai định thêm lại: phải trả lời được "thêm mới CÁI GÌ". */}
</aside> </aside>
) )
} }

View File

@ -23,19 +23,17 @@
import { useState, type FormEvent, type ReactNode } from 'react' import { useState, type FormEvent, type ReactNode } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' 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 { toast } from 'sonner'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea' import { Textarea } from '@/components/ui/Textarea'
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent' import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel' import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { import {
KHKK_APPROVAL_GROUPS,
KHKK_APPROVAL_GROUP_DEFAULT, KHKK_APPROVAL_GROUP_DEFAULT,
KHKK_APPROVAL_GROUP_LABELS, KHKK_APPROVAL_GROUP_LABELS,
khkkGroupMenuLabel, khkkGroupMenuLabel,
@ -55,11 +53,9 @@ interface WorkflowDto {
isUserSelectable: boolean 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 // 🧊 [S174] `firstFreeGroup()` ĐÃ GỠ cùng lúc bỏ ô chọn nhóm — nó chỉ tồn tại để tự nhảy
// yên ở một nhóm chắc chắn 409. // 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
function firstFreeGroup(taken: number[]): number | null { // chỉnh"). Giữ lại thành hàm chết thì lượt sau có người gọi lại.
return KHKK_APPROVAL_GROUPS.find((g) => !taken.includes(g)) ?? null
}
export function KhkkCreatePage() { export function KhkkCreatePage() {
const navigate = useNavigate() const navigate = useNavigate()
@ -69,21 +65,17 @@ export function KhkkCreatePage() {
const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('') const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('')
const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
const [approvalGroup, setApprovalGroup] = useState<number>(presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT)
const [ghiChu, setGhiChu] = useState('') 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 // [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')`. // cho form tạo. `null` ⇒ về form tạo. Đây là thứ thay cho việc `navigate('/khkk/:id')`.
const [viewPlanId, setViewPlanId] = useState<string | null>(null) const [viewPlanId, setViewPlanId] = useState<string | null>(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. // Cùng query key với `KhkkWorkListPanel` ⇒ dùng CHUNG cache, không gọi mạng 2 lần.
const candidates = useQuery({ const candidates = useQuery({
queryKey: ['khkk-pe-candidates'], 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. // Phiếu đang XEM — chỉ tải khi thật sự có cái để xem.
const viewPlan = useQuery({ const viewPlan = useQuery({
queryKey: ['khkk-detail', viewPlanId], queryKey: ['khkk-detail', viewPlanId],
@ -113,10 +115,10 @@ export function KhkkCreatePage() {
const create = useMutation({ const create = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn') 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 = { const body: CreateKhkkInput = {
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
approvalWorkflowId, approvalWorkflowId: autoWorkflow.id,
ghiChu: ghiChu.trim() || null, ghiChu: ghiChu.trim() || null,
approvalGroup, // [K2] gửi TƯỜNG MINH, không dựa default ngầm của BE 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) => { onSuccess: (data) => {
toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết') toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết')
setPurchaseEvaluationId('') setPurchaseEvaluationId('')
setApprovalWorkflowId('')
setGhiChu('') setGhiChu('')
setViewPlanId(data.id) setViewPlanId(data.id)
}, },
@ -141,21 +142,17 @@ export function KhkkCreatePage() {
const missingCreate = [ const missingCreate = [
!purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn', !purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn',
!approvalWorkflowId && 'Quy trình duyệt', !workflows.isLoading && !autoWorkflow && `Quy trình ${wantWorkflowCode} (chưa cấu hình)`,
groupTaken && `Nhóm duyệt còn trống (N${approvalGroup} đã có kế hoạch)`, groupTaken && `Phiếu này đã có kế hoạch nhóm N${approvalGroup}`,
].filter(Boolean) as string[] ].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ì // [S174] Bấm PE ở panel 1 = nạp vào form + rời chế-độ xem. 🧊 Khối "nhảy sang nhóm trống
// nhảy sang nhóm trống đầu tiên ⇒ người dùng không phải tự dò. // đầ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) => { const pickPe = (peId: string) => {
setPurchaseEvaluationId(peId) setPurchaseEvaluationId(peId)
setViewPlanId(null) 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) => { const onSubmit = (e: FormEvent) => {
@ -185,22 +182,6 @@ export function KhkkCreatePage() {
onPickPlan={setViewPlanId} onPickPlan={setViewPlanId}
selectedPeId={purchaseEvaluationId || null} selectedPeId={purchaseEvaluationId || null}
selectedPlanId={viewPlanId} selectedPlanId={viewPlanId}
footer={
<Button
type="button"
className="w-full"
// "Thêm mới" = trả panel 2 về form TRẮNG. Không điều hướng đi đâu.
onClick={() => {
setViewPlanId(null)
setPurchaseEvaluationId('')
setApprovalWorkflowId('')
setGhiChu('')
}}
>
<Plus className="mr-2 h-4 w-4" />
Thêm mới
</Button>
}
/> />
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block"> <main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
@ -294,55 +275,59 @@ export function KhkkCreatePage() {
</div> </div>
</Section> </Section>
<Section title="2. Nhóm duyệt & quy trình"> {/* [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 `<Select disabled>`: ô xám vẫn trông như "bấm được nhưng đang khoá"
và vẫn nằm trong tab-order — hiển thị thẳng giá trị mới nói đúng rằng
đây là dữ-kiện, không phải lựa-chọn. Muốn nhóm khác thì đổi leaf ở menu. */}
<Section title="2. Nhóm duyệt & quy trình (theo menu, không đổi được)">
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div> <div>
<Label htmlFor="approvalGroup" className="text-[11px]">Nhóm duyệt (N1N8) *</Label> <Label className="text-[11px]">Nhóm duyệt</Label>
<Select <div className="flex h-9 items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700">
id="approvalGroup" <span className="rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10px] font-semibold text-violet-700">
value={approvalGroup} N{approvalGroup}
onChange={(e) => setApprovalGroup(Number(e.target.value))} </span>
> <span className="truncate">
{KHKK_APPROVAL_GROUPS.map((g) => { {KHKK_APPROVAL_GROUP_LABELS[approvalGroup] ?? `Nhóm ${approvalGroup}`}
const used = takenGroups.includes(g) </span>
return ( </div>
<option key={g} value={g} disabled={used}>
N{g} - {KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
{used ? ' (đã có kế hoạch)' : ''}
</option>
)
})}
</Select>
{groupTaken ? ( {groupTaken ? (
<p className="mt-1 text-[11px] font-medium text-red-600"> <p className="mt-1 text-[11px] font-medium text-red-600">
Phiếu này đã kế hoạch cho nhóm N{approvalGroup} chọn nhóm khác. Phiếu này đã 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.
</p> </p>
) : ( ) : (
<p className="mt-1 text-[11px] text-slate-500"> <p className="mt-1 text-[11px] text-slate-500">
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 ch sang mục khác
</p> </p>
)} )}
</div> </div>
<div> <div>
<Label htmlFor="workflow" className="text-[11px]">Quy trình duyệt *</Label> <Label className="text-[11px]">Quy trình duyệt</Label>
<Select <div className="flex h-9 items-center rounded-md border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700">
id="workflow" {workflows.isLoading ? (
value={approvalWorkflowId} <span className="text-slate-400">Đang tải</span>
onChange={(e) => setApprovalWorkflowId(e.target.value)} ) : autoWorkflow ? (
> <span className="truncate">
<option value=""> Chọn quy trình duyệt </option> <span className="font-mono text-[12px] text-brand-700">{autoWorkflow.code}</span>
{workflows.data?.map((w) => ( <span className="text-slate-400"> · </span>
<option key={w.id} value={w.id}>{w.code} - {w.name}</option> {autoWorkflow.name}
))} </span>
</Select> ) : (
{workflows.data && workflows.data.length === 0 ? ( <span className="text-red-600">Không tìm thấy {wantWorkflowCode}</span>
)}
</div>
{!workflows.isLoading && !autoWorkflow ? (
<p className="mt-1 text-[11px] text-amber-700"> <p className="mt-1 text-[11px] text-amber-700">
Chưa 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 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ự dùng quy trình của nhóm khác.
</p> </p>
) : ( ) : (
<p className="mt-1 text-[11px] text-slate-500"> <p className="mt-1 text-[11px] text-slate-500">
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
</p> </p>
)} )}
</div> </div>
@ -380,7 +365,7 @@ export function KhkkCreatePage() {
type="button" type="button"
variant="ghost" variant="ghost"
className="text-xs" className="text-xs"
onClick={() => { setPurchaseEvaluationId(''); setApprovalWorkflowId(''); setGhiChu('') }} onClick={() => { setPurchaseEvaluationId(''); setGhiChu('') }}
> >
Hủy Hủy
</Button> </Button>

View File

@ -21,7 +21,7 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
Check, ClipboardList, FileCheck, MessageSquare, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users, ClipboardList, FileCheck, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PePipelineStrip } from '@/components/pe/PePipelineStrip' 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. // 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) 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 ────────────────── // 🧊 [S174 owner] Section 5kiến cấp duyệt" ĐÃ GỠ khỏi panel giữa — anh: "bỏ luôn cái
const opinions = plan.levelOpinions ?? [] // này ra luôn". Sơ-đồ Bước/Cấp + chữ ký đã sống ở PANEL 3 (`KhkkWorkflowPanel`), để cả hai
const steps = plan.workflowSteps ?? [] // 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 ( return (
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}> <div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
@ -569,112 +570,6 @@ export function KhkkDetailContent({
</ul> </ul>
)} )}
</Section> </Section>
{/* ─────────────── 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. */}
<Section
title="5. Ý kiến cấp duyệt"
icon={<MessageSquare className="h-4 w-4" />}
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 ? (
<div className="text-sm text-muted-foreground">
Phiếu chưa pin quy trình duyệt chưa cấp nào đ ghi ý kiến.
</div>
) : (
<div className="space-y-3">
{steps.map((step) => {
const signedCount = step.levels.filter(lv =>
opinions.some(o => o.approvalWorkflowLevelId === lv.id)).length
return (
<div key={step.id} className="rounded-lg border border-slate-200 bg-slate-50/40">
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-slate-50/80 px-3 py-2">
<span className="text-[12px] font-bold uppercase tracking-wide text-slate-700">
Bước {step.order} {step.name}
</span>
{step.departmentName && (
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
{step.departmentName}
</span>
)}
<span
className="ml-auto text-[10px] text-slate-500"
title={`Tổng ${step.levels.length} NV tham gia (mỗi cấp chỉ cần 1 NV ký — OR-of-N)`}
>
{signedCount}/{step.levels.length} cấp đã duyệt · {step.levels.length} NV tham gia
</span>
</div>
<div className="p-3">
{step.levels.length === 0 ? (
<div className="text-[12px] italic text-slate-400"> Bước này chưa cấu hình cấp duyệt.</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{step.levels.map((lv) => {
const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id)
return (
<div
key={lv.id}
className={cn(
'rounded-lg border bg-white p-3',
signed ? 'border-emerald-200' : 'border-dashed border-slate-200',
)}
>
<div className="mb-2 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h4 className="text-[13px] font-semibold text-slate-700">
{lv.name || `Cấp ${lv.order}`} {' '}
<span className="text-slate-900">{lv.approverFullName ?? '(chưa cấu hình)'}</span>
</h4>
{/* 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 && (
<div className="mt-1 inline-flex items-center gap-1 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
Admin <strong>{signed.signedByFullName}</strong> duyệt thay
</div>
)}
</div>
{signed ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
<Check className="h-3 w-3" /> Đã duyệt
</span>
) : (
<span className="inline-flex shrink-0 items-center rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-500">
Chưa
</span>
)}
</div>
{signed ? (
<>
<div className="whitespace-pre-wrap text-sm text-slate-800">
{signed.comment?.trim() || '(duyệt — không ý kiến)'}
</div>
<div className="mt-2 border-t border-slate-100 pt-1.5 text-[11px] text-slate-500">
{formatDateTime(signed.signedAt)}
</div>
</>
) : (
<div className="text-sm italic text-slate-400">Chưa ý kiến duyệt.</div>
)}
</div>
)
})}
</div>
)}
</div>
</div>
)
})}
</div>
)}
</Section>
</div> </div>
{/* Dialog thêm/sửa căn cứ */} {/* Dialog thêm/sửa căn cứ */}

View File

@ -31,6 +31,7 @@ import { cn } from '@/lib/cn'
import { import {
KHKK_PHASE_BADGE, KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS, KHKK_PHASE_LABELS,
KhkkPhase,
type KhkkListItemDto, type KhkkListItemDto,
type KhkkPeCandidateDto, type KhkkPeCandidateDto,
type PagedResult, type PagedResult,
@ -78,7 +79,6 @@ export function KhkkWorkListPanel({
onPickPlan, onPickPlan,
selectedPeId, selectedPeId,
selectedPlanId, 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). */ /** 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 onPickPe: (peId: string) => void
@ -87,8 +87,6 @@ export function KhkkWorkListPanel({
onPickPlan: (planId: string) => void onPickPlan: (planId: string) => void
selectedPeId?: string | null selectedPeId?: string | null
selectedPlanId?: 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() const { can } = usePermission()
// 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị). // 🔴 ĐÚ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]) 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 ( return (
<aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white"> <aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white">
@ -209,8 +214,9 @@ export function KhkkWorkListPanel({
<div className="shrink-0 border-t-2 border-slate-200" /> <div className="shrink-0 border-t-2 border-slate-200" />
<Tier <Tier
title="Kế hoạch ký kết đã tạo" title="Kế hoạch ký kết đang xử lý"
count={plans.data?.total ?? null} // Đếm từ mảng ĐÃ LỌC, KHÔNG lấy `total` của server (xem khối lọc phía trên).
count={plans.data ? planItems.length : null}
accent="bg-violet-50 text-violet-600" accent="bg-violet-50 text-violet-600"
icon={<FileSignature className="h-3 w-3" />} icon={<FileSignature className="h-3 w-3" />}
> >
@ -228,7 +234,10 @@ export function KhkkWorkListPanel({
</div> </div>
)} )}
{!plans.isLoading && !plans.isError && planItems.length === 0 && ( {!plans.isLoading && !plans.isError && planItems.length === 0 && (
<p className="p-3 text-[11px] italic text-slate-400">Chưa kế hoạch kết nào.</p> <p className="p-3 text-[11px] italic text-slate-400">
Không còn kế hoạch nào đang xử . Phiếu đã duyệt không hiện đây xem tại
menu Kế hoạch kết .
</p>
)} )}
<ul className="divide-y divide-slate-100"> <ul className="divide-y divide-slate-100">
{planItems.map((k) => ( {planItems.map((k) => (
@ -267,19 +276,23 @@ export function KhkkWorkListPanel({
</li> </li>
))} ))}
</ul> </ul>
{plans.data && plans.data.total > planItems.length && ( {/* 🔴 KHÔNG in "x/total" nữa: `total` gồm cả phiếu đã duyệt vừa bị lọc ⇒ tỉ số đó
sẽ đọc thành "đang cắt bớt" trong khi thật ra là "đang lọc bỏ". Nói đúng việc
đang làm, và chỉ nói khi thật sự có cái bị giấu. */}
{plans.data && plans.data.items.length > planItems.length && (
<p className="border-t border-slate-100 px-3 py-1.5 text-[10px] italic text-slate-400"> <p className="border-t border-slate-100 px-3 py-1.5 text-[10px] italic text-slate-400">
Hiện {planItems.length}/{plans.data.total} kế hoạch mới nhất xem đ menu Kế hoạch kết . Đã n kế hoạch trạng thái Đã duyệt xem đ menu Kế hoạch kết .
</p> </p>
)} )}
</Tier> </Tier>
</> </>
)} )}
{/* Nút hành-động dính ĐÁY, ngoài vùng cuộn của 2 tầng — mirror "+ Thêm mới" của {/* 🧊 [S174] KHÔNG có nút "+ Thêm mới" ở đáy — anh chốt bỏ. Lý do đứng vững: màn này
`PeListPanel` (màn Duyệt NCC — Thao tác). Hiện KỂ CẢ khi thiếu quyền KHKK: cột KHÔNG có đường "tạo từ số 0". Một kế hoạch LUÔN sinh ra từ một phiếu Duyệt NCC, mà
không dựng được thì vẫn phải còn đường bấm, không thì màn thành ngõ cụt. */} các phiếu đủ điều kiện đã nằm nguyên ở tầng trên ⇒ nút chỉ xoá form chứ không tạo
{footer && <div className="shrink-0 border-t border-slate-200 p-2">{footer}</div>} thêm được gì. Đây là chỗ khác CÓ CHỦ ĐÍCH với `PeListPanel` (bên đó tạo phiếu PE
mới là việc có thật). Ai định thêm lại: phải trả lời được "thêm mới CÁI GÌ". */}
</aside> </aside>
) )
} }

View File

@ -10,7 +10,6 @@ import { useNavigate, useSearchParams } from 'react-router-dom'
import { Inbox, AlertTriangle, Clock, FileText, Search, ClipboardList } from 'lucide-react' import { Inbox, AlertTriangle, Clock, FileText, Search, ClipboardList } from 'lucide-react'
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent' import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel' import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
import { PhaseBadge } from '@/components/PhaseBadge' import { PhaseBadge } from '@/components/PhaseBadge'
import { SlaTimer } from '@/components/SlaTimer' import { SlaTimer } from '@/components/SlaTimer'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
@ -339,13 +338,19 @@ export function InboxPage() {
{/* Panel 3 — Workflow + history */} {/* Panel 3 — Workflow + history */}
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block"> <aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
{/* [S168 GĐ3-D — owner slot (63) chốt D] Chưa chọn HĐ ⇒ cây toàn trình (GĐ3) {/* [S174 owner] GỠ `PipelineTreePanel` khỏi Hộp thư — anh: "chỗ hộp thư vứt panel
thế chỗ ô trng; chọn HĐ thì nhường chỗ cho quy trình duyệt. Trade-off D này vào làm j, tập trung vào KHKK thôi". Hộp thư là hàng-đợi-việc-của-tôi; cây
(cây rời panel khi chọn HĐ) — sub-implfe-gd3.md §1. */} toàn trình là bản-đồ-toàn-hệ ⇒ sai thể loại, và nó chiếm mất chỗ đáng ra để
{/* [gate F-1/F-2] Panel-3 TỰ CUỘN ⇒ thân cây `lg:max-h-none` giữ đúng 1 thanh cuộn trống chờ quy trình duyệt của HĐ đang chọn.
(chính /inbox là trang THIẾU ~26px vì dải stats + banner vai trò); sticky/self-start 🧊 Lineage: panel này vào đây @S168 GĐ3-D theo owner slot (63) chốt D + "mở rộng
của khuôn vô hiệu ở host display:block này — by-design. */} = cả 3 trang". Nay owner thu lại ĐÚNG trang này. HAI trang GĐ3/GĐ4 còn lại
{!selectedId && <PipelineTreePanel currentStage={3} bodyMaxHClass="max-h-[28rem] lg:max-h-none" />} (`MyContractsPage` · `HardCopiesPage`) GIỮ NGUYÊN — anh chỉ đích danh Hộp thư,
không nói gỡ hết; đừng tự suy rộng. */}
{!selectedId && (
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
Quy trình duyệt sẽ hiện khi chọn hợp đng.
</div>
)}
{selectedId && detail.data && <WorkflowHistoryPanel contract={detail.data} />} {selectedId && detail.data && <WorkflowHistoryPanel contract={detail.data} />}
</aside> </aside>
</div> </div>

View File

@ -23,19 +23,17 @@
import { useState, type FormEvent, type ReactNode } from 'react' import { useState, type FormEvent, type ReactNode } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' 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 { toast } from 'sonner'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea' import { Textarea } from '@/components/ui/Textarea'
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent' import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel' import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { import {
KHKK_APPROVAL_GROUPS,
KHKK_APPROVAL_GROUP_DEFAULT, KHKK_APPROVAL_GROUP_DEFAULT,
KHKK_APPROVAL_GROUP_LABELS, KHKK_APPROVAL_GROUP_LABELS,
khkkGroupMenuLabel, khkkGroupMenuLabel,
@ -55,11 +53,9 @@ interface WorkflowDto {
isUserSelectable: boolean 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 // 🧊 [S174] `firstFreeGroup()` ĐÃ GỠ cùng lúc bỏ ô chọn nhóm — nó chỉ tồn tại để tự nhảy
// yên ở một nhóm chắc chắn 409. // 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
function firstFreeGroup(taken: number[]): number | null { // chỉnh"). Giữ lại thành hàm chết thì lượt sau có người gọi lại.
return KHKK_APPROVAL_GROUPS.find((g) => !taken.includes(g)) ?? null
}
export function KhkkCreatePage() { export function KhkkCreatePage() {
const navigate = useNavigate() const navigate = useNavigate()
@ -69,21 +65,17 @@ export function KhkkCreatePage() {
const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('') const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('')
const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
const [approvalGroup, setApprovalGroup] = useState<number>(presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT)
const [ghiChu, setGhiChu] = useState('') 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 // [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')`. // cho form tạo. `null` ⇒ về form tạo. Đây là thứ thay cho việc `navigate('/khkk/:id')`.
const [viewPlanId, setViewPlanId] = useState<string | null>(null) const [viewPlanId, setViewPlanId] = useState<string | null>(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. // Cùng query key với `KhkkWorkListPanel` ⇒ dùng CHUNG cache, không gọi mạng 2 lần.
const candidates = useQuery({ const candidates = useQuery({
queryKey: ['khkk-pe-candidates'], 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. // Phiếu đang XEM — chỉ tải khi thật sự có cái để xem.
const viewPlan = useQuery({ const viewPlan = useQuery({
queryKey: ['khkk-detail', viewPlanId], queryKey: ['khkk-detail', viewPlanId],
@ -113,10 +115,10 @@ export function KhkkCreatePage() {
const create = useMutation({ const create = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn') 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 = { const body: CreateKhkkInput = {
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
approvalWorkflowId, approvalWorkflowId: autoWorkflow.id,
ghiChu: ghiChu.trim() || null, ghiChu: ghiChu.trim() || null,
approvalGroup, // [K2] gửi TƯỜNG MINH, không dựa default ngầm của BE 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) => { onSuccess: (data) => {
toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết') toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết')
setPurchaseEvaluationId('') setPurchaseEvaluationId('')
setApprovalWorkflowId('')
setGhiChu('') setGhiChu('')
setViewPlanId(data.id) setViewPlanId(data.id)
}, },
@ -141,21 +142,17 @@ export function KhkkCreatePage() {
const missingCreate = [ const missingCreate = [
!purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn', !purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn',
!approvalWorkflowId && 'Quy trình duyệt', !workflows.isLoading && !autoWorkflow && `Quy trình ${wantWorkflowCode} (chưa cấu hình)`,
groupTaken && `Nhóm duyệt còn trống (N${approvalGroup} đã có kế hoạch)`, groupTaken && `Phiếu này đã có kế hoạch nhóm N${approvalGroup}`,
].filter(Boolean) as string[] ].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ì // [S174] Bấm PE ở panel 1 = nạp vào form + rời chế-độ xem. 🧊 Khối "nhảy sang nhóm trống
// nhảy sang nhóm trống đầu tiên ⇒ người dùng không phải tự dò. // đầ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) => { const pickPe = (peId: string) => {
setPurchaseEvaluationId(peId) setPurchaseEvaluationId(peId)
setViewPlanId(null) 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) => { const onSubmit = (e: FormEvent) => {
@ -185,22 +182,6 @@ export function KhkkCreatePage() {
onPickPlan={setViewPlanId} onPickPlan={setViewPlanId}
selectedPeId={purchaseEvaluationId || null} selectedPeId={purchaseEvaluationId || null}
selectedPlanId={viewPlanId} selectedPlanId={viewPlanId}
footer={
<Button
type="button"
className="w-full"
// "Thêm mới" = trả panel 2 về form TRẮNG. Không điều hướng đi đâu.
onClick={() => {
setViewPlanId(null)
setPurchaseEvaluationId('')
setApprovalWorkflowId('')
setGhiChu('')
}}
>
<Plus className="mr-2 h-4 w-4" />
Thêm mới
</Button>
}
/> />
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block"> <main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
@ -294,55 +275,59 @@ export function KhkkCreatePage() {
</div> </div>
</Section> </Section>
<Section title="2. Nhóm duyệt & quy trình"> {/* [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 `<Select disabled>`: ô xám vẫn trông như "bấm được nhưng đang khoá"
và vẫn nằm trong tab-order — hiển thị thẳng giá trị mới nói đúng rằng
đây là dữ-kiện, không phải lựa-chọn. Muốn nhóm khác thì đổi leaf ở menu. */}
<Section title="2. Nhóm duyệt & quy trình (theo menu, không đổi được)">
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div> <div>
<Label htmlFor="approvalGroup" className="text-[11px]">Nhóm duyệt (N1N8) *</Label> <Label className="text-[11px]">Nhóm duyệt</Label>
<Select <div className="flex h-9 items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700">
id="approvalGroup" <span className="rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10px] font-semibold text-violet-700">
value={approvalGroup} N{approvalGroup}
onChange={(e) => setApprovalGroup(Number(e.target.value))} </span>
> <span className="truncate">
{KHKK_APPROVAL_GROUPS.map((g) => { {KHKK_APPROVAL_GROUP_LABELS[approvalGroup] ?? `Nhóm ${approvalGroup}`}
const used = takenGroups.includes(g) </span>
return ( </div>
<option key={g} value={g} disabled={used}>
N{g} - {KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
{used ? ' (đã có kế hoạch)' : ''}
</option>
)
})}
</Select>
{groupTaken ? ( {groupTaken ? (
<p className="mt-1 text-[11px] font-medium text-red-600"> <p className="mt-1 text-[11px] font-medium text-red-600">
Phiếu này đã kế hoạch cho nhóm N{approvalGroup} chọn nhóm khác. Phiếu này đã 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.
</p> </p>
) : ( ) : (
<p className="mt-1 text-[11px] text-slate-500"> <p className="mt-1 text-[11px] text-slate-500">
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 ch sang mục khác
</p> </p>
)} )}
</div> </div>
<div> <div>
<Label htmlFor="workflow" className="text-[11px]">Quy trình duyệt *</Label> <Label className="text-[11px]">Quy trình duyệt</Label>
<Select <div className="flex h-9 items-center rounded-md border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700">
id="workflow" {workflows.isLoading ? (
value={approvalWorkflowId} <span className="text-slate-400">Đang tải</span>
onChange={(e) => setApprovalWorkflowId(e.target.value)} ) : autoWorkflow ? (
> <span className="truncate">
<option value=""> Chọn quy trình duyệt </option> <span className="font-mono text-[12px] text-brand-700">{autoWorkflow.code}</span>
{workflows.data?.map((w) => ( <span className="text-slate-400"> · </span>
<option key={w.id} value={w.id}>{w.code} - {w.name}</option> {autoWorkflow.name}
))} </span>
</Select> ) : (
{workflows.data && workflows.data.length === 0 ? ( <span className="text-red-600">Không tìm thấy {wantWorkflowCode}</span>
)}
</div>
{!workflows.isLoading && !autoWorkflow ? (
<p className="mt-1 text-[11px] text-amber-700"> <p className="mt-1 text-[11px] text-amber-700">
Chưa 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 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ự dùng quy trình của nhóm khác.
</p> </p>
) : ( ) : (
<p className="mt-1 text-[11px] text-slate-500"> <p className="mt-1 text-[11px] text-slate-500">
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
</p> </p>
)} )}
</div> </div>
@ -380,7 +365,7 @@ export function KhkkCreatePage() {
type="button" type="button"
variant="ghost" variant="ghost"
className="text-xs" className="text-xs"
onClick={() => { setPurchaseEvaluationId(''); setApprovalWorkflowId(''); setGhiChu('') }} onClick={() => { setPurchaseEvaluationId(''); setGhiChu('') }}
> >
Hủy Hủy
</Button> </Button>