[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).
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { ArrowLeft, CheckCircle2, MessageSquare, XCircle, ListChecks } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeft, CheckCircle2, MessageSquare, XCircle, ListChecks, GitBranch } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||
import { SlaTimer } from '@/components/SlaTimer'
|
||||
import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection'
|
||||
import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab'
|
||||
// [W6 S187] Màn duyệt V2-trạm — CHỈ dùng cho HĐ đã pin `approvalWorkflowId` (xem khối
|
||||
// dual-render ở dưới). HĐ V1 legacy không import nhánh này lúc chạy.
|
||||
import { ContractWorkflowPanel } from '@/components/contracts/ContractWorkflowPanel'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
@ -23,6 +27,10 @@ import {
|
||||
type ContractDetail,
|
||||
} from '@/types/contracts'
|
||||
import { ContractTypeLabel } from '@/types/forms'
|
||||
// [W5 S187] Nhãn 8 nhóm duyệt KHKK — IMPORT, KHÔNG chép chuỗi. Map này là single-source
|
||||
// (`types/khkk.ts:140-149`, đã đối chiếu từng ký tự với nhãn BE seed @K4a); đẻ map thứ 2 ở
|
||||
// đây là phá đúng cái acceptance nó phục vụ.
|
||||
import { KHKK_APPROVAL_GROUP_LABELS } from '@/types/khkk'
|
||||
|
||||
const fmt = (s: string) => new Date(s).toLocaleString('vi-VN')
|
||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') + ' VND'
|
||||
@ -71,6 +79,34 @@ export function ContractDetailContent({
|
||||
|
||||
const availableTargets = c.workflow?.nextPhases ?? []
|
||||
|
||||
// ===== [W6 S187 · YC-023 GĐ3] DUAL-RENDER V2-trạm ⟂ V1-legacy ==========================
|
||||
// 🔴 `approvalWorkflowId != null` là ĐÚNG cờ phân nhánh — cùng cờ mà BE dùng để chọn đường
|
||||
// duyệt (`ContractWorkflowService.cs:111` `if (contract.ApprovalWorkflowId is Guid awId)`)
|
||||
// VÀ cùng cờ mục "Ý kiến cấp duyệt (Quy trình V2)" bên dưới đã dùng từ S29. Một cờ, ba nơi
|
||||
// đọc, không đẻ khái niệm "HĐ mới" thứ hai.
|
||||
// • V2 ⇒ `ContractWorkflowPanel` (sơ đồ Bước→Cấp + banner lượt-ai + 2 nút Duyệt/Trả lại,
|
||||
// có gating theo Cấp đang chờ).
|
||||
// • V1 legacy (không pin — 7 HĐ prod chạy nhánh này, `ContractWorkflowService.cs:110`)
|
||||
// ⇒ GIỮ NGUYÊN 2 nút "Yêu cầu sửa"/"Duyệt → tiếp" ở header + Dialog chọn phase.
|
||||
// 2 khối là THAY THẾ nhau, không chồng: để cả hai thì cùng một HĐ V2 có 2 đường bấm duyệt
|
||||
// (đường header không nói được đang ở Bước/Cấp nào, cũng không gating theo người) — mâu
|
||||
// thuẫn hiển thị chứ không phải tiện thêm.
|
||||
const isV2 = c.approvalWorkflowId != null
|
||||
|
||||
// ===== [W5 S187 · YC-023 GĐ3] NGUỒN GỐC KHKK (GĐ2) =====================================
|
||||
// 🔴 Điều kiện render là `!= null` (LOOSE — bắt cả `undefined`) CÓ CHỦ ĐÍCH: `types/contracts.ts:174-182`
|
||||
// khai 2 nghĩa KHÁC NHAU — `undefined` = payload/cache TanStack CHƯA có W2; `null` = BE nói
|
||||
// "HĐ này không có nguồn KHKK". Cả 2 đều KHÔNG có gì để vẽ, nhưng KHÔNG được suy `undefined`
|
||||
// thành "HĐ tạo tay" ở bất kỳ chỗ nào khác.
|
||||
// Nguồn dữ liệu: BE reverse-join `ContractSigningPlanLines WHERE ContractId = id`
|
||||
// (`ContractFeatures.cs:720-748`) — `Contract` KHÔNG có con-trỏ ngược sang kế hoạch.
|
||||
const src = c.source
|
||||
const srcGroup = src?.approvalGroup ?? null
|
||||
// Nhãn nhóm: tra map single-source. Truthy-check theo đúng khuôn `khkkGroupMenuLabel`
|
||||
// (`types/khkk.ts:159-162`) vì `Record<number,string>` trả `string` cho MỌI số — nhóm lạ
|
||||
// (ngoài 1..8, vd dữ liệu SQL thô) phải hiện "chưa có trong danh mục" thay vì ô trống CÂM.
|
||||
const srcGroupLabel = srcGroup != null ? KHKK_APPROVAL_GROUP_LABELS[srcGroup] : undefined
|
||||
|
||||
function openAction(decisionType: number) {
|
||||
const targets = c.workflow?.nextPhases ?? []
|
||||
const defaultTarget = decisionType === ApprovalDecision.Reject
|
||||
@ -102,7 +138,9 @@ export function ContractDetailContent({
|
||||
<PhaseBadge phase={c.phase} />
|
||||
</div>
|
||||
</div>
|
||||
{availableTargets.length > 0 && (
|
||||
{/* [W6 S187] `!isV2` = nửa V1 của dual-render. HĐ pin quy trình V2 thao tác duyệt ở
|
||||
`ContractWorkflowPanel` bên dưới (đúng Bước/Cấp + đúng người), không ở đây. */}
|
||||
{!isV2 && availableTargets.length > 0 && (
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button variant="outline" onClick={() => openAction(ApprovalDecision.Reject)}>
|
||||
<XCircle className="h-4 w-4" />
|
||||
@ -139,6 +177,114 @@ export function ContractDetailContent({
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* [W5 S187 · YC-023 GĐ3] Card "Nguồn gốc Kế hoạch ký kết" — đứng NGAY SAU khối thông
|
||||
tin chính: người mở HĐ cần biết nó đẻ ra từ đâu trước khi đọc góp ý / chi tiết.
|
||||
Tông VIOLET = GĐ2 (KHKK) theo bảng màu 4 giai đoạn `PipelineStageFolders.tsx:41-46`;
|
||||
link sang Phiếu Duyệt NCC dùng tông BRAND = GĐ1 (PE).
|
||||
⚠️ Chỉ dùng stop violet CÓ THẬT trong `index.css` `@theme` (50/100/500/600/700) —
|
||||
violet-300/800 rơi về bảng Tailwind mặc định, lệch hệ màu (đã thấy ở
|
||||
`KhkkDetailContent.tsx:477`, pre-existing, không sửa ở wave này).
|
||||
Vỏ `<section className="rounded-lg border … p-5">` + `<h2 className="mb-3 …">` bám
|
||||
ĐÚNG khuôn các khối cùng trang — 2 app đều KHÔNG có shadcn `Card`. */}
|
||||
{src != null && (
|
||||
<section className="rounded-lg border border-violet-100 bg-violet-50/60 p-5">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-violet-700">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Nguồn gốc Kế hoạch ký 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">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<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.
|
||||
import { useState, useMemo, type FormEvent, useEffect } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { FileText, Plus, Search, Save, Pencil, Trash2, Lock } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab'
|
||||
@ -35,6 +35,9 @@ import {
|
||||
type ContractDetail,
|
||||
type ContractListItem,
|
||||
} from '@/types/contracts'
|
||||
// [W5 S187 vế (88)] Nhãn 8 nhóm duyệt KHKK — IMPORT single-source (`types/khkk.ts:140-149`),
|
||||
// KHÔNG chép chuỗi.
|
||||
import { KHKK_APPROVAL_GROUP_LABELS } from '@/types/khkk'
|
||||
|
||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||||
|
||||
@ -541,6 +544,14 @@ function ContractEditForm({
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const isDraft = contract.phase === ContractPhase.DangSoanThao
|
||||
// ===== [W5 S187 vế (88)] Ruột pre-fill: HĐ sinh từ cầu KHKK→HĐ mang sẵn 15 field do BE gán
|
||||
// (`CreateContractFromSigningPlanFeatures.cs:186-207`). Màn sửa Nháp này TRƯỚC W5 chỉ hiện
|
||||
// 11/15; 4 field còn lại (Phòng ban · Người soạn · SLA · nguồn KHKK) có ĐỦ trong `ContractDetail`
|
||||
// đang tải sẵn nhưng KHÔNG được vẽ ⇒ người sửa Nháp không thấy HĐ này đẻ từ đâu. Dải chỉ-đọc
|
||||
// dưới đây bù đúng 4 field đó, 0 request mới, 0 field bịa. (Nợ còn lại → §5 sub-file W5.)
|
||||
const src = contract.source
|
||||
const srcGroup = src?.approvalGroup ?? null
|
||||
const srcGroupLabel = srcGroup != null ? KHKK_APPROVAL_GROUP_LABELS[srcGroup] : undefined
|
||||
const [templateId, setTemplateId] = useState(contract.templateId ?? '')
|
||||
const [giaTri, setGiaTri] = useState(String(contract.giaTri ?? 0))
|
||||
const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '')
|
||||
@ -610,6 +621,49 @@ function ContractEditForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* [W5 S187 vế (88)] Dải CHỈ-ĐỌC — dữ liệu HĐ mang sẵn mà form không có ô nhập.
|
||||
🔴 Đặt NGOÀI `grid grid-cols-2` bên dưới CÓ CHỦ ĐÍCH: nhét thêm ô con vào grid đó
|
||||
làm sibling auto-place trượt cột (bài S182) — dải này là 1 khối phẳng, 0 rủi ro.
|
||||
Dòng 1 chỉ hiện khi HĐ có nguồn KHKK (`source != null`, loose — bắt cả `undefined`
|
||||
của cache cũ); dòng 2 hiện LUÔN vì cả 3 field đều do BE gán ở MỌI đường tạo HĐ. */}
|
||||
<div className="mb-4 space-y-1.5 rounded-md border border-violet-100 bg-violet-50/60 px-3 py-2 text-xs">
|
||||
{src != null && (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-slate-700">
|
||||
<span className="font-medium text-violet-700">Kế thừa từ Kế hoạch ký kết:</span>
|
||||
{/* Chỉ link khi phiếu cha còn đọc được — `maKeHoach === null` ⇔ phiếu đã xoá mềm,
|
||||
mà `/khkk/:id` không `IgnoreQueryFilters` ⇒ 404 (đo ở §1 sub-file W5). */}
|
||||
{src.maKeHoach != null ? (
|
||||
<Link
|
||||
to={`/khkk/${src.planId}${srcGroup != null ? `?group=${srcGroup}` : ''}`}
|
||||
className="font-mono font-medium text-violet-600 underline underline-offset-2 hover:text-violet-700"
|
||||
>
|
||||
{src.maKeHoach}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-mono text-slate-500">(phiếu đã xoá — không mở được phiếu gốc)</span>
|
||||
)}
|
||||
{srcGroup != null && (
|
||||
<span className="rounded bg-violet-100 px-1.5 py-0.5 font-medium text-violet-700">
|
||||
N{srcGroup} · {srcGroupLabel ? srcGroupLabel : 'chưa có trong danh mục'}
|
||||
</span>
|
||||
)}
|
||||
<span>· {src.tenHangMucs.length} hạng mục</span>
|
||||
{src.approvedAmountTotal != null && (
|
||||
<span>
|
||||
· Σ giá duyệt chốt <strong className="text-slate-900">{fmtMoney(src.approvedAmountTotal)} đ</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-slate-600">
|
||||
<span>Phòng ban: <strong className="text-slate-800">{contract.departmentName ?? '—'}</strong></span>
|
||||
<span>Người soạn: <strong className="text-slate-800">{contract.drafterName ?? '—'}</strong></span>
|
||||
<span className="flex items-center gap-1">
|
||||
SLA: <SlaTimer deadline={contract.slaDeadline} createdAt={contract.createdAt} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Loại HĐ</Label>
|
||||
|
||||
Reference in New Issue
Block a user