[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, // 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ỉ // 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). // 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 { 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 { toast } from 'sonner'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Dialog } from '@/components/ui/Dialog'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { ContractPhase, type ContractDetail } from '@/types/contracts' import { ContractPhase, type ContractDetail } from '@/types/contracts'
@ -21,6 +22,10 @@ import type {
NguyenTacDvDetail, NguyenTacDvDetail,
} from '@/types/contract-details' } 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') const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
// Map ContractType → URL slug + render config // Map ContractType → URL slug + render config
@ -38,21 +43,23 @@ const TYPE_TO_SLUG: Record<number, TypeKey> = {
export function ContractDetailsTab({ contract }: { contract: ContractDetail }) { export function ContractDetailsTab({ contract }: { contract: ContractDetail }) {
const qc = useQueryClient() const qc = useQueryClient()
const canEdit = contract.phase === ContractPhase.DangSoanThao const canEdit = contract.phase === ContractPhase.DangSoanThao
const [editTarget, setEditTarget] = useState<EditTarget | null>(null)
const bundleQuery = useQuery({ const bundleQuery = useQuery({
queryKey: ['contract-details', contract.id], queryKey: ['contract-details', contract.id],
queryFn: async () => (await api.get<ContractDetailsBundle>(`/contracts/${contract.id}/details`)).data, 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({ const deleteRow = useMutation({
mutationFn: async (detailId: string) => { mutationFn: async (detailId: string) => {
await api.delete(`/contracts/${contract.id}/details/${detailId}`) await api.delete(`/contracts/${contract.id}/details/${detailId}`)
}, },
onSuccess: () => { onSuccess: () => { invalidate(); toast.success('Đã xóa') },
qc.invalidateQueries({ queryKey: ['contract-details', contract.id] })
qc.invalidateQueries({ queryKey: ['contract-changelogs', contract.id] })
toast.success('Đã xóa')
},
onError: err => toast.error(getErrorMessage(err)), onError: err => toast.error(getErrorMessage(err)),
}) })
@ -61,25 +68,26 @@ export function ContractDetailsTab({ contract }: { contract: ContractDetail }) {
const bundle = bundleQuery.data const bundle = bundleQuery.data
const onEdit = canEdit
? (row: Record<string, unknown>) => setEditTarget({ row })
: undefined
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{bundle.type === 1 && <ThauPhuTable rows={bundle.thauPhu} 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} 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} 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} 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} 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} 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} canEdit={canEdit} />} {bundle.type === 7 && <NguyenTacDvTable rows={bundle.nguyenTacDv} onDelete={deleteRow.mutate} onEdit={onEdit} canEdit={canEdit} />}
{canEdit && ( {canEdit && (
<AddRowForm <AddRowForm
contractId={contract.id} contractId={contract.id}
contractType={contract.type} contractType={contract.type}
existingCount={getRowCount(bundle)} existingCount={getRowCount(bundle)}
onAdded={() => { onAdded={invalidate}
qc.invalidateQueries({ queryKey: ['contract-details', contract.id] })
qc.invalidateQueries({ queryKey: ['contract-changelogs', contract.id] })
}}
/> />
)} )}
@ -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. 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> </div>
)} )}
<EditRowDialog
target={editTarget}
contractId={contract.id}
contractType={contract.type}
onClose={() => setEditTarget(null)}
onSaved={() => { invalidate(); setEditTarget(null) }}
/>
</div> </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 ( return (
<button <div className="flex justify-end gap-0.5">
onClick={() => { if (confirm('Xóa dòng này?')) onDelete() }} {onEdit && (
className="text-slate-400 hover:text-red-600" <button
aria-label="Xóa" onClick={onEdit}
> className="rounded p-0.5 text-slate-400 hover:bg-slate-100 hover:text-brand-600"
<Trash2 className="h-3.5 w-3.5" /> aria-label="Sửa"
</button> 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) 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 ( return (
<TableShell <TableShell
headers={['Hạng mục', 'ĐVT', 'Khối lượng', 'Đơn giá', 'Thành tiền', 'Hoàn thành', 'Ghi chú']} 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-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.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 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> </tr>
))} ))}
</TableShell> </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 ( return (
<TableShell <TableShell
headers={['Mã CV', 'Tên công việc', 'ĐVT', 'KL', 'Đơn giá', 'Thành tiền', 'Hoàn thành']} 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">{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-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.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> </tr>
))} ))}
</TableShell> </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 ( return (
<TableShell <TableShell
headers={['Mã SP', 'Tên SP', 'ĐVT', 'SL', 'Đơn giá', 'Thành tiền', 'Giao hàng']} 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">{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-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 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> </tr>
))} ))}
</TableShell> </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 ( return (
<TableShell <TableShell
headers={['Mã DV', 'Tên DV', 'ĐVT', 'Thời gian', 'Đơn giá', 'Thành tiền']} 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.thoiGian)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGia)}</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 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> </tr>
))} ))}
</TableShell> </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 ( return (
<TableShell <TableShell
headers={['Mã SP', 'Tên SP', 'ĐVT', 'SL', 'Đơn giá', 'VAT (%)', 'Thành tiền']} 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">{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 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 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> </tr>
))} ))}
</TableShell> </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 ( return (
<TableShell headers={['Nhóm SP', 'Tên SP', 'ĐVT', 'Giá min', 'Giá max', 'Điều kiện thanh toán']}> <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>} {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.donGiaToiThieu)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGiaToiDa)}</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 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> </tr>
))} ))}
</TableShell> </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 ( return (
<TableShell headers={['Loại DV', 'Tên DV', 'ĐVT', 'Giá min', 'Giá max', 'SLA']}> <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>} {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.donGiaToiThieu)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGiaToiDa)}</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 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> </tr>
))} ))}
</TableShell> </TableShell>
@ -569,3 +598,86 @@ function buildPayload(contractType: number, order: number, form: Record<string,
} }
return common 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>
)
}

View File

@ -1,12 +1,13 @@
// Tab "Chi tiết" — hiện table line items theo loại HĐ (7 schema khác nhau, // 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ỉ // 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). // 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 { 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 { toast } from 'sonner'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Dialog } from '@/components/ui/Dialog'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { ContractPhase, type ContractDetail } from '@/types/contracts' import { ContractPhase, type ContractDetail } from '@/types/contracts'
@ -21,6 +22,10 @@ import type {
NguyenTacDvDetail, NguyenTacDvDetail,
} from '@/types/contract-details' } 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') const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
// Map ContractType → URL slug + render config // Map ContractType → URL slug + render config
@ -38,21 +43,23 @@ const TYPE_TO_SLUG: Record<number, TypeKey> = {
export function ContractDetailsTab({ contract }: { contract: ContractDetail }) { export function ContractDetailsTab({ contract }: { contract: ContractDetail }) {
const qc = useQueryClient() const qc = useQueryClient()
const canEdit = contract.phase === ContractPhase.DangSoanThao const canEdit = contract.phase === ContractPhase.DangSoanThao
const [editTarget, setEditTarget] = useState<EditTarget | null>(null)
const bundleQuery = useQuery({ const bundleQuery = useQuery({
queryKey: ['contract-details', contract.id], queryKey: ['contract-details', contract.id],
queryFn: async () => (await api.get<ContractDetailsBundle>(`/contracts/${contract.id}/details`)).data, 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({ const deleteRow = useMutation({
mutationFn: async (detailId: string) => { mutationFn: async (detailId: string) => {
await api.delete(`/contracts/${contract.id}/details/${detailId}`) await api.delete(`/contracts/${contract.id}/details/${detailId}`)
}, },
onSuccess: () => { onSuccess: () => { invalidate(); toast.success('Đã xóa') },
qc.invalidateQueries({ queryKey: ['contract-details', contract.id] })
qc.invalidateQueries({ queryKey: ['contract-changelogs', contract.id] })
toast.success('Đã xóa')
},
onError: err => toast.error(getErrorMessage(err)), onError: err => toast.error(getErrorMessage(err)),
}) })
@ -61,25 +68,26 @@ export function ContractDetailsTab({ contract }: { contract: ContractDetail }) {
const bundle = bundleQuery.data const bundle = bundleQuery.data
const onEdit = canEdit
? (row: Record<string, unknown>) => setEditTarget({ row })
: undefined
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{bundle.type === 1 && <ThauPhuTable rows={bundle.thauPhu} 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} 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} 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} 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} 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} 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} canEdit={canEdit} />} {bundle.type === 7 && <NguyenTacDvTable rows={bundle.nguyenTacDv} onDelete={deleteRow.mutate} onEdit={onEdit} canEdit={canEdit} />}
{canEdit && ( {canEdit && (
<AddRowForm <AddRowForm
contractId={contract.id} contractId={contract.id}
contractType={contract.type} contractType={contract.type}
existingCount={getRowCount(bundle)} existingCount={getRowCount(bundle)}
onAdded={() => { onAdded={invalidate}
qc.invalidateQueries({ queryKey: ['contract-details', contract.id] })
qc.invalidateQueries({ queryKey: ['contract-changelogs', contract.id] })
}}
/> />
)} )}
@ -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. 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> </div>
)} )}
<EditRowDialog
target={editTarget}
contractId={contract.id}
contractType={contract.type}
onClose={() => setEditTarget(null)}
onSaved={() => { invalidate(); setEditTarget(null) }}
/>
</div> </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 ( return (
<button <div className="flex justify-end gap-0.5">
onClick={() => { if (confirm('Xóa dòng này?')) onDelete() }} {onEdit && (
className="text-slate-400 hover:text-red-600" <button
aria-label="Xóa" onClick={onEdit}
> className="rounded p-0.5 text-slate-400 hover:bg-slate-100 hover:text-brand-600"
<Trash2 className="h-3.5 w-3.5" /> aria-label="Sửa"
</button> 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) 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 ( return (
<TableShell <TableShell
headers={['Hạng mục', 'ĐVT', 'Khối lượng', 'Đơn giá', 'Thành tiền', 'Hoàn thành', 'Ghi chú']} 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-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.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 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> </tr>
))} ))}
</TableShell> </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 ( return (
<TableShell <TableShell
headers={['Mã CV', 'Tên công việc', 'ĐVT', 'KL', 'Đơn giá', 'Thành tiền', 'Hoàn thành']} 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">{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-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.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> </tr>
))} ))}
</TableShell> </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 ( return (
<TableShell <TableShell
headers={['Mã SP', 'Tên SP', 'ĐVT', 'SL', 'Đơn giá', 'Thành tiền', 'Giao hàng']} 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">{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-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 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> </tr>
))} ))}
</TableShell> </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 ( return (
<TableShell <TableShell
headers={['Mã DV', 'Tên DV', 'ĐVT', 'Thời gian', 'Đơn giá', 'Thành tiền']} 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.thoiGian)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGia)}</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 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> </tr>
))} ))}
</TableShell> </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 ( return (
<TableShell <TableShell
headers={['Mã SP', 'Tên SP', 'ĐVT', 'SL', 'Đơn giá', 'VAT (%)', 'Thành tiền']} 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">{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 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 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> </tr>
))} ))}
</TableShell> </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 ( return (
<TableShell headers={['Nhóm SP', 'Tên SP', 'ĐVT', 'Giá min', 'Giá max', 'Điều kiện thanh toán']}> <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>} {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.donGiaToiThieu)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGiaToiDa)}</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 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> </tr>
))} ))}
</TableShell> </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 ( return (
<TableShell headers={['Loại DV', 'Tên DV', 'ĐVT', 'Giá min', 'Giá max', 'SLA']}> <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>} {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.donGiaToiThieu)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGiaToiDa)}</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 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> </tr>
))} ))}
</TableShell> </TableShell>
@ -569,3 +598,86 @@ function buildPayload(contractType: number, order: number, form: Record<string,
} }
return common 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>
)
}

103
scripts/deps-audit.ps1 Normal file
View File

@ -0,0 +1,103 @@
#!/usr/bin/env pwsh
# Dependency vulnerability audit cho SOLUTION_ERP
# Usage: pwsh scripts/deps-audit.ps1 [-FailOnHigh]
#
# Scan:
# 1. NuGet vulnerable (BE) — dotnet list package --vulnerable --include-transitive
# 2. npm audit (fe-admin + fe-user) — level >= moderate
#
# Exit code:
# 0 — clean
# 1 — vulnerabilities found (only fail with -FailOnHigh)
#
# Skill reference: .claude/skills/dependency-audit-erp/SKILL.md
param(
[switch]$FailOnHigh
)
$ErrorActionPreference = "Continue"
$script:hasIssues = $false
function Write-Section($title) {
Write-Host ""
Write-Host "===== $title =====" -ForegroundColor Cyan
}
# ========= 1. NuGet =========
Write-Section "NuGet vulnerabilities (BE .NET 10)"
Push-Location $PSScriptRoot/..
try {
$output = dotnet list SolutionErp.slnx package --vulnerable --include-transitive 2>&1 | Out-String
Write-Host $output
if ($output -match 'has the following vulnerable packages') {
$script:hasIssues = $true
Write-Host "[!] NuGet vulnerable packages found" -ForegroundColor Red
} else {
Write-Host "[OK] No NuGet vulnerabilities" -ForegroundColor Green
}
} catch {
Write-Host "[!] dotnet list failed: $_" -ForegroundColor Red
$script:hasIssues = $true
}
Pop-Location
# ========= 2. npm fe-admin =========
Write-Section "npm audit fe-admin"
Push-Location $PSScriptRoot/../fe-admin
try {
if (-not (Test-Path node_modules)) {
Write-Host "node_modules missing — chạy npm install trước." -ForegroundColor Yellow
} else {
$auditOutput = npm audit --audit-level=moderate 2>&1 | Out-String
Write-Host $auditOutput
if ($LASTEXITCODE -ne 0) {
$script:hasIssues = $true
Write-Host "[!] fe-admin npm audit found issues" -ForegroundColor Red
} else {
Write-Host "[OK] fe-admin npm clean" -ForegroundColor Green
}
}
} catch {
Write-Host "[!] npm audit fe-admin failed: $_" -ForegroundColor Red
$script:hasIssues = $true
}
Pop-Location
# ========= 3. npm fe-user =========
Write-Section "npm audit fe-user"
Push-Location $PSScriptRoot/../fe-user
try {
if (-not (Test-Path node_modules)) {
Write-Host "node_modules missing — chạy npm install trước." -ForegroundColor Yellow
} else {
$auditOutput = npm audit --audit-level=moderate 2>&1 | Out-String
Write-Host $auditOutput
if ($LASTEXITCODE -ne 0) {
$script:hasIssues = $true
Write-Host "[!] fe-user npm audit found issues" -ForegroundColor Red
} else {
Write-Host "[OK] fe-user npm clean" -ForegroundColor Green
}
}
} catch {
Write-Host "[!] npm audit fe-user failed: $_" -ForegroundColor Red
$script:hasIssues = $true
}
Pop-Location
# ========= Summary =========
Write-Section "Summary"
if ($script:hasIssues) {
Write-Host "[!] Vulnerabilities or issues found." -ForegroundColor Red
Write-Host "Tham khao .claude/skills/dependency-audit-erp/SKILL.md cho workflow fix."
Write-Host "Nho check pin constraints (MediatR 12.4.1, Swashbuckle 6.9.0, Node 20) truoc khi npm audit fix."
if ($FailOnHigh) {
exit 1
}
} else {
Write-Host "[OK] All clean." -ForegroundColor Green
}

View File

@ -164,6 +164,55 @@ public class ContractsController(IMediator mediator) : ControllerBase
return NoContent(); return NoContent();
} }
[HttpPut("{id:guid}/details/thau-phu/{detailId:guid}")]
public async Task<IActionResult> UpdateThauPhuDetail(Guid id, Guid detailId, [FromBody] ThauPhuDetailDto body, CancellationToken ct)
{
await mediator.Send(new UpdateThauPhuDetailCommand(id, detailId, body), ct);
return NoContent();
}
[HttpPut("{id:guid}/details/giao-khoan/{detailId:guid}")]
public async Task<IActionResult> UpdateGiaoKhoanDetail(Guid id, Guid detailId, [FromBody] GiaoKhoanDetailDto body, CancellationToken ct)
{
await mediator.Send(new UpdateGiaoKhoanDetailCommand(id, detailId, body), ct);
return NoContent();
}
[HttpPut("{id:guid}/details/nha-cung-cap/{detailId:guid}")]
public async Task<IActionResult> UpdateNhaCungCapDetail(Guid id, Guid detailId, [FromBody] NhaCungCapDetailDto body, CancellationToken ct)
{
await mediator.Send(new UpdateNhaCungCapDetailCommand(id, detailId, body), ct);
return NoContent();
}
[HttpPut("{id:guid}/details/dich-vu/{detailId:guid}")]
public async Task<IActionResult> UpdateDichVuDetail(Guid id, Guid detailId, [FromBody] DichVuDetailDto body, CancellationToken ct)
{
await mediator.Send(new UpdateDichVuDetailCommand(id, detailId, body), ct);
return NoContent();
}
[HttpPut("{id:guid}/details/mua-ban/{detailId:guid}")]
public async Task<IActionResult> UpdateMuaBanDetail(Guid id, Guid detailId, [FromBody] MuaBanDetailDto body, CancellationToken ct)
{
await mediator.Send(new UpdateMuaBanDetailCommand(id, detailId, body), ct);
return NoContent();
}
[HttpPut("{id:guid}/details/nguyen-tac-ncc/{detailId:guid}")]
public async Task<IActionResult> UpdateNguyenTacNccDetail(Guid id, Guid detailId, [FromBody] NguyenTacNccDetailDto body, CancellationToken ct)
{
await mediator.Send(new UpdateNguyenTacNccDetailCommand(id, detailId, body), ct);
return NoContent();
}
[HttpPut("{id:guid}/details/nguyen-tac-dv/{detailId:guid}")]
public async Task<IActionResult> UpdateNguyenTacDvDetail(Guid id, Guid detailId, [FromBody] NguyenTacDvDetailDto body, CancellationToken ct)
{
await mediator.Send(new UpdateNguyenTacDvDetailCommand(id, detailId, body), ct);
return NoContent();
}
// ========== Changelogs (read-only) ========== // ========== Changelogs (read-only) ==========
[HttpGet("{id:guid}/changelogs")] [HttpGet("{id:guid}/changelogs")]

View File

@ -276,6 +276,144 @@ public class AddNguyenTacDvDetailHandler(IApplicationDbContext db, IChangelogSer
} }
} }
// ========== UPDATE detail (per type — typed) ==========
// Mỗi type có handler riêng để TS strict + validation đúng schema. Pattern
// lặp giống Add commands. ID trong URL phải khớp với detail thuộc đúng
// contract + đúng type (EnsureContractType guard).
public record UpdateThauPhuDetailCommand(Guid ContractId, Guid DetailId, ThauPhuDetailDto Detail) : IRequest;
public class UpdateThauPhuDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler<UpdateThauPhuDetailCommand>
{
public async Task Handle(UpdateThauPhuDetailCommand cmd, CancellationToken ct)
{
var contract = await EnsureContractType(db, cmd.ContractId, ContractType.HopDongThauPhu, ct);
var entity = await db.ThauPhuDetails.FirstOrDefaultAsync(x => x.Id == cmd.DetailId && x.ContractId == cmd.ContractId, ct)
?? throw new NotFoundException("ThauPhuDetail", cmd.DetailId);
var d = cmd.Detail;
entity.Order = d.Order; entity.HangMuc = d.HangMuc; entity.DonViTinh = d.DonViTinh;
entity.KhoiLuong = d.KhoiLuong; entity.DonGia = d.DonGia; entity.ThanhTien = d.ThanhTien;
entity.ThoiGianHoanThanh = d.ThoiGianHoanThanh; entity.GhiChu = d.GhiChu;
await changelog.LogDetailChangeAsync(contract.Id, entity.Id, ChangelogAction.Update,
summary: $"Sửa hạng mục: {d.HangMuc}", phaseAtChange: contract.Phase, ct: ct);
await db.SaveChangesAsync(ct);
}
}
public record UpdateGiaoKhoanDetailCommand(Guid ContractId, Guid DetailId, GiaoKhoanDetailDto Detail) : IRequest;
public class UpdateGiaoKhoanDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler<UpdateGiaoKhoanDetailCommand>
{
public async Task Handle(UpdateGiaoKhoanDetailCommand cmd, CancellationToken ct)
{
var contract = await EnsureContractType(db, cmd.ContractId, ContractType.HopDongGiaoKhoan, ct);
var entity = await db.GiaoKhoanDetails.FirstOrDefaultAsync(x => x.Id == cmd.DetailId && x.ContractId == cmd.ContractId, ct)
?? throw new NotFoundException("GiaoKhoanDetail", cmd.DetailId);
var d = cmd.Detail;
entity.Order = d.Order; entity.MaCongViec = d.MaCongViec; entity.TenCongViec = d.TenCongViec;
entity.DonViTinh = d.DonViTinh; entity.KhoiLuong = d.KhoiLuong; entity.DonGia = d.DonGia;
entity.ThanhTien = d.ThanhTien; entity.ThoiGianHoanThanh = d.ThoiGianHoanThanh;
entity.YeuCauKyThuat = d.YeuCauKyThuat; entity.GhiChu = d.GhiChu;
await changelog.LogDetailChangeAsync(contract.Id, entity.Id, ChangelogAction.Update,
summary: $"Sửa công việc: {d.TenCongViec}", phaseAtChange: contract.Phase, ct: ct);
await db.SaveChangesAsync(ct);
}
}
public record UpdateNhaCungCapDetailCommand(Guid ContractId, Guid DetailId, NhaCungCapDetailDto Detail) : IRequest;
public class UpdateNhaCungCapDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler<UpdateNhaCungCapDetailCommand>
{
public async Task Handle(UpdateNhaCungCapDetailCommand cmd, CancellationToken ct)
{
var contract = await EnsureContractType(db, cmd.ContractId, ContractType.HopDongNhaCungCap, ct);
var entity = await db.NhaCungCapDetails.FirstOrDefaultAsync(x => x.Id == cmd.DetailId && x.ContractId == cmd.ContractId, ct)
?? throw new NotFoundException("NhaCungCapDetail", cmd.DetailId);
var d = cmd.Detail;
entity.Order = d.Order; entity.MaSP = d.MaSP; entity.TenSP = d.TenSP;
entity.ThongSoKyThuat = d.ThongSoKyThuat; entity.DonViTinh = d.DonViTinh;
entity.SoLuong = d.SoLuong; entity.DonGia = d.DonGia; entity.ThanhTien = d.ThanhTien;
entity.ThoiGianGiao = d.ThoiGianGiao; entity.XuatXu = d.XuatXu; entity.GhiChu = d.GhiChu;
await changelog.LogDetailChangeAsync(contract.Id, entity.Id, ChangelogAction.Update,
summary: $"Sửa SP: {d.TenSP}", phaseAtChange: contract.Phase, ct: ct);
await db.SaveChangesAsync(ct);
}
}
public record UpdateDichVuDetailCommand(Guid ContractId, Guid DetailId, DichVuDetailDto Detail) : IRequest;
public class UpdateDichVuDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler<UpdateDichVuDetailCommand>
{
public async Task Handle(UpdateDichVuDetailCommand cmd, CancellationToken ct)
{
var contract = await EnsureContractType(db, cmd.ContractId, ContractType.HopDongDichVu, ct);
var entity = await db.DichVuDetails.FirstOrDefaultAsync(x => x.Id == cmd.DetailId && x.ContractId == cmd.ContractId, ct)
?? throw new NotFoundException("DichVuDetail", cmd.DetailId);
var d = cmd.Detail;
entity.Order = d.Order; entity.MaDichVu = d.MaDichVu; entity.TenDichVu = d.TenDichVu;
entity.MoTa = d.MoTa; entity.DonViTinh = d.DonViTinh; entity.ThoiGian = d.ThoiGian;
entity.DonGia = d.DonGia; entity.ThanhTien = d.ThanhTien;
entity.TuNgay = d.TuNgay; entity.DenNgay = d.DenNgay; entity.GhiChu = d.GhiChu;
await changelog.LogDetailChangeAsync(contract.Id, entity.Id, ChangelogAction.Update,
summary: $"Sửa DV: {d.TenDichVu}", phaseAtChange: contract.Phase, ct: ct);
await db.SaveChangesAsync(ct);
}
}
public record UpdateMuaBanDetailCommand(Guid ContractId, Guid DetailId, MuaBanDetailDto Detail) : IRequest;
public class UpdateMuaBanDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler<UpdateMuaBanDetailCommand>
{
public async Task Handle(UpdateMuaBanDetailCommand cmd, CancellationToken ct)
{
var contract = await EnsureContractType(db, cmd.ContractId, ContractType.HopDongMuaBan, ct);
var entity = await db.MuaBanDetails.FirstOrDefaultAsync(x => x.Id == cmd.DetailId && x.ContractId == cmd.ContractId, ct)
?? throw new NotFoundException("MuaBanDetail", cmd.DetailId);
var d = cmd.Detail;
entity.Order = d.Order; entity.MaSP = d.MaSP; entity.TenSP = d.TenSP;
entity.MoTa = d.MoTa; entity.DonViTinh = d.DonViTinh;
entity.SoLuong = d.SoLuong; entity.DonGia = d.DonGia;
entity.ThueVAT = d.ThueVAT; entity.ThanhTien = d.ThanhTien;
entity.XuatXu = d.XuatXu; entity.GhiChu = d.GhiChu;
await changelog.LogDetailChangeAsync(contract.Id, entity.Id, ChangelogAction.Update,
summary: $"Sửa SP: {d.TenSP}", phaseAtChange: contract.Phase, ct: ct);
await db.SaveChangesAsync(ct);
}
}
public record UpdateNguyenTacNccDetailCommand(Guid ContractId, Guid DetailId, NguyenTacNccDetailDto Detail) : IRequest;
public class UpdateNguyenTacNccDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler<UpdateNguyenTacNccDetailCommand>
{
public async Task Handle(UpdateNguyenTacNccDetailCommand cmd, CancellationToken ct)
{
var contract = await EnsureContractType(db, cmd.ContractId, ContractType.HopDongNguyenTacNCC, ct);
var entity = await db.NguyenTacNccDetails.FirstOrDefaultAsync(x => x.Id == cmd.DetailId && x.ContractId == cmd.ContractId, ct)
?? throw new NotFoundException("NguyenTacNccDetail", cmd.DetailId);
var d = cmd.Detail;
entity.Order = d.Order; entity.NhomSP = d.NhomSP; entity.TenSP = d.TenSP;
entity.DonViTinh = d.DonViTinh; entity.DonGiaToiThieu = d.DonGiaToiThieu;
entity.DonGiaToiDa = d.DonGiaToiDa; entity.DieuKienGiaoHang = d.DieuKienGiaoHang;
entity.DieuKienThanhToan = d.DieuKienThanhToan; entity.GhiChu = d.GhiChu;
await changelog.LogDetailChangeAsync(contract.Id, entity.Id, ChangelogAction.Update,
summary: $"Sửa SP nguyên tắc: {d.TenSP}", phaseAtChange: contract.Phase, ct: ct);
await db.SaveChangesAsync(ct);
}
}
public record UpdateNguyenTacDvDetailCommand(Guid ContractId, Guid DetailId, NguyenTacDvDetailDto Detail) : IRequest;
public class UpdateNguyenTacDvDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler<UpdateNguyenTacDvDetailCommand>
{
public async Task Handle(UpdateNguyenTacDvDetailCommand cmd, CancellationToken ct)
{
var contract = await EnsureContractType(db, cmd.ContractId, ContractType.HopDongNguyenTacDichVu, ct);
var entity = await db.NguyenTacDvDetails.FirstOrDefaultAsync(x => x.Id == cmd.DetailId && x.ContractId == cmd.ContractId, ct)
?? throw new NotFoundException("NguyenTacDvDetail", cmd.DetailId);
var d = cmd.Detail;
entity.Order = d.Order; entity.LoaiDichVu = d.LoaiDichVu; entity.TenDichVu = d.TenDichVu;
entity.DonViTinh = d.DonViTinh; entity.DonGiaToiThieu = d.DonGiaToiThieu;
entity.DonGiaToiDa = d.DonGiaToiDa; entity.PhamViDichVu = d.PhamViDichVu;
entity.SLA = d.SLA; entity.GhiChu = d.GhiChu;
await changelog.LogDetailChangeAsync(contract.Id, entity.Id, ChangelogAction.Update,
summary: $"Sửa DV nguyên tắc: {d.TenDichVu}", phaseAtChange: contract.Phase, ct: ct);
await db.SaveChangesAsync(ct);
}
}
// ========== DELETE detail (generic — dispatch theo Type) ========== // ========== DELETE detail (generic — dispatch theo Type) ==========
public record DeleteContractDetailCommand(Guid ContractId, Guid DetailId) : IRequest; public record DeleteContractDetailCommand(Guid ContractId, Guid DetailId) : IRequest;