[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
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:
@ -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 HĐ ở phase "Đang soạn thảo". Hiện tại HĐ đã 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 (
|
||||
<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="text-slate-400 hover:text-red-600"
|
||||
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 có 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 có 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 HĐ ở phase "Đang soạn thảo". Hiện tại HĐ đã 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 (
|
||||
<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="text-slate-400 hover:text-red-600"
|
||||
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 có 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 có 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>
|
||||
)
|
||||
}
|
||||
|
||||
103
scripts/deps-audit.ps1
Normal file
103
scripts/deps-audit.ps1
Normal 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
|
||||
}
|
||||
@ -164,6 +164,55 @@ public class ContractsController(IMediator mediator) : ControllerBase
|
||||
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) ==========
|
||||
|
||||
[HttpGet("{id:guid}/changelogs")]
|
||||
|
||||
@ -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) ==========
|
||||
|
||||
public record DeleteContractDetailCommand(Guid ContractId, Guid DetailId) : IRequest;
|
||||
|
||||
Reference in New Issue
Block a user