[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:
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user