[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" />