[CLAUDE] Contract: W5+W6FE card Nguồn-gốc KHKK + panel duyệt 2-nút mirror KhkkWorkflowPanel (dual-render V1) + Designer type-3 family-slice + ruột pre-fill đo-khai
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 8m27s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-10 19:46:33 +07:00
parent ad9a21ed30
commit b275893c8f
8 changed files with 1354 additions and 36 deletions

View File

@ -4,12 +4,16 @@
// approval history live separately in WorkflowHistoryPanel (Panel 3).
import { useState, type FormEvent } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, CheckCircle2, MessageSquare, XCircle, ListChecks } from 'lucide-react'
import { Link } from 'react-router-dom'
import { ArrowLeft, CheckCircle2, MessageSquare, XCircle, ListChecks, GitBranch } from 'lucide-react'
import { toast } from 'sonner'
import { PhaseBadge } from '@/components/PhaseBadge'
import { SlaTimer } from '@/components/SlaTimer'
import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection'
import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab'
// [W6 S187] Màn duyệt V2-trạm — CHỈ dùng cho HĐ đã pin `approvalWorkflowId` (xem khối
// dual-render ở dưới). HĐ V1 legacy không import nhánh này lúc chạy.
import { ContractWorkflowPanel } from '@/components/contracts/ContractWorkflowPanel'
import { Button } from '@/components/ui/Button'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
@ -23,6 +27,10 @@ import {
type ContractDetail,
} from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms'
// [W5 S187] Nhãn 8 nhóm duyệt KHKK — IMPORT, KHÔNG chép chuỗi. Map này là single-source
// (`types/khkk.ts:140-149`, đã đối chiếu từng ký tự với nhãn BE seed @K4a); đẻ map thứ 2 ở
// đây là phá đúng cái acceptance nó phục vụ.
import { KHKK_APPROVAL_GROUP_LABELS } from '@/types/khkk'
const fmt = (s: string) => new Date(s).toLocaleString('vi-VN')
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') + ' VND'
@ -71,6 +79,34 @@ export function ContractDetailContent({
const availableTargets = c.workflow?.nextPhases ?? []
// ===== [W6 S187 · YC-023 GĐ3] DUAL-RENDER V2-trạm ⟂ V1-legacy ==========================
// 🔴 `approvalWorkflowId != null` là ĐÚNG cờ phân nhánh — cùng cờ mà BE dùng để chọn đường
// duyệt (`ContractWorkflowService.cs:111` `if (contract.ApprovalWorkflowId is Guid awId)`)
// VÀ cùng cờ mục "Ý kiến cấp duyệt (Quy trình V2)" bên dưới đã dùng từ S29. Một cờ, ba nơi
// đọc, không đẻ khái niệm "HĐ mới" thứ hai.
// • V2 ⇒ `ContractWorkflowPanel` (sơ đồ Bước→Cấp + banner lượt-ai + 2 nút Duyệt/Trả lại,
// có gating theo Cấp đang chờ).
// • V1 legacy (không pin — 7 HĐ prod chạy nhánh này, `ContractWorkflowService.cs:110`)
// ⇒ GIỮ NGUYÊN 2 nút "Yêu cầu sửa"/"Duyệt → tiếp" ở header + Dialog chọn phase.
// 2 khối là THAY THẾ nhau, không chồng: để cả hai thì cùng một HĐ V2 có 2 đường bấm duyệt
// (đường header không nói được đang ở Bước/Cấp nào, cũng không gating theo người) — mâu
// thuẫn hiển thị chứ không phải tiện thêm.
const isV2 = c.approvalWorkflowId != null
// ===== [W5 S187 · YC-023 GĐ3] NGUỒN GỐC KHKK (GĐ2) =====================================
// 🔴 Điều kiện render là `!= null` (LOOSE — bắt cả `undefined`) CÓ CHỦ ĐÍCH: `types/contracts.ts:174-182`
// khai 2 nghĩa KHÁC NHAU — `undefined` = payload/cache TanStack CHƯA có W2; `null` = BE nói
// "HĐ này không có nguồn KHKK". Cả 2 đều KHÔNG có gì để vẽ, nhưng KHÔNG được suy `undefined`
// thành "HĐ tạo tay" ở bất kỳ chỗ nào khác.
// Nguồn dữ liệu: BE reverse-join `ContractSigningPlanLines WHERE ContractId = id`
// (`ContractFeatures.cs:720-748`) — `Contract` KHÔNG có con-trỏ ngược sang kế hoạch.
const src = c.source
const srcGroup = src?.approvalGroup ?? null
// Nhãn nhóm: tra map single-source. Truthy-check theo đúng khuôn `khkkGroupMenuLabel`
// (`types/khkk.ts:159-162`) vì `Record<number,string>` trả `string` cho MỌI số — nhóm lạ
// (ngoài 1..8, vd dữ liệu SQL thô) phải hiện "chưa có trong danh mục" thay vì ô trống CÂM.
const srcGroupLabel = srcGroup != null ? KHKK_APPROVAL_GROUP_LABELS[srcGroup] : undefined
function openAction(decisionType: number) {
const targets = c.workflow?.nextPhases ?? []
const defaultTarget = decisionType === ApprovalDecision.Reject
@ -102,7 +138,9 @@ export function ContractDetailContent({
<PhaseBadge phase={c.phase} />
</div>
</div>
{availableTargets.length > 0 && (
{/* [W6 S187] `!isV2` = nửa V1 của dual-render. HĐ pin quy trình V2 thao tác duyệt ở
`ContractWorkflowPanel` bên dưới (đúng Bước/Cấp + đúng người), không ở đây. */}
{!isV2 && availableTargets.length > 0 && (
<div className="flex shrink-0 gap-2">
<Button variant="outline" onClick={() => openAction(ApprovalDecision.Reject)}>
<XCircle className="h-4 w-4" />
@ -139,6 +177,114 @@ export function ContractDetailContent({
)}
</section>
{/* [W5 S187 · YC-023 GĐ3] Card "Nguồn gốc Kế hoạch ký kết" — đứng NGAY SAU khối thông
tin chính: người mở HĐ cần biết nó đẻ ra từ đâu trước khi đọc góp ý / chi tiết.
Tông VIOLET = GĐ2 (KHKK) theo bảng màu 4 giai đoạn `PipelineStageFolders.tsx:41-46`;
link sang Phiếu Duyệt NCC dùng tông BRAND = GĐ1 (PE).
⚠️ Chỉ dùng stop violet CÓ THẬT trong `index.css` `@theme` (50/100/500/600/700) —
violet-300/800 rơi về bảng Tailwind mặc định, lệch hệ màu (đã thấy ở
`KhkkDetailContent.tsx:477`, pre-existing, không sửa ở wave này).
Vỏ `<section className="rounded-lg border … p-5">` + `<h2 className="mb-3 …">` bám
ĐÚNG khuôn các khối cùng trang — 2 app đều KHÔNG có shadcn `Card`. */}
{src != null && (
<section className="rounded-lg border border-violet-100 bg-violet-50/60 p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-violet-700">
<GitBranch className="h-4 w-4" />
Nguồn gốc Kế hoạch kết
</h2>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div className="col-span-2">
<dt className="text-slate-500"> kế hoạch</dt>
<dd className="mt-1 flex flex-wrap items-center gap-2">
{/* 🔴 CHỈ link khi phiếu cha còn đọc được. `maKeHoach === null` ⇔ phiếu đã xoá
mềm (BE `ContractFeatures.cs:738-740` lấy mã qua query CÓ global filter), mà
màn `/khkk/:id` đọc qua `GetContractSigningPlanQuery`
(`ContractSigningPlanFeatures.cs:957-962`) cũng KHÔNG `IgnoreQueryFilters`
⇒ 404. Link tới đó = ngõ cụt. Trạng thái này nói bằng CHỮ NHÌN THẤY ĐƯỢC,
không giấu sau tooltip. */}
{src.maKeHoach != null ? (
<Link
// `?group=` KHÔNG phải param chết: `KhkkDetailPage.tsx:50-52` (cả 2 app) đọc
// và validate bằng REGEX CHUỖI `/^[1-8]$/` rồi dựng đường "quay lại danh sách"
// `/khkk/list?group=n` ⇒ giữ đúng chỗ đứng + sidebar sáng đúng leaf nhóm.
to={`/khkk/${src.planId}${srcGroup != null ? `?group=${srcGroup}` : ''}`}
className="font-mono font-medium text-violet-600 underline underline-offset-2 hover:text-violet-700"
>
{src.maKeHoach}
</Link>
) : (
<>
<span className="font-mono text-slate-500">(phiếu đã xoá)</span>
<span className="text-[11px] text-slate-500"> không mở đưc phiếu gốc</span>
</>
)}
{srcGroup != null && (
<span className="rounded bg-violet-100 px-2 py-0.5 text-[11px] font-medium text-violet-700">
Nhóm N{srcGroup} · {srcGroupLabel ? srcGroupLabel : 'chưa có trong danh mục'}
</span>
)}
</dd>
</div>
<div className="col-span-2">
<dt className="text-slate-500">Hạng mục đưa vào ({src.tenHangMucs.length})</dt>
<dd className="mt-1">
{src.tenHangMucs.length === 0 ? (
// BE dựng `Source` chỉ khi có ≥1 dòng nối ⇒ nhánh này gần như bất khả; giữ chữ
// trung tính để rỗng-vì-dữ-liệu không trông giống lỗi tải.
<span className="text-slate-400">(không hạng mục nào)</span>
) : (
<ul className="flex flex-wrap gap-1.5">
{src.tenHangMucs.map((ten, i) => (
<li
key={`${i}-${ten}`}
className="rounded border border-violet-100 bg-white px-2 py-0.5 text-[12px] text-slate-700"
>
{ten}
</li>
))}
</ul>
)}
</dd>
</div>
<div>
<dt className="text-slate-500">Σ giá duyệt chốt</dt>
{/* Dùng ĐÚNG formatter của file (`fmtMoney`) — không đẻ khuôn tiền thứ 2. */}
<dd className="mt-0.5 font-medium text-slate-900">
{src.approvedAmountTotal != null ? fmtMoney(src.approvedAmountTotal) : '—'}
</dd>
</div>
<div>
<dt className="text-slate-500">Phiếu Duyệt NCC gốc</dt>
<dd className="mt-0.5">
{src.purchaseEvaluationId ? (
<Link
to={`/purchase-evaluations/${src.purchaseEvaluationId}`}
className="font-medium text-brand-600 underline underline-offset-2 hover:text-brand-700"
>
Mở phiếu Duyệt NCC
</Link>
) : (
<span className="text-slate-400"></span>
)}
</dd>
</div>
</dl>
</section>
)}
{/* [W6 S187 · YC-023 GĐ3] Màn duyệt V2-trạm — nửa V2 của dual-render.
🔴 ĐỨNG CAO, ngay dưới khối nhận-dạng HĐ: 2 nút duyệt của nhánh V1 vốn nằm ở HEADER
dính (`sticky top-0`), nên nếu nhánh V2 đẩy thao tác duyệt xuống tận cuối trang thì
đúng người-đang-chờ-duyệt lại phải cuộn đi tìm — mất chỗ đứng cũ chứ không phải sắp
xếp lại. Cùng lý do khuôn nguồn KHKK để panel ở cột-3 luôn nhìn thấy.
Vỏ `<section className="rounded-lg border … p-5">` bám đúng khuôn các khối cùng trang
(2 app đều KHÔNG có shadcn `Card`). */}
{isV2 && (
<section className="rounded-lg border border-brand-200 bg-white p-5">
<ContractWorkflowPanel contract={c} />
</section>
)}
<section className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
<MessageSquare className="h-4 w-4" />

View File

@ -0,0 +1,396 @@
// [W6 S187 · YC-023 GĐ3] MÀN DUYỆT V2-TRẠM CỦA HỢP ĐỒNG.
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// KHUÔN NGUỒN: `components/khkk/KhkkWorkflowPanel.tsx` (bản owner-trim S175 — CHỈ
// Duyệt / Trả lại). Cách suy trạng thái bê nguyên: BE trả CÂY THÔ + CẶP CON-TRỎ, panel
// tự suy Done/Current/Pending — KHÔNG có `status` precompute như cặp DTO của Duyệt NCC:
// • `currentWorkflowStepIndex` = INDEX 0-based vào mảng `workflowSteps` (đã sort Order)
// • `currentApprovalLevelOrder` = GIÁ TRỊ `level.order` (KHÔNG phải index)
// Luật so khớp OR-of-N canonical (BE `ContractWorkflowService.cs:264-279`):
// `steps[idx].levels.filter(l => l.order === cur)` rồi `.some(l => l.approverUserId === uid)`
// — N Cấp CÙNG order = OR-of-N, chỉ 1 người ký là tiến. Admin bypass (BE `:268`).
//
// ══ WIRE: KHÔNG CÓ "ENDPOINT V2" RIÊNG ═══════════════════════════════════════════════
// Chỉ có MỘT cửa: `POST /contracts/{id}/transitions` body `{targetPhase, decision, comment}`
// (`ContractsController.cs:64-69` + `:244`). `ApproveV2Async` là **private**, tới được bằng
// DISPATCH trong `TransitionAsync`: `:105` bắt `fromPhase==ChoDuyet && decision==Approve`,
// `:111` `if (contract.ApprovalWorkflowId is Guid awId)` → nhánh V2. Vậy nên:
// • DUYỆT → `{targetPhase: ChoDuyet(10), decision: Approve(1)}`. Nhánh approve KHÔNG đọc
// `targetPhase` (BE tự tính: lên Cấp → lên Bước → `DaPhatHanh` + gen mã HĐ RG-001);
// vẫn phải gửi giá trị HỢP LỆ vì validator `IsInEnum()` (`ContractFeatures.cs:210`).
// Gửi `ChoDuyet` = trung tính, không hứa hộ BE một phase đích.
// • TRẢ LẠI → `{targetPhase: TraLai(98), decision: Reject(2)}`. Nhánh reject (`:49-66`)
// phân 2 ngả bằng ĐÚNG `targetPhase`: `== TuChoi` ⇒ TuChoi, else ⇒ TraLai. Gửi 98 tường
// minh thay vì "số nào cũng được miễn khác 99".
//
// ══ 3 MẢNH CỦA KHUÔN KHKK CỐ Ý KHÔNG PORT (đo ra, không phải cắt cho gọn) ═════════════
// 1. ❌ Ô-tích "Cấp này KẾT THÚC quy trình": `TransitionContractBody` có ĐÚNG 3 field;
// `skipToFinal` là tham số C# default (`TransitionAsync(..., bool skipToFinal=false)`)
// KHÔNG nằm trên API surface ⇒ vẽ ô-tích = control chết, tích xong không đổi gì.
// 2. ❌ Badge "⚑ Duyệt thay CEO" cho Cấp có `allowApproverFinalize`: `ApproveV2Async` của HĐ
// KHÔNG ĐỌC cờ đó lần nào (`ContractWorkflowService.cs:230-407` — cờ duy nhất nó đọc là
// `AllowApproverSkipToFinal` `:335`, cho đường skip mà FE không gọi được). DTO vẫn trả
// field (`ContractDtos.cs:138`) và chú thích W2 ghi rõ "dùng hay không là quyết của W6".
// Vẽ badge nói "duyệt ở đây là KẾT THÚC" = NÓI DỐI: duyệt xong vẫn chạy tiếp Cấp sau.
// 3. ❌ Banner kết-thúc-sớm + meta `{code} v{NN} · {name}`: `ContractDetail` 0 field
// `endedByLevelFinalize`/`workflowCode`/`workflowVersion`/`workflowName`. Mượn
// `c.workflow.policyName` là ghép nhãn của workflow-policy V1 vào quy trình V2 — hai
// vật khác nhau. Cây thô đã INLINE trong detail ⇒ cũng KHÔNG gọi thêm
// `/approval-workflows-v2/{id}` ("KHÔNG đẻ đường thứ 3" — `ContractDtos.cs:86-88`).
//
// ══ 2 KHỐI BỎ VÌ TRÙNG CHỖ ĐÃ CÓ TRÊN CÙNG TRANG ════════════════════════════════════
// Cả 4 host (`ContractDetailPage` ×2 app · `MyContractsPage` · `ContractsListPage` ·
// `InboxPage`) đều render `ContractDetailContent` KÈM `WorkflowHistoryPanel`, mà panel đó đã
// có "Lịch sử duyệt (N)" + "Lịch sử điều chỉnh". Đính kèm cũng đã có
// `ContractAttachmentsSection` ngay trong `ContractDetailContent` (HĐ không có đường
// `approval-attachments` riêng như KHKK). ⇒ panel này CHỈ gồm: header · sơ đồ Bước→Cấp ·
// banner "đến lượt ai" · 2 nút + dialog.
import { useMemo, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Label } from '@/components/ui/Label'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { useAuth } from '@/contexts/AuthContext'
import {
ApprovalDecision,
ContractPhase,
type ContractDetail,
type ContractLevelOpinion,
type ContractWorkflowLevel,
} from '@/types/contracts'
type NodeStatus = 'Done' | 'Current' | 'Pending'
/** 2 hành động owner chốt @S188 (84). "Từ chối" KHÔNG render — máy BE (`TuChoi=99`) giữ nguyên. */
type PanelAction = 'approve' | 'return'
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('vi-VN')
}
// Pattern 14 (Tailwind JIT): chuỗi class ĐẦY ĐỦ, KHÔNG nội suy mảnh (`bg-${x}-50` bị purge).
const STATUS_DOT: Record<NodeStatus, string> = {
Done: 'bg-emerald-500 text-white',
Current: 'bg-brand-600 text-white',
Pending: 'bg-slate-200 text-slate-500',
}
const STATUS_BOX: Record<NodeStatus, string> = {
Done: 'border-emerald-200 bg-emerald-50/40',
Current: 'border-brand-300 bg-brand-50/50',
Pending: 'border-slate-200 bg-white',
}
const STATUS_ICON: Record<NodeStatus, string> = {
Done: '✓',
Current: '●',
Pending: '○',
}
export function ContractWorkflowPanel({
contract: c,
onChanged,
}: {
contract: ContractDetail
/** gọi sau khi duyệt/trả lại thành công — host refetch thêm theo nhu cầu của nó. */
onChanged?: () => void
}) {
const qc = useQueryClient()
const { user } = useAuth()
const [action, setAction] = useState<PanelAction | null>(null)
const [comment, setComment] = useState('')
const steps = c.workflowSteps ?? []
const curStepIdx = c.currentWorkflowStepIndex
const curLevelOrder = c.currentApprovalLevelOrder
const isWaiting = c.phase === ContractPhase.ChoDuyet
// Terminal thành công của HĐ là `DaPhatHanh` (kèm gen mã RG-001) — KHÔNG phải `DaDuyet`
// như Duyệt NCC / KHKK (`ContractWorkflowService.cs:392`).
const isIssued = c.phase === ContractPhase.DaPhatHanh
// Chữ ký thật của từng Cấp: tra `levelOpinions` — KHÔNG tra `approvals` như khuôn KHKK.
// 🔴 `ContractApproval` (`types/contracts.ts:103-112`) KHÔNG mang `approvalWorkflowLevelId`
// (BE nhét "[Bước n — Cấp m]" vào chuỗi `comment` — `ContractWorkflowService.cs:291`), nên
// đọc từ đó là phải parse chuỗi. `ContractLevelOpinion` mang thẳng khoá Level và được
// `ApproveV2Async` UPSERT ngay trong cùng giao dịch duyệt (`:305-329`) ⇒ đúng nguồn.
const signedByLevelId = useMemo(() => {
const m = new Map<string, ContractLevelOpinion>()
for (const o of c.levelOpinions ?? []) {
if (!m.has(o.approvalWorkflowLevelId)) m.set(o.approvalWorkflowLevelId, o)
}
return m
}, [c.levelOpinions])
const stepStatus = (idx: number): NodeStatus => {
if (isIssued) return 'Done'
if (!isWaiting || curStepIdx == null) return 'Pending'
if (idx < curStepIdx) return 'Done'
if (idx === curStepIdx) return 'Current'
return 'Pending'
}
const levelStatus = (idx: number, order: number): NodeStatus => {
const s = stepStatus(idx)
if (s !== 'Current' || curLevelOrder == null) return s
if (order < curLevelOrder) return 'Done'
if (order === curLevelOrder) return 'Current'
return 'Pending'
}
// 🔴 OR-of-N: mọi Cấp CÙNG `order` trong Bước hiện tại đều là người được duyệt lượt này.
const inRange = curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length
const currentStep = isWaiting && inRange ? steps[curStepIdx!] : null
const currentLevels: ContractWorkflowLevel[] =
currentStep != null && curLevelOrder != null
? currentStep.levels.filter(l => l.order === curLevelOrder)
: []
const isAdmin = user?.roles?.includes('Admin') ?? false
const actorIsCurrentApprover = !!user?.id && currentLevels.some(l => l.approverUserId === user.id)
const actorInLevel = isAdmin || actorIsCurrentApprover
const blockedByLevel = isWaiting && !actorInLevel
const transition = useMutation({
mutationFn: async (a: PanelAction) => {
// Xem khối WIRE ở đầu file: cặp (targetPhase, decision) LÀ thứ chọn nhánh trong
// `TransitionAsync`, không phải một tên endpoint riêng.
const body = a === 'approve'
? { targetPhase: ContractPhase.ChoDuyet, decision: ApprovalDecision.Approve, comment: comment.trim() || null }
: { targetPhase: ContractPhase.TraLai, decision: ApprovalDecision.Reject, comment: comment.trim() || null }
await api.post(`/contracts/${c.id}/transitions`, body)
},
onSuccess: (_res, a) => {
toast.success(a === 'approve' ? 'Đã duyệt cấp hiện tại.' : 'Đã trả lại người soạn sửa.')
setAction(null)
setComment('')
// `['contract', id]` = queryKey detail của CẢ 4 host (`ContractDetailPage` ×2 app,
// `MyContractsPage`, `ContractsListPage`, `InboxPage`). 3 key còn lại là prefix của
// các danh sách — TanStack khớp theo tiền tố nên không cần liệt đủ tham số lọc.
qc.invalidateQueries({ queryKey: ['contract', c.id] })
qc.invalidateQueries({ queryKey: ['my-contracts'] })
qc.invalidateQueries({ queryKey: ['contracts'] })
qc.invalidateQueries({ queryKey: ['inbox'] })
qc.invalidateQueries({ queryKey: ['contract-changelogs', c.id] })
qc.invalidateQueries({ queryKey: ['pipeline-contract-index'] })
onChanged?.()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const commentRequired = action === 'return'
const confirmDisabled = transition.isPending || (commentRequired && !comment.trim())
// Câu giải thích khi nút bị khoá. 🔴 [S182 MAJOR-1] KHÔNG đặt `title` lên chính
// `<button disabled>`: Chromium nuốt sự kiện chuột của form-control disabled ⇒ tooltip
// KHÔNG BAO GIỜ bật. Cắm lên `<span>` bọc ngoài (không disabled). Và thông tin PHÂN BIỆT
// không nằm sau hover — banner "đến lượt ai" ngay trên đã nói bằng chữ nhìn-thấy-được.
const blockedHint = 'Cấp hiện tại chỉ người được phân công (hoặc Admin) mới thao tác được.'
return (
<div className="space-y-4">
{/* ── 1. Header ───────────────────────────────────────────────────────── */}
<div>
<h3 className="text-sm font-semibold text-slate-900">Quy trình duyệt</h3>
<p className="mt-0.5 text-[11px] text-slate-500">
Bước = Phòng · Cấp = nhân viên duyệt. Nhiều nhân viên cùng một Cấp thì chỉ cần MỘT
người duyệt sang Cấp kế.
</p>
</div>
{/* ── 2. Sơ đồ Bước → Cấp → NV ────────────────────────────────────────── */}
{steps.length > 0 ? (
<ol className="space-y-2">
{steps.map((step, idx) => {
const st = stepStatus(idx)
// Gom Cấp CÙNG `order` thành MỘT dòng — OR-of-N: N người, chỉ 1 chữ ký là tiến.
const orders = [...new Set(step.levels.map(l => l.order))].sort((a, b) => a - b)
return (
<li key={step.id} className={cn('rounded-md border p-2', STATUS_BOX[st])}>
<div className="flex flex-wrap items-center gap-1.5 text-xs font-medium">
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-bold', STATUS_DOT[st])}>
{STATUS_ICON[st]}
</span>
<span className="text-slate-800">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>
)}
</div>
{orders.length > 0 && (
<ul className="ml-7 mt-1.5 space-y-1 border-l-2 border-violet-200 pl-2">
{orders.map(order => {
const group = step.levels.filter(l => l.order === order)
const ls: NodeStatus = levelStatus(idx, order)
return (
<li key={`${step.id}-${order}`} className="text-[11px]">
<div className="flex items-start gap-1.5">
<span className={cn('mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] font-bold', STATUS_DOT[ls])}>
{STATUS_ICON[ls]}
</span>
<div className="min-w-0 flex-1">
<div className="font-medium text-slate-700">
{group[0]?.name || `Cấp ${order}`}
{ls === 'Current' && <span className="ml-1.5 text-[10px] font-normal text-brand-700">đang chờ</span>}
{ls === 'Done' && <span className="ml-1.5 text-[10px] font-normal text-emerald-600">đã duyệt</span>}
</div>
{/* Liệt-kê NV của cấp: người ĐÃ KÝ in đậm, NV cùng cấp còn lại mờ. */}
<div className="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-slate-500">
{group.length === 0
? '(chưa cấu hình)'
: group.map((l, i) => {
const sign = signedByLevelId.get(l.id)
return (
<span key={l.id} className="inline-flex items-center gap-1">
{i > 0 && <span className="text-slate-300">/</span>}
<span className={cn(sign ? 'font-semibold text-slate-800' : 'text-slate-400')}>
{l.approverFullName ?? '(chưa cấu hình)'}
</span>
{sign && (
<span className="text-[10px] text-emerald-600"> {formatDateTime(sign.signedAt)}</span>
)}
</span>
)
})}
</div>
</div>
</div>
</li>
)
})}
</ul>
)}
</li>
)
})}
</ol>
) : (
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] text-slate-600">
Quy trình duyệt của loại này chưa cấu hình Bước/Cấp liên hệ Admin dựng quy trình
trước khi trình duyệt.
</div>
)}
{/* ── 3. Banner "đến lượt ai" ─────────────────────────────────────────── */}
{isWaiting && currentStep && (
<div className={cn(
'rounded border px-3 py-2 text-[11px]',
actorInLevel ? 'border-emerald-200 bg-emerald-50 text-emerald-800' : 'border-amber-300 bg-amber-50 text-amber-800',
)}>
<div className="font-medium">
Đang chờ Bước {currentStep.order} ({currentStep.name}) Cấp {curLevelOrder}
</div>
<div className="mt-0.5">
Người duyệt: {currentLevels.map(l => l.approverFullName ?? '(chưa rõ)').join(' / ') || '(chưa có)'}
</div>
{actorInLevel
? <div className="mt-0.5 font-medium"> Đến lượt bạn duyệt</div>
: <div className="mt-0.5"> Không phải lượt bạn chỉ người trên mới thao tác cấp này</div>}
</div>
)}
{/* HĐ đang chờ duyệt mà cặp con-trỏ trỏ ra ngoài cây (quy trình bị sửa sau khi trình,
hoặc dữ liệu cũ) — nói bằng chữ thay vì im lặng không hiện banner nào. */}
{isWaiting && steps.length > 0 && !currentStep && (
<div className="rounded border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
Không xác đnh đưc cấp đang chờ (con trỏ Bước {String(curStepIdx ?? '—')} nằm ngoài
quy trình đã pin). Liên hệ Admin kiểm tra lại quy trình của loại này.
</div>
)}
{c.phase === ContractPhase.TraLai && (
<div className="rounded border border-orange-300 bg-orange-50 px-3 py-2 text-[11px] text-orange-800">
bị TRẢ LẠI sửa nội dung rồi gửi duyệt lại, quy trình chạy lại từ Bước 1 · Cấp 1.
</div>
)}
{c.phase === ContractPhase.TuChoi && (
<div className="rounded border border-red-300 bg-red-50 px-3 py-2 text-[11px] text-red-800">
đã bị TỪ CHỐI không thao tác đưc nữa.
</div>
)}
{/* ── 4. Hành động — ĐÚNG 2 NÚT (owner @S188 (84)) ─────────────────────── */}
{/* 🔴 "Từ chối" KHÔNG có đường bấm ở đây, giống KHKK sau khi anh trim S174. Máy BE
(`ApprovalDecision.Reject` + `targetPhase=TuChoi` ⇒ phase `TuChoi=99`) GIỮ NGUYÊN:
HĐ cũ đã từ-chối vẫn hiển thị đúng, và mở lại sau này chỉ là thêm nút. */}
{isWaiting && (
<div>
<Label className="text-xs">Hành đng:</Label>
<div className="mt-1 flex flex-wrap gap-1.5">
<span title={blockedByLevel ? blockedHint : undefined}>
<button
type="button"
disabled={blockedByLevel}
onClick={() => { if (!blockedByLevel) { setComment(''); setAction('approve') } }}
className={cn(
'rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-emerald-300 text-emerald-700 hover:bg-emerald-50',
)}
>
Duyệt
</button>
</span>
<span title={blockedByLevel ? blockedHint : undefined}>
<button
type="button"
disabled={blockedByLevel}
onClick={() => { if (!blockedByLevel) { setComment(''); setAction('return') } }}
className={cn(
'rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-amber-300 text-amber-700 hover:bg-amber-50',
)}
>
Trả lại
</button>
</span>
</div>
</div>
)}
{action !== null && (
<Dialog
open
onClose={() => setAction(null)}
title={action === 'approve' ? '✓ Duyệt hợp đồng' : '← Trả lại người soạn sửa'}
footer={<>
<Button variant="outline" onClick={() => setAction(null)}>Hủy</Button>
<Button onClick={() => transition.mutate(action)} disabled={confirmDisabled}>
{transition.isPending ? 'Đang gửi...' : 'Xác nhận'}
</Button>
</>}
>
{action === 'return' && (
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
về trạng thái &ldquo;Trả lại&rdquo;. Người soạn sửa xong gửi lại thì quy trình chạy
từ Bước 1 · Cấp 1.
</div>
)}
<Label>
Ghi chú{commentRequired ? <span className="text-red-500"> *</span> : ' (tùy chọn)'}
</Label>
<Textarea
value={comment}
onChange={e => setComment(e.target.value)}
rows={3}
// Trần 1000 ký tự = ĐÚNG luật BE (`TransitionContractCommandValidator`
// `ContractFeatures.cs:212` `MaximumLength(1000)`) — KHÔNG chép 2000 của KHKK sang,
// gõ quá là 400 ở lúc bấm Xác nhận.
maxLength={1000}
placeholder={commentRequired ? 'Nêu lý do trả lại...' : 'Ghi chú của bạn (ghi vào phiếu)...'}
/>
{action === 'approve' && (
<p className="mt-1 text-[11px] text-slate-500">
Ghi chú sẽ đưc ghi vào mục &ldquo;Ý kiến cấp duyệt (Quy trình V2)&rdquo; của
(1 ô/Cấp duyệt lại thì ghi đè ô ). Bỏ trống thì hệ thống ghi
&ldquo;(duyệt không ý kiến)&rdquo;.
</p>
)}
</Dialog>
)}
</div>
)
}

View File

@ -12,7 +12,7 @@
// hiển thị Chi tiết section.
import { useState, useMemo, type FormEvent, useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom'
import { Link, useSearchParams } from 'react-router-dom'
import { FileText, Plus, Search, Save, Pencil, Trash2, Lock } from 'lucide-react'
import { toast } from 'sonner'
import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab'
@ -35,6 +35,9 @@ import {
type ContractDetail,
type ContractListItem,
} from '@/types/contracts'
// [W5 S187 vế (88)] Nhãn 8 nhóm duyệt KHKK — IMPORT single-source (`types/khkk.ts:140-149`),
// KHÔNG chép chuỗi.
import { KHKK_APPROVAL_GROUP_LABELS } from '@/types/khkk'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
@ -541,6 +544,14 @@ function ContractEditForm({
onSaved: () => void
}) {
const isDraft = contract.phase === ContractPhase.DangSoanThao
// ===== [W5 S187 vế (88)] Ruột pre-fill: HĐ sinh từ cầu KHKK→HĐ mang sẵn 15 field do BE gán
// (`CreateContractFromSigningPlanFeatures.cs:186-207`). Màn sửa Nháp này TRƯỚC W5 chỉ hiện
// 11/15; 4 field còn lại (Phòng ban · Người soạn · SLA · nguồn KHKK) có ĐỦ trong `ContractDetail`
// đang tải sẵn nhưng KHÔNG được vẽ ⇒ người sửa Nháp không thấy HĐ này đẻ từ đâu. Dải chỉ-đọc
// dưới đây bù đúng 4 field đó, 0 request mới, 0 field bịa. (Nợ còn lại → §5 sub-file W5.)
const src = contract.source
const srcGroup = src?.approvalGroup ?? null
const srcGroupLabel = srcGroup != null ? KHKK_APPROVAL_GROUP_LABELS[srcGroup] : undefined
const [templateId, setTemplateId] = useState(contract.templateId ?? '')
const [giaTri, setGiaTri] = useState(String(contract.giaTri ?? 0))
const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '')
@ -610,6 +621,49 @@ function ContractEditForm({
</div>
)}
{/* [W5 S187 vế (88)] Dải CHỈ-ĐỌC — dữ liệu HĐ mang sẵn mà form không có ô nhập.
🔴 Đặt NGOÀI `grid grid-cols-2` bên dưới CÓ CHỦ ĐÍCH: nhét thêm ô con vào grid đó
làm sibling auto-place trượt cột (bài S182) — dải này là 1 khối phẳng, 0 rủi ro.
Dòng 1 chỉ hiện khi HĐ có nguồn KHKK (`source != null`, loose — bắt cả `undefined`
của cache cũ); dòng 2 hiện LUÔN vì cả 3 field đều do BE gán ở MỌI đường tạo HĐ. */}
<div className="mb-4 space-y-1.5 rounded-md border border-violet-100 bg-violet-50/60 px-3 py-2 text-xs">
{src != null && (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-slate-700">
<span className="font-medium text-violet-700">Kế thừa từ Kế hoạch kết:</span>
{/* Chỉ link khi phiếu cha còn đọc được — `maKeHoach === null` ⇔ phiếu đã xoá mềm,
mà `/khkk/:id` không `IgnoreQueryFilters` ⇒ 404 (đo ở §1 sub-file W5). */}
{src.maKeHoach != null ? (
<Link
to={`/khkk/${src.planId}${srcGroup != null ? `?group=${srcGroup}` : ''}`}
className="font-mono font-medium text-violet-600 underline underline-offset-2 hover:text-violet-700"
>
{src.maKeHoach}
</Link>
) : (
<span className="font-mono text-slate-500">(phiếu đã xoá không mở đưc phiếu gốc)</span>
)}
{srcGroup != null && (
<span className="rounded bg-violet-100 px-1.5 py-0.5 font-medium text-violet-700">
N{srcGroup} · {srcGroupLabel ? srcGroupLabel : 'chưa có trong danh mục'}
</span>
)}
<span>· {src.tenHangMucs.length} hạng mục</span>
{src.approvedAmountTotal != null && (
<span>
· Σ giá duyệt chốt <strong className="text-slate-900">{fmtMoney(src.approvedAmountTotal)} đ</strong>
</span>
)}
</div>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-slate-600">
<span>Phòng ban: <strong className="text-slate-800">{contract.departmentName ?? '—'}</strong></span>
<span>Người soạn: <strong className="text-slate-800">{contract.drafterName ?? '—'}</strong></span>
<span className="flex items-center gap-1">
SLA: <SlaTimer deadline={contract.slaDeadline} createdAt={contract.createdAt} />
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Loại </Label>