[CLAUDE] App+Api+FE+Scripts: Edit detail row inline + deps audit helper
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m45s

## Edit detail row inline (BE)

7 typed UpdateXxxDetailCommand handler trong ContractDetailsFeatures.cs
— pattern lặp giống Add commands, EnsureContractType guard + log
ChangelogAction.Update với summary "Sửa <hạng mục/SP/CV/...>".

7 PUT endpoints trong ContractsController:
- PUT /contracts/{id}/details/{thau-phu|giao-khoan|nha-cung-cap|dich-vu|
  mua-ban|nguyen-tac-ncc|nguyen-tac-dv}/{detailId}

## Edit detail row inline (FE)

ContractDetailsTab.tsx refactor:
- DeleteBtn → ActionBtns (Pencil + Trash) với onEdit + onDelete callbacks
- 7 XxxTable signatures + onEdit prop + pass row data via callback
- New EditRowDialog component:
  * useEffect populate form từ row data khi target thay đổi
  * Reuse FIELDS_BY_TYPE config + buildPayload (compute thanhTien)
  * Date field convert ISO → yyyy-MM-dd cho input[type=date]
  * PUT /contracts/{id}/details/{slug}/{detailId}
- Parent state editTarget — open dialog, close khi save thành công

Mirror fe-admin (file copy).

## Deps audit helper script

scripts/deps-audit.ps1 — chạy thủ công hoặc CI integration:
- dotnet list package --vulnerable --include-transitive (BE)
- npm audit --audit-level=moderate (fe-admin + fe-user)
- Color-coded output (green/red), summary cuối
- -FailOnHigh switch để CI gate

Skill ref .claude/skills/dependency-audit-erp/SKILL.md (đã có) cho
pin constraints + workflow fix.

## Build

- BE: dotnet build pass (0 error)
- fe-user: tsc + vite pass (11.52s)
- fe-admin: tsc + vite pass (577ms)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-23 15:18:53 +07:00
parent 4edcd588d8
commit e53cd3a3b2
5 changed files with 594 additions and 80 deletions

View File

@ -1,12 +1,13 @@
// Tab "Chi tiết" — hiện table line items theo loại HĐ (7 schema khác nhau,
// auto pick render component theo bundle.type). Add row form ở footer (chỉ
// khi Phase=DangSoanThao và user là drafter — owner mới được edit details).
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Dialog } from '@/components/ui/Dialog'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { ContractPhase, type ContractDetail } from '@/types/contracts'
@ -21,6 +22,10 @@ import type {
NguyenTacDvDetail,
} from '@/types/contract-details'
// Generic shape của 1 row đang edit (parent state). Sub-tables type-narrow
// qua bundle.type khi dispatch onEdit.
type EditTarget = { row: Record<string, unknown> }
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
// Map ContractType → URL slug + render config
@ -38,21 +43,23 @@ const TYPE_TO_SLUG: Record<number, TypeKey> = {
export function ContractDetailsTab({ contract }: { contract: ContractDetail }) {
const qc = useQueryClient()
const canEdit = contract.phase === ContractPhase.DangSoanThao
const [editTarget, setEditTarget] = useState<EditTarget | null>(null)
const bundleQuery = useQuery({
queryKey: ['contract-details', contract.id],
queryFn: async () => (await api.get<ContractDetailsBundle>(`/contracts/${contract.id}/details`)).data,
})
function invalidate() {
qc.invalidateQueries({ queryKey: ['contract-details', contract.id] })
qc.invalidateQueries({ queryKey: ['contract-changelogs', contract.id] })
}
const deleteRow = useMutation({
mutationFn: async (detailId: string) => {
await api.delete(`/contracts/${contract.id}/details/${detailId}`)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['contract-details', contract.id] })
qc.invalidateQueries({ queryKey: ['contract-changelogs', contract.id] })
toast.success('Đã xóa')
},
onSuccess: () => { invalidate(); toast.success('Đã xóa') },
onError: err => toast.error(getErrorMessage(err)),
})
@ -61,25 +68,26 @@ export function ContractDetailsTab({ contract }: { contract: ContractDetail }) {
const bundle = bundleQuery.data
const onEdit = canEdit
? (row: Record<string, unknown>) => setEditTarget({ row })
: undefined
return (
<div className="space-y-3">
{bundle.type === 1 && <ThauPhuTable rows={bundle.thauPhu} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{bundle.type === 2 && <GiaoKhoanTable rows={bundle.giaoKhoan} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{bundle.type === 3 && <NhaCungCapTable rows={bundle.nhaCungCap} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{bundle.type === 4 && <DichVuTable rows={bundle.dichVu} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{bundle.type === 5 && <MuaBanTable rows={bundle.muaBan} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{bundle.type === 6 && <NguyenTacNccTable rows={bundle.nguyenTacNcc} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{bundle.type === 7 && <NguyenTacDvTable rows={bundle.nguyenTacDv} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{bundle.type === 1 && <ThauPhuTable rows={bundle.thauPhu} onDelete={deleteRow.mutate} onEdit={onEdit} canEdit={canEdit} />}
{bundle.type === 2 && <GiaoKhoanTable rows={bundle.giaoKhoan} onDelete={deleteRow.mutate} onEdit={onEdit} canEdit={canEdit} />}
{bundle.type === 3 && <NhaCungCapTable rows={bundle.nhaCungCap} onDelete={deleteRow.mutate} onEdit={onEdit} canEdit={canEdit} />}
{bundle.type === 4 && <DichVuTable rows={bundle.dichVu} onDelete={deleteRow.mutate} onEdit={onEdit} canEdit={canEdit} />}
{bundle.type === 5 && <MuaBanTable rows={bundle.muaBan} onDelete={deleteRow.mutate} onEdit={onEdit} canEdit={canEdit} />}
{bundle.type === 6 && <NguyenTacNccTable rows={bundle.nguyenTacNcc} onDelete={deleteRow.mutate} onEdit={onEdit} canEdit={canEdit} />}
{bundle.type === 7 && <NguyenTacDvTable rows={bundle.nguyenTacDv} onDelete={deleteRow.mutate} onEdit={onEdit} canEdit={canEdit} />}
{canEdit && (
<AddRowForm
contractId={contract.id}
contractType={contract.type}
existingCount={getRowCount(bundle)}
onAdded={() => {
qc.invalidateQueries({ queryKey: ['contract-details', contract.id] })
qc.invalidateQueries({ queryKey: ['contract-changelogs', contract.id] })
}}
onAdded={invalidate}
/>
)}
@ -88,6 +96,14 @@ export function ContractDetailsTab({ contract }: { contract: ContractDetail }) {
Chỉ sửa đưc chi tiết khi phase "Đang soạn thảo". Hiện tại đã chuyển sang phase khác.
</div>
)}
<EditRowDialog
target={editTarget}
contractId={contract.id}
contractType={contract.type}
onClose={() => setEditTarget(null)}
onSaved={() => { invalidate(); setEditTarget(null) }}
/>
</div>
)
}
@ -122,15 +138,28 @@ function TableShell({ headers, totalRow, children }: { headers: string[]; totalR
)
}
function DeleteBtn({ onDelete }: { onDelete: () => void }) {
function ActionBtns({ onEdit, onDelete }: { onEdit?: () => void; onDelete: () => void }) {
return (
<button
onClick={() => { if (confirm('Xóa dòng này?')) onDelete() }}
className="text-slate-400 hover:text-red-600"
aria-label="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<div className="flex justify-end gap-0.5">
{onEdit && (
<button
onClick={onEdit}
className="rounded p-0.5 text-slate-400 hover:bg-slate-100 hover:text-brand-600"
aria-label="Sửa"
title="Sửa dòng"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={() => { if (confirm('Xóa dòng này?')) onDelete() }}
className="rounded p-0.5 text-slate-400 hover:bg-slate-100 hover:text-red-600"
aria-label="Xóa"
title="Xóa dòng"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)
}
@ -138,7 +167,7 @@ function totalOf(rows: { thanhTien: number }[]): number {
return rows.reduce((s, r) => s + (r.thanhTien ?? 0), 0)
}
function ThauPhuTable({ rows, onDelete, canEdit }: { rows: ThauPhuDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
function ThauPhuTable({ rows, onDelete, onEdit, canEdit }: { rows: ThauPhuDetail[]; onDelete: (id: string) => void; onEdit?: (row: Record<string, unknown>) => void; canEdit: boolean }) {
return (
<TableShell
headers={['Hạng mục', 'ĐVT', 'Khối lượng', 'Đơn giá', 'Thành tiền', 'Hoàn thành', 'Ghi chú']}
@ -155,14 +184,14 @@ function ThauPhuTable({ rows, onDelete, canEdit }: { rows: ThauPhuDetail[]; onDe
<td className="px-2 py-1.5 text-right font-medium">{fmtMoney(r.thanhTien)}</td>
<td className="px-2 py-1.5 text-xs text-slate-500">{r.thoiGianHoanThanh ? new Date(r.thoiGianHoanThanh).toLocaleDateString('vi-VN') : '—'}</td>
<td className="px-2 py-1.5 text-xs text-slate-500">{r.ghiChu ?? ''}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
<td className="px-2 py-1.5">{canEdit && <ActionBtns onEdit={onEdit ? () => onEdit(r as unknown as Record<string, unknown>) : undefined} onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
)
}
function GiaoKhoanTable({ rows, onDelete, canEdit }: { rows: GiaoKhoanDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
function GiaoKhoanTable({ rows, onDelete, onEdit, canEdit }: { rows: GiaoKhoanDetail[]; onDelete: (id: string) => void; onEdit?: (row: Record<string, unknown>) => void; canEdit: boolean }) {
return (
<TableShell
headers={['Mã CV', 'Tên công việc', 'ĐVT', 'KL', 'Đơn giá', 'Thành tiền', 'Hoàn thành']}
@ -179,14 +208,14 @@ function GiaoKhoanTable({ rows, onDelete, canEdit }: { rows: GiaoKhoanDetail[];
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGia)}</td>
<td className="px-2 py-1.5 text-right font-medium">{fmtMoney(r.thanhTien)}</td>
<td className="px-2 py-1.5 text-xs text-slate-500">{r.thoiGianHoanThanh ? new Date(r.thoiGianHoanThanh).toLocaleDateString('vi-VN') : '—'}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
<td className="px-2 py-1.5">{canEdit && <ActionBtns onEdit={onEdit ? () => onEdit(r as unknown as Record<string, unknown>) : undefined} onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
)
}
function NhaCungCapTable({ rows, onDelete, canEdit }: { rows: NhaCungCapDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
function NhaCungCapTable({ rows, onDelete, onEdit, canEdit }: { rows: NhaCungCapDetail[]; onDelete: (id: string) => void; onEdit?: (row: Record<string, unknown>) => void; canEdit: boolean }) {
return (
<TableShell
headers={['Mã SP', 'Tên SP', 'ĐVT', 'SL', 'Đơn giá', 'Thành tiền', 'Giao hàng']}
@ -203,14 +232,14 @@ function NhaCungCapTable({ rows, onDelete, canEdit }: { rows: NhaCungCapDetail[]
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGia)}</td>
<td className="px-2 py-1.5 text-right font-medium">{fmtMoney(r.thanhTien)}</td>
<td className="px-2 py-1.5 text-xs text-slate-500">{r.thoiGianGiao ? new Date(r.thoiGianGiao).toLocaleDateString('vi-VN') : '—'}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
<td className="px-2 py-1.5">{canEdit && <ActionBtns onEdit={onEdit ? () => onEdit(r as unknown as Record<string, unknown>) : undefined} onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
)
}
function DichVuTable({ rows, onDelete, canEdit }: { rows: DichVuDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
function DichVuTable({ rows, onDelete, onEdit, canEdit }: { rows: DichVuDetail[]; onDelete: (id: string) => void; onEdit?: (row: Record<string, unknown>) => void; canEdit: boolean }) {
return (
<TableShell
headers={['Mã DV', 'Tên DV', 'ĐVT', 'Thời gian', 'Đơn giá', 'Thành tiền']}
@ -226,14 +255,14 @@ function DichVuTable({ rows, onDelete, canEdit }: { rows: DichVuDetail[]; onDele
<td className="px-2 py-1.5 text-right">{fmtMoney(r.thoiGian)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGia)}</td>
<td className="px-2 py-1.5 text-right font-medium">{fmtMoney(r.thanhTien)}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
<td className="px-2 py-1.5">{canEdit && <ActionBtns onEdit={onEdit ? () => onEdit(r as unknown as Record<string, unknown>) : undefined} onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
)
}
function MuaBanTable({ rows, onDelete, canEdit }: { rows: MuaBanDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
function MuaBanTable({ rows, onDelete, onEdit, canEdit }: { rows: MuaBanDetail[]; onDelete: (id: string) => void; onEdit?: (row: Record<string, unknown>) => void; canEdit: boolean }) {
return (
<TableShell
headers={['Mã SP', 'Tên SP', 'ĐVT', 'SL', 'Đơn giá', 'VAT (%)', 'Thành tiền']}
@ -250,14 +279,14 @@ function MuaBanTable({ rows, onDelete, canEdit }: { rows: MuaBanDetail[]; onDele
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGia)}</td>
<td className="px-2 py-1.5 text-right text-xs">{r.thueVAT}%</td>
<td className="px-2 py-1.5 text-right font-medium">{fmtMoney(r.thanhTien)}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
<td className="px-2 py-1.5">{canEdit && <ActionBtns onEdit={onEdit ? () => onEdit(r as unknown as Record<string, unknown>) : undefined} onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
)
}
function NguyenTacNccTable({ rows, onDelete, canEdit }: { rows: NguyenTacNccDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
function NguyenTacNccTable({ rows, onDelete, onEdit, canEdit }: { rows: NguyenTacNccDetail[]; onDelete: (id: string) => void; onEdit?: (row: Record<string, unknown>) => void; canEdit: boolean }) {
return (
<TableShell headers={['Nhóm SP', 'Tên SP', 'ĐVT', 'Giá min', 'Giá max', 'Điều kiện thanh toán']}>
{rows.length === 0 && <tr><td colSpan={8} className="px-3 py-6 text-center text-xs text-slate-400">Chưa SP.</td></tr>}
@ -270,14 +299,14 @@ function NguyenTacNccTable({ rows, onDelete, canEdit }: { rows: NguyenTacNccDeta
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGiaToiThieu)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGiaToiDa)}</td>
<td className="px-2 py-1.5 text-xs text-slate-500">{r.dieuKienThanhToan ?? '—'}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
<td className="px-2 py-1.5">{canEdit && <ActionBtns onEdit={onEdit ? () => onEdit(r as unknown as Record<string, unknown>) : undefined} onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
)
}
function NguyenTacDvTable({ rows, onDelete, canEdit }: { rows: NguyenTacDvDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
function NguyenTacDvTable({ rows, onDelete, onEdit, canEdit }: { rows: NguyenTacDvDetail[]; onDelete: (id: string) => void; onEdit?: (row: Record<string, unknown>) => void; canEdit: boolean }) {
return (
<TableShell headers={['Loại DV', 'Tên DV', 'ĐVT', 'Giá min', 'Giá max', 'SLA']}>
{rows.length === 0 && <tr><td colSpan={8} className="px-3 py-6 text-center text-xs text-slate-400">Chưa DV.</td></tr>}
@ -290,7 +319,7 @@ function NguyenTacDvTable({ rows, onDelete, canEdit }: { rows: NguyenTacDvDetail
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGiaToiThieu)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGiaToiDa)}</td>
<td className="px-2 py-1.5 text-xs text-slate-500">{r.sla ?? '—'}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
<td className="px-2 py-1.5">{canEdit && <ActionBtns onEdit={onEdit ? () => onEdit(r as unknown as Record<string, unknown>) : undefined} onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
@ -569,3 +598,86 @@ function buildPayload(contractType: number, order: number, form: Record<string,
}
return common
}
// ===== Edit dialog — populated từ row data, PUT thay POST =====
function EditRowDialog({
target, contractId, contractType, onClose, onSaved,
}: {
target: { row: Record<string, unknown> } | null
contractId: string
contractType: number
onClose: () => void
onSaved: () => void
}) {
const [form, setForm] = useState<Record<string, string>>({})
// Populate khi target thay đổi (open dialog với row data)
useEffect(() => {
if (!target) return
const init: Record<string, string> = {}
for (const f of FIELDS_BY_TYPE[contractType] ?? []) {
const v = target.row[f.name]
if (v == null) init[f.name] = ''
else if (f.type === 'date') {
// ISO string → yyyy-MM-dd cho input[type=date]
const d = new Date(String(v))
init[f.name] = isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10)
} else {
init[f.name] = String(v)
}
}
setForm(init)
}, [target, contractType])
const submit = useMutation({
mutationFn: async () => {
if (!target) return
const slug = TYPE_TO_SLUG[contractType]
const detailId = target.row.id as string
const order = Number(target.row.order ?? 1)
const payload = buildPayload(contractType, order, form)
// Patch id field cho BE PUT (URL có detailId nhưng body cũng cần)
payload.id = detailId
await api.put(`/contracts/${contractId}/details/${slug}/${detailId}`, payload)
},
onSuccess: () => { toast.success('Đã lưu'); onSaved() },
onError: err => toast.error(getErrorMessage(err)),
})
if (!target) return null
const fields = FIELDS_BY_TYPE[contractType] ?? []
return (
<Dialog
open={!!target}
onClose={onClose}
title="Sửa chi tiết"
size="lg"
footer={
<>
<Button variant="outline" onClick={onClose}>Hủy</Button>
<Button onClick={() => submit.mutate()} disabled={submit.isPending}>
{submit.isPending ? 'Đang lưu…' : 'Lưu'}
</Button>
</>
}
>
<div className="grid grid-cols-2 gap-3">
{fields.map(f => (
<div key={f.name} className="space-y-1">
<label className="text-[11px] font-medium text-slate-600">{f.label}</label>
<Input
type={f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'}
value={form[f.name] ?? ''}
onChange={e => setForm(s => ({ ...s, [f.name]: e.target.value }))}
placeholder={f.placeholder}
step={f.type === 'number' ? 'any' : undefined}
required={f.required}
/>
</div>
))}
</div>
</Dialog>
)
}