[CLAUDE] FE-User+FE-Admin: action buttons (Mở chi tiết + Xóa) trên row Panel 1 Thao tác
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m35s

User feedback: thêm nút edit/action ở mỗi row trong list Panel 1 trang
Thao tác. Hiện absolute positioned ở góc phải-trên row, opacity-0 → 100
khi hover (group-hover). Sibling không nested để click không trigger
row select propagation.

## 2 button per row

- ⤴ ExternalLink → navigate /contracts/{id} (fullpage detail với
  Workflow + History, khác Panel 2 chỉ có Edit form)
- 🗑 Trash2 → confirm() + DELETE /contracts/{id} (soft delete,
  blocked sau DangInKy ở BE). Nếu xóa HĐ đang select → clear ?id=

## Implementation details

- pr-16 cho row button để chừa khoảng cho action group
- group-hover:opacity-100 transition (smooth fade in)
- Mutation invalidate ['my-contracts'] sau xóa thành công
- Toast success + getErrorMessage cho fail case (vd xóa HĐ đã qua DangInKy)

Build: tsc + vite pass cả 2 app (fe-user 515ms, fe-admin 937ms)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-23 10:54:54 +07:00
parent 8c4b4da951
commit ec0c983e8e
2 changed files with 92 additions and 8 deletions

View File

@ -12,8 +12,8 @@
// 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 { useNavigate, useSearchParams } from 'react-router-dom'
import { FileText, Plus, Search, Save } from 'lucide-react' import { FileText, Plus, Search, Save, ExternalLink, Trash2 } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab' import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab'
import { PhaseBadge } from '@/components/PhaseBadge' import { PhaseBadge } from '@/components/PhaseBadge'
@ -39,6 +39,8 @@ import {
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
export function ContractCreatePage() { export function ContractCreatePage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : 2 const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : 2
const selectedId = searchParams.get('id') const selectedId = searchParams.get('id')
@ -51,6 +53,23 @@ export function ContractCreatePage() {
(await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 100 } })).data, (await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 100 } })).data,
}) })
const deleteContract = useMutation({
mutationFn: async (id: string) => {
await api.delete(`/contracts/${id}`)
},
onSuccess: (_, deletedId) => {
toast.success('Đã xóa HĐ')
qc.invalidateQueries({ queryKey: ['my-contracts'] })
// Nếu đang edit HĐ vừa xóa → clear selection
if (selectedId === deletedId) {
const next = new URLSearchParams(searchParams)
next.delete('id')
setSearchParams(next, { replace: true })
}
},
onError: err => toast.error(getErrorMessage(err)),
})
const detail = useQuery({ const detail = useQuery({
queryKey: ['contract', selectedId], queryKey: ['contract', selectedId],
queryFn: async () => (await api.get<ContractDetail>(`/contracts/${selectedId}`)).data, queryFn: async () => (await api.get<ContractDetail>(`/contracts/${selectedId}`)).data,
@ -141,11 +160,11 @@ export function ContractCreatePage() {
)} )}
<ul className="divide-y divide-slate-100"> <ul className="divide-y divide-slate-100">
{rows.map(c => ( {rows.map(c => (
<li key={c.id}> <li key={c.id} className="group relative">
<button <button
onClick={() => selectContract(c.id)} onClick={() => selectContract(c.id)}
className={cn( className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50', 'block w-full px-3 py-2.5 pr-16 text-left transition hover:bg-slate-50',
selectedId === c.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200', selectedId === c.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
)} )}
> >
@ -167,6 +186,29 @@ export function ContractCreatePage() {
<SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} /> <SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} />
</div> </div>
</button> </button>
{/* Action buttons — hover-show, sibling không nested để click không trigger row select */}
<div className="absolute right-2 top-2 z-10 flex gap-0.5 opacity-0 transition group-hover:opacity-100">
<button
onClick={() => navigate(`/contracts/${c.id}`)}
title="Mở chi tiết (fullpage)"
className="rounded p-1 text-slate-500 hover:bg-white hover:text-brand-600 hover:shadow-sm"
>
<ExternalLink className="h-3.5 w-3.5" />
</button>
<button
onClick={() => {
if (confirm(`Xóa HĐ "${c.tenHopDong ?? c.maHopDong ?? 'này'}"?`)) {
deleteContract.mutate(c.id)
}
}}
title="Xóa HĐ"
className="rounded p-1 text-slate-500 hover:bg-white hover:text-red-600 hover:shadow-sm"
disabled={deleteContract.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</li> </li>
))} ))}
</ul> </ul>

View File

@ -12,8 +12,8 @@
// 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 { useNavigate, useSearchParams } from 'react-router-dom'
import { FileText, Plus, Search, Save } from 'lucide-react' import { FileText, Plus, Search, Save, ExternalLink, Trash2 } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab' import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab'
import { PhaseBadge } from '@/components/PhaseBadge' import { PhaseBadge } from '@/components/PhaseBadge'
@ -39,6 +39,8 @@ import {
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
export function ContractCreatePage() { export function ContractCreatePage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : 2 const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : 2
const selectedId = searchParams.get('id') const selectedId = searchParams.get('id')
@ -51,6 +53,23 @@ export function ContractCreatePage() {
(await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 100 } })).data, (await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 100 } })).data,
}) })
const deleteContract = useMutation({
mutationFn: async (id: string) => {
await api.delete(`/contracts/${id}`)
},
onSuccess: (_, deletedId) => {
toast.success('Đã xóa HĐ')
qc.invalidateQueries({ queryKey: ['my-contracts'] })
// Nếu đang edit HĐ vừa xóa → clear selection
if (selectedId === deletedId) {
const next = new URLSearchParams(searchParams)
next.delete('id')
setSearchParams(next, { replace: true })
}
},
onError: err => toast.error(getErrorMessage(err)),
})
const detail = useQuery({ const detail = useQuery({
queryKey: ['contract', selectedId], queryKey: ['contract', selectedId],
queryFn: async () => (await api.get<ContractDetail>(`/contracts/${selectedId}`)).data, queryFn: async () => (await api.get<ContractDetail>(`/contracts/${selectedId}`)).data,
@ -141,11 +160,11 @@ export function ContractCreatePage() {
)} )}
<ul className="divide-y divide-slate-100"> <ul className="divide-y divide-slate-100">
{rows.map(c => ( {rows.map(c => (
<li key={c.id}> <li key={c.id} className="group relative">
<button <button
onClick={() => selectContract(c.id)} onClick={() => selectContract(c.id)}
className={cn( className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50', 'block w-full px-3 py-2.5 pr-16 text-left transition hover:bg-slate-50',
selectedId === c.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200', selectedId === c.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
)} )}
> >
@ -167,6 +186,29 @@ export function ContractCreatePage() {
<SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} /> <SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} />
</div> </div>
</button> </button>
{/* Action buttons — hover-show, sibling không nested để click không trigger row select */}
<div className="absolute right-2 top-2 z-10 flex gap-0.5 opacity-0 transition group-hover:opacity-100">
<button
onClick={() => navigate(`/contracts/${c.id}`)}
title="Mở chi tiết (fullpage)"
className="rounded p-1 text-slate-500 hover:bg-white hover:text-brand-600 hover:shadow-sm"
>
<ExternalLink className="h-3.5 w-3.5" />
</button>
<button
onClick={() => {
if (confirm(`Xóa HĐ "${c.tenHopDong ?? c.maHopDong ?? 'này'}"?`)) {
deleteContract.mutate(c.id)
}
}}
title="Xóa HĐ"
className="rounded p-1 text-slate-500 hover:bg-white hover:text-red-600 hover:shadow-sm"
disabled={deleteContract.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</li> </li>
))} ))}
</ul> </ul>