[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

@ -0,0 +1,80 @@
// [W6 S187 · YC-023/YC-024] HỌ QUY TRÌNH DUYỆT HỢP ĐỒNG — bản mirror FE của
// `SolutionErp.Domain/Contracts/ContractApprovalWorkflowFamily.cs` (NHÀ DUY NHẤT phía BE).
// Owner chốt @S188: (80) trục = 7 LOẠI HĐ · (81) PA-A mỗi loại MỘT mã quy trình riêng, 0 cột
// mới trong bảng quy trình — đúng khuôn `KHKK-N{n}` đã qua UAT.
//
// ══ VÌ SAO LÀ FILE RIÊNG, KHÔNG PHẢI HẰNG CỤC BỘ TRONG TRANG DESIGNER ═══════════════════
// Có ĐÚNG HAI chỗ đọc, và chúng ở hai tầng khác nhau:
// 1. `pages/system/ApprovalWorkflowsV2Page.tsx` — cắt lát `history` theo `Code` để panel của
// loại này không hiện quy trình của 6 loại kia;
// 2. `components/Layout.tsx` `resolvePath` — đổi menu-key `AwV2_Hd*` thành đường dẫn
// ("chỗ thứ 4" Pattern 16-bis; thiếu là leaf DROP IM LẶNG khỏi sidebar — gotcha #50).
// Chép 2 bản thì thêm loại HĐ thứ 8 phải sửa 2 nơi và LỆCH ĐƯỢC CÂM (menu trỏ tới `:typeCode`
// mà trang không nhận ⇒ rơi về lưới landing, trông y như "chưa cấu hình quy trình"). BE cố ý
// dựng để "thêm loại chỉ sửa 1 nơi"; FE giữ đúng tính chất đó.
//
// 🔴 fe-admin ONLY — KHÔNG-CẶP CÓ CHỦ ĐÍCH. fe-user không có màn Designer (Layout bên đó 0
// nhánh `AwV2_*`, App.tsx 0 route `/system/approval-workflows-v2`). Đừng "vá" thành mirror
// ×2 app ở lượt sau.
/**
* `ApprovalWorkflowApplicableType.Contract` — CẢ 7 mã dưới đây đều là loại này.
* (Cùng số 3 mà `TYPE_CODE_TO_INT.Contract` đang dùng cho deep-link `AwV2_Contract` cũ:
* panel "cả loại Hợp đồng" và 7 panel per-loại đọc CÙNG một bucket của overview API.)
*/
export const AW_TYPE_INT_CONTRACT = 3
/** Tiền tố chung của cả họ — tách hằng để `contractWfKeyToken` cắt đúng độ dài (mirror `CodePrefix`). */
const CODE_PREFIX = 'HD-'
export type ContractWfFamilyMember = {
/** Mã quy trình trong DB (`ApprovalWorkflow.Code`) — ASCII HOA, không dấu. */
code: string
/**
* 🔴 Đây là enum **ContractType** (1..7, nhãn tra `ContractTypeLabel` ở `types/forms.ts`),
* KHÔNG phải `ApplicableType`. Hai trục trùng số 3 một cách tình cờ
* (`ContractType.HopDongNhaCungCap = 3` ⟂ `ApplicableType.Contract = 3`) — đừng dùng lẫn.
*/
contractType: number
}
/**
* 7 thành viên — thứ tự = `ContractType` 1..7 = thứ tự leaf trên sidebar (BE seed
* `SeedMenuTreeAsync` Order 11..17). Giữ nguyên thứ tự này để hai bên không lệch mắt.
*/
export const CONTRACT_WF_FAMILY: readonly ContractWfFamilyMember[] = [
{ code: 'HD-TP', contractType: 1 },
{ code: 'HD-GK', contractType: 2 },
{ code: 'HD-NCC', contractType: 3 },
{ code: 'HD-DV', contractType: 4 },
{ code: 'HD-MB', contractType: 5 },
{ code: 'HD-NT-NCC', contractType: 6 },
{ code: 'HD-NT-DV', contractType: 7 },
]
/**
* QUY TẮC ĐẶT KEY MENU (mirror `ContractApprovalWorkflowFamily.KeyToken`): bỏ tiền tố `HD-`,
* bỏ mọi dấu `-`, GIỮ NGUYÊN hoa/thường phần còn lại ⇒ `HD-TP`→`TP`, `HD-NT-NCC`→`NTNCC`.
* SUY TỪ MÃ chứ không khai thêm cột: mã đổi thì token đổi theo, không có đường lệch câm.
*/
export function contractWfKeyToken(code: string): string {
const rest = code.startsWith(CODE_PREFIX) ? code.slice(CODE_PREFIX.length) : code
return rest.replace(/-/g, '')
}
/**
* Đoạn `:typeCode` trên URL Designer = phần sau `AwV2_` của menu-key BE
* (`MenuKeys.ApprovalWorkflowContractLeaf` = `AwV2_Hd{token}`) ⇒ `HD-NT-NCC` → `HdNTNCC`.
*/
export const contractWfTypeCode = (code: string) => `Hd${contractWfKeyToken(code)}`
/**
* Tra ngược từ `:typeCode` trên URL / phần sau `AwV2_` của menu-key.
* 🔴 Tra bằng BẢNG, không cắt chuỗi ngược: `HdNTNCC` chẻ ra `NT-NCC` hay `N-TNCC` là hai mã
* khác nhau và chỉ một cái tồn tại — đoán ngược là đẻ mã ma.
* `null` = không thuộc họ ⇒ caller rơi về nhánh cũ (`TYPE_CODE_TO_INT`), KHÔNG tự nhận bừa.
*/
export function findContractWfByTypeCode(typeCode: string | null | undefined): ContractWfFamilyMember | null {
if (!typeCode) return null
return CONTRACT_WF_FAMILY.find(m => contractWfTypeCode(m.code) === typeCode) ?? null
}

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>

View File

@ -34,6 +34,11 @@ import type { Department, Paged } from '@/types/master'
// [W2 S182] 2 hàm dùng chung với màn nghiệp vụ (fe-user) — KHÔNG chép chuỗi
// `KHKK-N{n}` / nhãn nhóm vào file này, để 1 nguồn duy nhất khi BE đổi.
import { khkkGroupMenuLabel, khkkGroupWorkflowCode } from '@/types/khkk'
// [W6 S187] Họ 7 quy trình duyệt HĐ (type-3). Cùng lý do như trên: mã `HD-*` KHÔNG gõ vào
// file này — bảng mirror ở `lib/contractWorkflowFamily.ts`, dùng chung với `Layout.resolvePath`.
import { AW_TYPE_INT_CONTRACT, findContractWfByTypeCode } from '@/lib/contractWorkflowFamily'
// Nhãn 7 loại HĐ — tra bảng SẴN CÓ, không đẻ bảng nhãn thứ hai trong file này.
import { ContractTypeLabel } from '@/types/forms'
// ===== Types (mirror BE AwAdminOverviewDto) =====
@ -301,9 +306,18 @@ export function ApprovalWorkflowsV2Page() {
// TRÙNG NHAU ⇒ 1 lần tải dùng chung, và invalidate sau khi lưu làm tươi cả 9 màn.
const khkkGroupMatch = typeCode?.match(/^KhkkN([1-8])$/) ?? null
const khkkGroup = khkkGroupMatch ? Number(khkkGroupMatch[1]) : null
// [W6 S187 · YC-023] HỌ THỨ HAI cùng cơ chế: 7 leaf `AwV2_Hd*` deep-link về CHÍNH trang này
// với typeCode `Hd{TOKEN}` (`Layout.tsx` — chỗ-thứ-4 Pattern 16-bis). `HdTP`/`HdNTNCC`… KHÔNG
// phải ApplicableType riêng: cả 7 loại đều là type 3 (Hợp đồng), phân biệt nhau bằng MÃ quy
// trình `HD-TP`/`HD-NT-NCC`… ⇒ y hệt quan hệ `KhkkN{n}` ⟂ type 10.
const contractFamily = findContractWfByTypeCode(typeCode)
const selectedTypeInt = khkkGroup !== null
? TYPE_INT_CONTRACT_SIGNING_PLAN
: typeCode ? TYPE_CODE_TO_INT[typeCode] : null
: contractFamily !== null
? AW_TYPE_INT_CONTRACT
: typeCode ? TYPE_CODE_TO_INT[typeCode] : null
const overview = useQuery({
queryKey: ['approval-workflow-v2-overview', selectedTypeInt],
@ -317,61 +331,93 @@ export function ApprovalWorkflowsV2Page() {
? overview.data?.types.find(t => t.applicableType === selectedTypeInt) ?? null
: null
// [W2b S182] Lát cắt TỔNG HỢP cho panel nhóm. BE trả 1 bucket cho CẢ type 10
// (`active` = 1-trong-8 quy trình nhóm, tuỳ bản nào kích hoạt sau cùng), nên
// panel nhóm KHÔNG được đọc `bucket.active` — sẽ hiện nhầm quy trình của nhóm khác.
// [W2b S182 · mở rộng W6 S187] MÃ quy trình mà panel đang xem — `null` = panel theo LOẠI
// (xem cả bucket, hành vi từ Mig 22). Hai HỌ per-Code dùng CHUNG một đường, không đẻ nhánh
// cắt thứ hai: KHKK `KHKK-N{n}` (type 10) · HĐ `HD-*` (type 3).
const familyCode = khkkGroup !== null
? khkkGroupWorkflowCode(khkkGroup)
: contractFamily?.code ?? null
// [W2b S182] Lát cắt TỔNG HỢP cho panel per-Code. BE trả 1 bucket cho CẢ loại
// (`active` = 1-trong-N quy trình của họ, tuỳ bản nào kích hoạt sau cùng), nên
// panel per-Code KHÔNG được đọc `bucket.active` — sẽ hiện nhầm quy trình của mã khác.
// Cắt lại từ `history`:
// 🔴 `history` là DANH SÁCH ĐẦY ĐỦ, KỂ CẢ bản đang áp dụng (verified BE
// `ApprovalWorkflowV2AdminFeatures.cs:206-211` — không lọc `!IsActive`);
// 🔴 `history` là DANH SÁCH ĐẦY ĐỦ, KỂ CẢ bản đang áp dụng (verified BE — `AwTypeSummaryDto`
// chú thích `History` = "KHO PHIÊN BẢN — MỌI version của loại, kể cả bản đang áp dụng";
// trỏ TÊN field thay vì số dòng vì khối đó vừa đổi ở W1b);
// chính `TypePanel` mới lọc `!isActive` lúc render mục "Lịch sử versions".
// · `active` suy TỪ chính lát cắt (`find(v => v.isActive)`), không mượn ô ngoài.
// Giữ nguyên shape `TypeSummaryDto` ⇒ `TypePanel`/`Designer` không cần biết
// chúng đang xem một nhóm hay cả loại.
// chúng đang xem một mã hay cả loại.
//
// 🔴 [W6 S187 — QUYẾT F-3, phương án (ii)] KHÔNG đọc `ActiveOthers` mà W1b thêm vào DTO.
// Bài toán F-3 ("type-3 nhiều bản cùng IsActive ⇒ Designer giấu N-1 bản") tan ngay khi
// cắt theo `Code`: mỗi panel chỉ còn ĐÚNG 1 bản active của chính mã đó, N-1 bản kia thuộc
// về panel của mã khác chứ không "bị giấu". Đọc thêm `activeOthers` ở đây sẽ kéo quy trình
// của 6 loại HĐ khác vào panel này — đúng cái lỗi lát cắt sinh ra để chặn.
// ⚠️ Hệ quả cho BE (NGOÀI lane FE, báo lead): sau bản này `ActiveOthers` KHÔNG CÒN consumer
// FE nào — gỡ field + re-check test W1b là việc của `implementer-backend`, đừng tưởng đã xong.
const currentType: TypeSummaryDto | null = useMemo(() => {
if (khkkGroup === null) return typeBucket
if (familyCode === null) return typeBucket
if (!typeBucket) return null
const wantCode = khkkGroupWorkflowCode(khkkGroup)
const versions = typeBucket.history.filter(d => d.code === wantCode)
const versions = typeBucket.history.filter(d => d.code === familyCode)
return {
applicableType: typeBucket.applicableType,
applicableTypeLabel: typeBucket.applicableTypeLabel,
active: versions.find(v => v.isActive) ?? null,
history: versions,
}
}, [khkkGroup, typeBucket])
}, [familyCode, typeBucket])
// [W2c S182] Mã quy trình của panel nhóm là KHOÁ, không phải gợi ý: mỗi nhóm đúng
// MỘT chuỗi phiên bản `KHKK-N{n}`. Gõ mã khác trên panel nhóm = đẻ quy trình mồ côi
// (phiếu nhóm đó vẫn tìm `KHKK-N{n}` `KhkkCreatePage.tsx:113`) ⇒ khoá thẳng ô Mã.
const lockedCode = khkkGroup !== null ? khkkGroupWorkflowCode(khkkGroup) : null
// [W2c S182 · mở rộng W6 S187] Mã quy trình của panel per-Code là KHOÁ, không phải gợi ý:
// mỗi thành viên của họ đúng MỘT chuỗi phiên bản. Gõ mã khác trên panel này = đẻ quy trình
// mồ côi — KHKK: phiếu nhóm đó vẫn tìm `KHKK-N{n}` (`KhkkCreatePage.tsx:113`); HĐ: BE khoá
// CỨNG theo (86) — `ContractWorkflowGuards.EnsureFamilyLock` bắt HĐ loại X phải pin đúng
// `ContractApprovalWorkflowFamily.CodeFor(X)`, mã lệch thì không HĐ nào pin được nó.
const lockedCode = familyCode
// [W2h/D4 S182 · mở rộng W6 S187] Vào từ leaf per-Code thì tiêu đề phải nói ĐÚNG mã nào —
// `applicableTypeLabel` của BE chỉ có MỘT chuỗi chung cho cả họ ("Kế hoạch ký kết HĐ" cho 8
// nhóm, "Hợp đồng" cho 7 loại) ⇒ để nguyên thì 15 panel trông y hệt nhau.
// 🔴 Truthy-check chứ không dùng thẳng `ContractTypeLabel[n]`: `Record<number, string>` trả
// `string` cho MỌI số, nên loại nằm ngoài bảng nhãn sẽ in ra `undefined` giữa tiêu đề thay vì
// rơi về nhãn của BE (cùng lý do `khkkGroupMenuLabel` phải tự kiểm — `types/khkk.ts`).
const panelLabel = khkkGroup !== null
? khkkGroupMenuLabel(khkkGroup)
: contractFamily !== null
? ContractTypeLabel[contractFamily.contractType] ?? currentType?.applicableTypeLabel ?? null
: currentType?.applicableTypeLabel ?? null
const panelDescription = khkkGroup !== null
? `${currentType?.applicableTypeLabel ?? 'Kế hoạch ký kết HĐ'} — nhóm duyệt N${khkkGroup}. Mã quy trình khoá theo nhóm: ${lockedCode}; mỗi lần lưu là một phiên bản mới của CHÍNH nhóm này, 7 nhóm còn lại không đổi.`
: contractFamily !== null
// Câu cuối KHÔNG phải trang trí: nó nói vì sao ô Mã bị khoá — HĐ loại này tự pin đúng mã
// đó lúc trình duyệt, quy trình mang mã khác thì không HĐ nào với tới được.
? `${currentType?.applicableTypeLabel ?? 'Hợp đồng'}${panelLabel}. Mã quy trình khoá theo loại HĐ: ${lockedCode}; mỗi lần lưu là một phiên bản mới của CHÍNH loại này, 6 loại còn lại không đổi. Hợp đồng loại này tự pin đúng mã trên khi trình duyệt.`
: currentType
? 'Mỗi Bước = 1 Phòng. Mỗi Cấp trong Bước = 1 nhân viên cụ thể duyệt. Tuần tự: Cấp 1 → Cấp 2 → ... → Bước kế.'
: 'Schema mới UAT — chọn loại quy trình từ menu bên trái.'
return (
<div className="p-6">
<PageHeader
// [W2h/D4 S182] Vào từ leaf nhóm thì tiêu đề phải nói ĐÚNG nhóm nào —
// `applicableTypeLabel` của BE chỉ có một chuỗi chung cho cả 8 nhóm
// ("Kế hoạch ký kết HĐ"), 8 panel sẽ trông y hệt nhau.
title={
<span className="flex items-center gap-2">
<Workflow className="h-5 w-5" />
{khkkGroup !== null
? `Quy trình duyệt (Mới): ${khkkGroupMenuLabel(khkkGroup)}`
: currentType
? `Quy trình duyệt (Mới): ${currentType.applicableTypeLabel}`
: 'Quy trình duyệt (Mới)'}
{panelLabel ? `Quy trình duyệt (Mới): ${panelLabel}` : 'Quy trình duyệt (Mới)'}
</span>
}
description={
khkkGroup !== null
? `${currentType?.applicableTypeLabel ?? 'Kế hoạch ký kết HĐ'} — nhóm duyệt N${khkkGroup}. Mã quy trình khoá theo nhóm: ${lockedCode}; mỗi lần lưu là một phiên bản mới của CHÍNH nhóm này, 7 nhóm còn lại không đổi.`
: currentType
? 'Mỗi Bước = 1 Phòng. Mỗi Cấp trong Bước = 1 nhân viên cụ thể duyệt. Tuần tự: Cấp 1 → Cấp 2 → ... → Bước kế.'
: 'Schema mới UAT — chọn loại quy trình từ menu bên trái.'
}
description={panelDescription}
/>
{overview.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{/* Lưới landing (không có `:typeCode`) = THẺ TÓM TẮT theo ApplicableType.
⚠️ KHAI THẲNG giới hạn còn lại: với 2 loại per-Code-family (10 KHKK · 3 HĐ) thẻ này in
`t.active` = MỘT trong N bản đang áp dụng và `t.history.length` = version của CẢ họ —
nó tóm tắt LOẠI, không tóm tắt từng mã. Đây là hình dạng có từ S182 (KHKK), W6 không
đổi: đường vào thật của admin là 15 leaf per-Code ở sidebar, mỗi leaf mở đúng panel đã
cắt theo `Code` nên không có bản nào bị giấu trên đường người dùng đi. */}
{overview.data && !currentType && (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{overview.data.types.map(t => (

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>