[CLAUDE] Contract: YC-029 W5 — đường xoá đồng-khuôn KHKK (owner-check + phase allow-list + changelog, test-before 13 ca)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-12 20:19:32 +07:00
parent 8a64f536e5
commit 2ae62f3fbc
7 changed files with 781 additions and 11 deletions

View File

@ -2,10 +2,11 @@
// in MyContractsPage 3-panel layout (Panel 2). Renders header (title + phase
// + actions) + Info + Comments + Attachments + transition Dialog. Workflow +
// approval history live separately in WorkflowHistoryPanel (Panel 3).
// File MIRROR SHA256 identical với fe-admin counterpart.
import { useState, type FormEvent } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { ArrowLeft, CheckCircle2, MessageSquare, XCircle, ListChecks, GitBranch } from 'lucide-react'
import { ArrowLeft, CheckCircle2, MessageSquare, Trash2, XCircle, ListChecks, GitBranch } from 'lucide-react'
import { toast } from 'sonner'
import { PhaseBadge } from '@/components/PhaseBadge'
import { SlaTimer } from '@/components/SlaTimer'
@ -18,6 +19,7 @@ import { Button } from '@/components/ui/Button'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { Dialog } from '@/components/ui/Dialog'
import { useAuth } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import {
@ -38,12 +40,21 @@ const fmtMoney = (v: number) => v.toLocaleString('vi-VN') + ' VND'
export function ContractDetailContent({
contract: c,
onBack,
onDeleted,
}: {
contract: ContractDetail
/** Optional back handler — shown as arrow button next to title. Pass `navigate(-1)` for fullpage; omit for embedded panel. */
onBack?: () => void
/**
* [W5c S190] Gọi SAU khi xoá HĐ ở thanh nút đáy — host bỏ chọn / quay về danh sách.
* KHÔNG truyền ⇒ chỉ có 3 `invalidateQueries` chạy: danh sách tự rụng dòng, còn màn chi
* tiết đang mở refetch ra 404 và host rơi về nhánh "không tìm thấy / chưa chọn HĐ" của nó.
* (Tính đến wave này chưa host nào truyền — xem `sub-b6-w5c-fe.md`.)
*/
onDeleted?: () => void
}) {
const qc = useQueryClient()
const { user: currentUser } = useAuth()
const [actionOpen, setActionOpen] = useState(false)
const [targetPhase, setTargetPhase] = useState<number>(0)
const [decision, setDecision] = useState<number>(ApprovalDecision.Approve)
@ -77,6 +88,44 @@ export function ContractDetailContent({
onError: err => toast.error(getErrorMessage(err)),
})
// ===== [W5c S190 · YC-029] XOÁ HỢP ĐỒNG ================================================
// Đường xoá MỀM (BE `ContractFeatures.cs` khối DELETE, W5b): HĐ rời danh sách thường chứ
// không mất khỏi DB. Mọi rào ở đây là BẢN SAO HIỂN THỊ của guard BE, KHÔNG thay nó — chỗ
// chặn thật nằm trong handler (`ContractsController.cs` DELETE còn `[Authorize]` trần vì
// quyền per-action là W8, owner đã HOÃN).
const deleteContract = useMutation({
// `reason` đi QUERY-STRING vì verb DELETE không mang body — đúng hình endpoint W5b
// (`[FromQuery] string? reason`). Màn này CHƯA thu lý do: khuôn KHKK
// (`KhkkDetailContent.tsx:1090`) cũng chỉ `window.confirm`, và tự đẻ `window.prompt` ở
// đây là quyết-định UX chưa ai chốt. Gọi không truyền ⇒ axios bỏ hẳn key ⇒ request y hệt
// `DELETE /contracts/{id}`; chỗ cắm để sẵn cho lúc owner chốt có hỏi lý do hay không.
mutationFn: async (reason?: string) =>
api.delete(`/contracts/${c.id}`, { params: reason?.trim() ? { reason: reason.trim() } : undefined }),
onSuccess: () => {
// Cùng bộ key với `transition` ở trên (:57-61) — HĐ vừa xoá phải rụng khỏi CẢ danh sách
// của tôi LẪN hộp chờ duyệt, không riêng màn đang đứng.
qc.invalidateQueries({ queryKey: ['contract', c.id] })
qc.invalidateQueries({ queryKey: ['my-contracts'] })
qc.invalidateQueries({ queryKey: ['inbox'] })
toast.success('Đã xóa hợp đồng.')
onDeleted?.()
},
onError: err => toast.error(getErrorMessage(err)),
})
// Rào HIỂN THỊ mirror guard BE — 2 vế:
// • phase ∈ allow-list `< DangInKy(5)` {TraLai(98), TuChoi(99)}. ChoDuyet(10) KHÔNG có
// kể cả với người soạn (đang chờ người khác duyệt); DaPhatHanh(9) đã sinh mã ⇒ chặn mọi
// đường. Viết `<` chứ không liệt 1/2/3/4 vì allow-list HĐ gồm cả phase legacy.
// • người soạn HOẶC Admin. `isDrafter` theo khuôn null-safe `PeDetailTabs.tsx:141`:
// `currentUser?.id != null` phải ĐỨNG TRƯỚC vì `drafterUserId` là `string | null`
// (`types/contracts.ts:154`) — thiếu vế đó thì `null === null` cho HĐ vô chủ hiện nút.
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
const isDrafter = currentUser?.id != null && c.drafterUserId === currentUser.id
const canDelete =
(c.phase < ContractPhase.DangInKy || c.phase === ContractPhase.TraLai || c.phase === ContractPhase.TuChoi)
&& (isDrafter || isAdmin)
const availableTargets = c.workflow?.nextPhases ?? []
// ===== [W6 S187 · YC-023 GĐ3] DUAL-RENDER V2-trạm ⟂ V1-legacy ==========================
@ -397,6 +446,37 @@ export function ContractDetailContent({
</div>
</div>
</Dialog>
{/* [W5c S190 · YC-029] THANH NÚT ĐÁY — khuôn `KhkkDetailContent.tsx:1076-1097`
(🔴 KHÔNG lấy `KhkkWorkflowPanel`: nhánh xoá trong đó là code chết từ S175).
🔴 `sticky bottom-0`: trang HĐ dài (thông tin + nguồn KHKK + góp ý + file + chi tiết
+ ý kiến duyệt) — nút đứng cuối luồng thì phải cuộn hết mới với tới.
🔸 Hỏi bằng `window.confirm` chứ KHÔNG mở hộp thoại `Dialog` thứ hai: hộp thoại duy
nhất của file này đang giữ ĐÚNG MỘT nghĩa — chuyển phase (chọn phase + ghi chú). Xoá
không có gì để nhập (lý do chưa thu, xem mutation ở trên) nên hộp thứ hai chỉ là ô
trống 2 nút — và nó cũng làm hỏng chính ô đo C-12 vế 3 (đếm token mở hộp thoại theo
DÒNG: bình luận nhắc tên token cũng bị tính, bẫy tự-trích-dẫn S188). Câu hỏi
mang MÃ HĐ + ĐÍCH đi tới ("mục Đã xóa") nói đủ hậu quả — mã để bấm nhầm HĐ khác thì
nhìn ra, đích để không tưởng là xoá vĩnh viễn. */}
{canDelete && (
<div className="sticky bottom-0 z-10 flex flex-wrap items-center gap-3 border-t border-slate-200 bg-white/95 px-5 py-3 backdrop-blur">
<button
type="button"
onClick={() => {
if (window.confirm(`Xóa hợp đồng ${c.maHopDong ?? c.tenHopDong ?? '(chưa có mã)'}? Hợp đồng chuyển sang mục "Đã xóa".`)) deleteContract.mutate(undefined)
}}
disabled={deleteContract.isPending}
className="inline-flex items-center gap-1.5 rounded-md bg-red-600 px-3 py-2 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
{deleteContract.isPending ? 'Đang xóa…' : 'Xóa hợp đồng'}
</button>
<span className="min-w-0 flex-1 text-[12px] text-slate-500">
Xoá mềm dữ liệu không mất khỏi hệ thống.
</span>
</div>
)}
</div>
)
}