[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
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:
@ -4,12 +4,16 @@
|
|||||||
// approval history live separately in WorkflowHistoryPanel (Panel 3).
|
// approval history live separately in WorkflowHistoryPanel (Panel 3).
|
||||||
import { useState, type FormEvent } from 'react'
|
import { useState, type FormEvent } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
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 { toast } from 'sonner'
|
||||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||||
import { SlaTimer } from '@/components/SlaTimer'
|
import { SlaTimer } from '@/components/SlaTimer'
|
||||||
import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection'
|
import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection'
|
||||||
import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab'
|
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 { Button } from '@/components/ui/Button'
|
||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
import { Textarea } from '@/components/ui/Textarea'
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
@ -23,6 +27,10 @@ import {
|
|||||||
type ContractDetail,
|
type ContractDetail,
|
||||||
} from '@/types/contracts'
|
} from '@/types/contracts'
|
||||||
import { ContractTypeLabel } from '@/types/forms'
|
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 fmt = (s: string) => new Date(s).toLocaleString('vi-VN')
|
||||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') + ' VND'
|
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') + ' VND'
|
||||||
@ -71,6 +79,34 @@ export function ContractDetailContent({
|
|||||||
|
|
||||||
const availableTargets = c.workflow?.nextPhases ?? []
|
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) {
|
function openAction(decisionType: number) {
|
||||||
const targets = c.workflow?.nextPhases ?? []
|
const targets = c.workflow?.nextPhases ?? []
|
||||||
const defaultTarget = decisionType === ApprovalDecision.Reject
|
const defaultTarget = decisionType === ApprovalDecision.Reject
|
||||||
@ -102,7 +138,9 @@ export function ContractDetailContent({
|
|||||||
<PhaseBadge phase={c.phase} />
|
<PhaseBadge phase={c.phase} />
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex shrink-0 gap-2">
|
||||||
<Button variant="outline" onClick={() => openAction(ApprovalDecision.Reject)}>
|
<Button variant="outline" onClick={() => openAction(ApprovalDecision.Reject)}>
|
||||||
<XCircle className="h-4 w-4" />
|
<XCircle className="h-4 w-4" />
|
||||||
@ -139,6 +177,114 @@ export function ContractDetailContent({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</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ý kết
|
||||||
|
</h2>
|
||||||
|
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<dt className="text-slate-500">Mã 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 HĐ ({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 có 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">
|
<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">
|
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||||
<MessageSquare className="h-4 w-4" />
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
|||||||
396
fe-admin/src/components/contracts/ContractWorkflowPanel.tsx
Normal file
396
fe-admin/src/components/contracts/ContractWorkflowPanel.tsx
Normal 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 là 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 HĐ 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 HĐ 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">
|
||||||
|
⚠ HĐ 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">
|
||||||
|
✗ HĐ đã 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">
|
||||||
|
HĐ về trạng thái “Trả lại”. 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 “Ý kiến cấp duyệt (Quy trình V2)” của HĐ
|
||||||
|
(1 ô/Cấp — duyệt lại thì ghi đè ô cũ). Bỏ trống thì hệ thống ghi
|
||||||
|
“(duyệt — không ý kiến)”.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
fe-admin/src/lib/contractWorkflowFamily.ts
Normal file
80
fe-admin/src/lib/contractWorkflowFamily.ts
Normal 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
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@
|
|||||||
// hiển thị Chi tiết section.
|
// hiển thị Chi tiết section.
|
||||||
import { useState, useMemo, type FormEvent, useEffect } from 'react'
|
import { useState, useMemo, type FormEvent, useEffect } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
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 { FileText, Plus, Search, Save, Pencil, Trash2, Lock } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab'
|
import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab'
|
||||||
@ -35,6 +35,9 @@ import {
|
|||||||
type ContractDetail,
|
type ContractDetail,
|
||||||
type ContractListItem,
|
type ContractListItem,
|
||||||
} from '@/types/contracts'
|
} 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')
|
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||||||
|
|
||||||
@ -541,6 +544,14 @@ function ContractEditForm({
|
|||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
}) {
|
}) {
|
||||||
const isDraft = contract.phase === ContractPhase.DangSoanThao
|
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 [templateId, setTemplateId] = useState(contract.templateId ?? '')
|
||||||
const [giaTri, setGiaTri] = useState(String(contract.giaTri ?? 0))
|
const [giaTri, setGiaTri] = useState(String(contract.giaTri ?? 0))
|
||||||
const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '')
|
const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '')
|
||||||
@ -610,6 +621,49 @@ function ContractEditForm({
|
|||||||
</div>
|
</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ý 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="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Loại HĐ</Label>
|
<Label>Loại HĐ</Label>
|
||||||
|
|||||||
@ -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
|
// [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.
|
// `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'
|
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) =====
|
// ===== 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.
|
// 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 khkkGroupMatch = typeCode?.match(/^KhkkN([1-8])$/) ?? null
|
||||||
const khkkGroup = khkkGroupMatch ? Number(khkkGroupMatch[1]) : 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
|
const selectedTypeInt = khkkGroup !== null
|
||||||
? TYPE_INT_CONTRACT_SIGNING_PLAN
|
? 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({
|
const overview = useQuery({
|
||||||
queryKey: ['approval-workflow-v2-overview', selectedTypeInt],
|
queryKey: ['approval-workflow-v2-overview', selectedTypeInt],
|
||||||
@ -317,61 +331,93 @@ export function ApprovalWorkflowsV2Page() {
|
|||||||
? overview.data?.types.find(t => t.applicableType === selectedTypeInt) ?? null
|
? overview.data?.types.find(t => t.applicableType === selectedTypeInt) ?? null
|
||||||
: null
|
: null
|
||||||
|
|
||||||
// [W2b S182] Lát cắt TỔNG HỢP cho panel nhóm. BE trả 1 bucket cho CẢ type 10
|
// [W2b S182 · mở rộng W6 S187] MÃ quy trình mà panel đang xem — `null` = panel theo LOẠI
|
||||||
// (`active` = 1-trong-8 quy trình nhóm, tuỳ bản nào kích hoạt sau cùng), nên
|
// (xem cả bucket, hành vi từ Mig 22). Hai HỌ per-Code dùng CHUNG một đường, không đẻ nhánh
|
||||||
// panel nhóm KHÔNG được đọc `bucket.active` — sẽ hiện nhầm quy trình của nhóm khác.
|
// 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`:
|
// Cắt lại từ `history`:
|
||||||
// 🔴 `history` là DANH SÁCH ĐẦY ĐỦ, KỂ CẢ bản đang áp dụng (verified BE
|
// 🔴 `history` là DANH SÁCH ĐẦY ĐỦ, KỂ CẢ bản đang áp dụng (verified BE — `AwTypeSummaryDto`
|
||||||
// `ApprovalWorkflowV2AdminFeatures.cs:206-211` — không lọc `!IsActive`);
|
// 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".
|
// 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.
|
// · `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
|
// 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(() => {
|
const currentType: TypeSummaryDto | null = useMemo(() => {
|
||||||
if (khkkGroup === null) return typeBucket
|
if (familyCode === null) return typeBucket
|
||||||
if (!typeBucket) return null
|
if (!typeBucket) return null
|
||||||
const wantCode = khkkGroupWorkflowCode(khkkGroup)
|
const versions = typeBucket.history.filter(d => d.code === familyCode)
|
||||||
const versions = typeBucket.history.filter(d => d.code === wantCode)
|
|
||||||
return {
|
return {
|
||||||
applicableType: typeBucket.applicableType,
|
applicableType: typeBucket.applicableType,
|
||||||
applicableTypeLabel: typeBucket.applicableTypeLabel,
|
applicableTypeLabel: typeBucket.applicableTypeLabel,
|
||||||
active: versions.find(v => v.isActive) ?? null,
|
active: versions.find(v => v.isActive) ?? null,
|
||||||
history: versions,
|
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
|
// [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Ộ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
|
// 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
|
||||||
// (phiếu nhóm đó vẫn tìm `KHKK-N{n}` — `KhkkCreatePage.tsx:113`) ⇒ khoá thẳng ô Mã.
|
// mồ côi — KHKK: phiếu nhóm đó vẫn tìm `KHKK-N{n}` (`KhkkCreatePage.tsx:113`); HĐ: BE khoá
|
||||||
const lockedCode = khkkGroup !== null ? khkkGroupWorkflowCode(khkkGroup) : null
|
// 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 (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<PageHeader
|
<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={
|
title={
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Workflow className="h-5 w-5" />
|
<Workflow className="h-5 w-5" />
|
||||||
{khkkGroup !== null
|
{panelLabel ? `Quy trình duyệt (Mới): ${panelLabel}` : 'Quy trình duyệt (Mới)'}
|
||||||
? `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)'}
|
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
description={
|
description={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.`
|
|
||||||
: 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.'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{overview.isLoading && <div className="text-sm text-slate-500">Đang tải…</div>}
|
{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 && (
|
{overview.data && !currentType && (
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{overview.data.types.map(t => (
|
{overview.data.types.map(t => (
|
||||||
|
|||||||
@ -4,12 +4,16 @@
|
|||||||
// approval history live separately in WorkflowHistoryPanel (Panel 3).
|
// approval history live separately in WorkflowHistoryPanel (Panel 3).
|
||||||
import { useState, type FormEvent } from 'react'
|
import { useState, type FormEvent } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
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 { toast } from 'sonner'
|
||||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||||
import { SlaTimer } from '@/components/SlaTimer'
|
import { SlaTimer } from '@/components/SlaTimer'
|
||||||
import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection'
|
import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection'
|
||||||
import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab'
|
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 { Button } from '@/components/ui/Button'
|
||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
import { Textarea } from '@/components/ui/Textarea'
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
@ -23,6 +27,10 @@ import {
|
|||||||
type ContractDetail,
|
type ContractDetail,
|
||||||
} from '@/types/contracts'
|
} from '@/types/contracts'
|
||||||
import { ContractTypeLabel } from '@/types/forms'
|
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 fmt = (s: string) => new Date(s).toLocaleString('vi-VN')
|
||||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') + ' VND'
|
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') + ' VND'
|
||||||
@ -71,6 +79,34 @@ export function ContractDetailContent({
|
|||||||
|
|
||||||
const availableTargets = c.workflow?.nextPhases ?? []
|
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) {
|
function openAction(decisionType: number) {
|
||||||
const targets = c.workflow?.nextPhases ?? []
|
const targets = c.workflow?.nextPhases ?? []
|
||||||
const defaultTarget = decisionType === ApprovalDecision.Reject
|
const defaultTarget = decisionType === ApprovalDecision.Reject
|
||||||
@ -102,7 +138,9 @@ export function ContractDetailContent({
|
|||||||
<PhaseBadge phase={c.phase} />
|
<PhaseBadge phase={c.phase} />
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex shrink-0 gap-2">
|
||||||
<Button variant="outline" onClick={() => openAction(ApprovalDecision.Reject)}>
|
<Button variant="outline" onClick={() => openAction(ApprovalDecision.Reject)}>
|
||||||
<XCircle className="h-4 w-4" />
|
<XCircle className="h-4 w-4" />
|
||||||
@ -139,6 +177,114 @@ export function ContractDetailContent({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</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ý kết
|
||||||
|
</h2>
|
||||||
|
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<dt className="text-slate-500">Mã 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 HĐ ({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 có 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">
|
<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">
|
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||||
<MessageSquare className="h-4 w-4" />
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
|||||||
396
fe-user/src/components/contracts/ContractWorkflowPanel.tsx
Normal file
396
fe-user/src/components/contracts/ContractWorkflowPanel.tsx
Normal 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 là 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 HĐ 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 HĐ 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">
|
||||||
|
⚠ HĐ 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">
|
||||||
|
✗ HĐ đã 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">
|
||||||
|
HĐ về trạng thái “Trả lại”. 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 “Ý kiến cấp duyệt (Quy trình V2)” của HĐ
|
||||||
|
(1 ô/Cấp — duyệt lại thì ghi đè ô cũ). Bỏ trống thì hệ thống ghi
|
||||||
|
“(duyệt — không ý kiến)”.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@
|
|||||||
// hiển thị Chi tiết section.
|
// hiển thị Chi tiết section.
|
||||||
import { useState, useMemo, type FormEvent, useEffect } from 'react'
|
import { useState, useMemo, type FormEvent, useEffect } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
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 { FileText, Plus, Search, Save, Pencil, Trash2, Lock } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab'
|
import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab'
|
||||||
@ -35,6 +35,9 @@ import {
|
|||||||
type ContractDetail,
|
type ContractDetail,
|
||||||
type ContractListItem,
|
type ContractListItem,
|
||||||
} from '@/types/contracts'
|
} 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')
|
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||||||
|
|
||||||
@ -541,6 +544,14 @@ function ContractEditForm({
|
|||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
}) {
|
}) {
|
||||||
const isDraft = contract.phase === ContractPhase.DangSoanThao
|
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 [templateId, setTemplateId] = useState(contract.templateId ?? '')
|
||||||
const [giaTri, setGiaTri] = useState(String(contract.giaTri ?? 0))
|
const [giaTri, setGiaTri] = useState(String(contract.giaTri ?? 0))
|
||||||
const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '')
|
const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '')
|
||||||
@ -610,6 +621,49 @@ function ContractEditForm({
|
|||||||
</div>
|
</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ý 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="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Loại HĐ</Label>
|
<Label>Loại HĐ</Label>
|
||||||
|
|||||||
Reference in New Issue
Block a user