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