// 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 { useEffect, useMemo, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' 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' import type { ContractDetailsBundle, ThauPhuDetail, GiaoKhoanDetail, NhaCungCapDetail, DichVuDetail, MuaBanDetail, NguyenTacNccDetail, 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 } const fmtMoney = (v: number) => v.toLocaleString('vi-VN') // Map ContractType → URL slug + render config type TypeKey = 'thau-phu' | 'giao-khoan' | 'nha-cung-cap' | 'dich-vu' | 'mua-ban' | 'nguyen-tac-ncc' | 'nguyen-tac-dv' const TYPE_TO_SLUG: Record = { 1: 'thau-phu', 2: 'giao-khoan', 3: 'nha-cung-cap', 4: 'dich-vu', 5: 'mua-ban', 6: 'nguyen-tac-ncc', 7: 'nguyen-tac-dv', } export function ContractDetailsTab({ contract }: { contract: ContractDetail }) { const qc = useQueryClient() const canEdit = contract.phase === ContractPhase.DangSoanThao const [editTarget, setEditTarget] = useState(null) const bundleQuery = useQuery({ queryKey: ['contract-details', contract.id], queryFn: async () => (await api.get(`/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: () => { invalidate(); toast.success('Đã xóa') }, onError: err => toast.error(getErrorMessage(err)), }) if (bundleQuery.isLoading) return
Đang tải chi tiết…
if (!bundleQuery.data) return
Không có chi tiết.
const bundle = bundleQuery.data const onEdit = canEdit ? (row: Record) => setEditTarget({ row }) : undefined return (
{bundle.type === 1 && } {bundle.type === 2 && } {bundle.type === 3 && } {bundle.type === 4 && } {bundle.type === 5 && } {bundle.type === 6 && } {bundle.type === 7 && } {canEdit && ( )} {!canEdit && (
⚠ 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.
)} setEditTarget(null)} onSaved={() => { invalidate(); setEditTarget(null) }} />
) } function getRowCount(bundle: ContractDetailsBundle): number { return ( bundle.thauPhu.length + bundle.giaoKhoan.length + bundle.nhaCungCap.length + bundle.dichVu.length + bundle.muaBan.length + bundle.nguyenTacNcc.length + bundle.nguyenTacDv.length ) } // ===== Per-type table renderers (gộp 1 file để dễ maintain) ===== function TableShell({ headers, totalRow, children }: { headers: string[]; totalRow?: React.ReactNode; children: React.ReactNode }) { return (
{headers.map(h => )} {children} {totalRow}
#{h}
) } function ActionBtns({ onEdit, onDelete }: { onEdit?: () => void; onDelete: () => void }) { return (
{onEdit && ( )}
) } function totalOf(rows: { thanhTien: number }[]): number { return rows.reduce((s, r) => s + (r.thanhTien ?? 0), 0) } function ThauPhuTable({ rows, onDelete, onEdit, canEdit }: { rows: ThauPhuDetail[]; onDelete: (id: string) => void; onEdit?: (row: Record) => void; canEdit: boolean }) { return ( 0 ? Tổng:{fmtMoney(totalOf(rows))} : undefined} > {rows.length === 0 && Chưa có hạng mục.} {rows.map((r, i) => ( {i + 1} {r.hangMuc} {r.donViTinh} {fmtMoney(r.khoiLuong)} {fmtMoney(r.donGia)} {fmtMoney(r.thanhTien)} {r.thoiGianHoanThanh ? new Date(r.thoiGianHoanThanh).toLocaleDateString('vi-VN') : '—'} {r.ghiChu ?? ''} {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} ) } function GiaoKhoanTable({ rows, onDelete, onEdit, canEdit }: { rows: GiaoKhoanDetail[]; onDelete: (id: string) => void; onEdit?: (row: Record) => void; canEdit: boolean }) { return ( 0 ? Tổng:{fmtMoney(totalOf(rows))} : undefined} > {rows.length === 0 && Chưa có công việc.} {rows.map((r, i) => ( {i + 1} {r.maCongViec} {r.tenCongViec} {r.donViTinh} {fmtMoney(r.khoiLuong)} {fmtMoney(r.donGia)} {fmtMoney(r.thanhTien)} {r.thoiGianHoanThanh ? new Date(r.thoiGianHoanThanh).toLocaleDateString('vi-VN') : '—'} {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} ) } function NhaCungCapTable({ rows, onDelete, onEdit, canEdit }: { rows: NhaCungCapDetail[]; onDelete: (id: string) => void; onEdit?: (row: Record) => void; canEdit: boolean }) { return ( 0 ? Tổng:{fmtMoney(totalOf(rows))} : undefined} > {rows.length === 0 && Chưa có sản phẩm.} {rows.map((r, i) => ( {i + 1} {r.maSP} {r.tenSP} {r.donViTinh} {fmtMoney(r.soLuong)} {fmtMoney(r.donGia)} {fmtMoney(r.thanhTien)} {r.thoiGianGiao ? new Date(r.thoiGianGiao).toLocaleDateString('vi-VN') : '—'} {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} ) } function DichVuTable({ rows, onDelete, onEdit, canEdit }: { rows: DichVuDetail[]; onDelete: (id: string) => void; onEdit?: (row: Record) => void; canEdit: boolean }) { return ( 0 ? Tổng:{fmtMoney(totalOf(rows))} : undefined} > {rows.length === 0 && Chưa có dịch vụ.} {rows.map((r, i) => ( {i + 1} {r.maDichVu} {r.tenDichVu} {r.donViTinh} {fmtMoney(r.thoiGian)} {fmtMoney(r.donGia)} {fmtMoney(r.thanhTien)} {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} ) } function MuaBanTable({ rows, onDelete, onEdit, canEdit }: { rows: MuaBanDetail[]; onDelete: (id: string) => void; onEdit?: (row: Record) => void; canEdit: boolean }) { return ( 0 ? Tổng:{fmtMoney(totalOf(rows))} : undefined} > {rows.length === 0 && Chưa có sản phẩm.} {rows.map((r, i) => ( {i + 1} {r.maSP} {r.tenSP} {r.donViTinh} {fmtMoney(r.soLuong)} {fmtMoney(r.donGia)} {r.thueVAT}% {fmtMoney(r.thanhTien)} {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} ) } function NguyenTacNccTable({ rows, onDelete, onEdit, canEdit }: { rows: NguyenTacNccDetail[]; onDelete: (id: string) => void; onEdit?: (row: Record) => void; canEdit: boolean }) { return ( {rows.length === 0 && Chưa có SP.} {rows.map((r, i) => ( {i + 1} {r.nhomSP} {r.tenSP} {r.donViTinh} {fmtMoney(r.donGiaToiThieu)} {fmtMoney(r.donGiaToiDa)} {r.dieuKienThanhToan ?? '—'} {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} ) } function NguyenTacDvTable({ rows, onDelete, onEdit, canEdit }: { rows: NguyenTacDvDetail[]; onDelete: (id: string) => void; onEdit?: (row: Record) => void; canEdit: boolean }) { return ( {rows.length === 0 && Chưa có DV.} {rows.map((r, i) => ( {i + 1} {r.loaiDichVu} {r.tenDichVu} {r.donViTinh} {fmtMoney(r.donGiaToiThieu)} {fmtMoney(r.donGiaToiDa)} {r.sla ?? '—'} {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} ) } // ===== Add row form — minimal: chỉ field bắt buộc, advanced edit qua FE // fullpage form sau (Iter 2). Hiện tại hỗ trợ quick-add 5-7 field per type. function AddRowForm({ contractId, contractType, existingCount, onAdded, }: { contractId: string contractType: number existingCount: number onAdded: () => void }) { const [open, setOpen] = useState(false) const slug = useMemo(() => TYPE_TO_SLUG[contractType], [contractType]) if (!slug) return null return (
{!open && ( )} {open && ( setOpen(false)} onAdded={() => { setOpen(false); onAdded() }} /> )}
) } function AddRowFields({ contractId, slug, contractType, nextOrder, onCancel, onAdded, }: { contractId: string slug: TypeKey contractType: number nextOrder: number onCancel: () => void onAdded: () => void }) { const [form, setForm] = useState>({}) // Load 4 catalogs cho datalist autocomplete (1 lần, cache TanStack) const units = useQuery({ queryKey: ['catalogs', 'units'], queryFn: async () => (await api.get('/catalogs/units')).data, }) const materials = useQuery({ queryKey: ['catalogs', 'materials'], queryFn: async () => (await api.get('/catalogs/materials')).data, }) const services = useQuery({ queryKey: ['catalogs', 'services'], queryFn: async () => (await api.get('/catalogs/services')).data, }) const workItems = useQuery({ queryKey: ['catalogs', 'work-items'], queryFn: async () => (await api.get('/catalogs/work-items')).data, }) const catalogData: Record, CatalogItem[]> = { units: units.data ?? [], materials: materials.data ?? [], services: services.data ?? [], 'work-items': workItems.data ?? [], } const submit = useMutation({ mutationFn: async () => { const payload = buildPayload(contractType, nextOrder, form) await api.post(`/contracts/${contractId}/details/${slug}`, payload) }, onSuccess: () => { toast.success('Đã thêm dòng'); onAdded() }, onError: err => toast.error(getErrorMessage(err)), }) const fields = FIELDS_BY_TYPE[contractType] ?? [] // Smart-fill: khi user pick value khớp catalog item, autofill các field // liên quan (defaultUnit cho donViTinh, name cho codeField siblings). function handleFieldChange(name: string, value: string) { setForm(s => { const next = { ...s, [name]: value } const fieldDef = fields.find(f => f.name === name) if (!fieldDef?.datalist) return next const items = catalogData[fieldDef.datalist] // Match theo `code` hoặc `name` (user có thể type either) const match = items.find(it => it.code === value || it.name === value) if (!match) return next // Auto-fill sibling fields nếu trống // - Nếu user pick code → fill name (sibling field same datalist) // - Nếu sibling field name 'donViTinh' chưa có giá trị → fill defaultUnit for (const sibling of fields) { if (sibling.name === name) continue if (sibling.datalist === fieldDef.datalist && !next[sibling.name]) { // Sibling cùng catalog — fill code/name correlate if (sibling.name.startsWith('ma') || sibling.name === 'maSP' || sibling.name === 'maCongViec' || sibling.name === 'maDichVu') { next[sibling.name] = match.code } else if (sibling.name.startsWith('ten') || sibling.name === 'tenSP' || sibling.name === 'hangMuc' || sibling.name === 'tenCongViec' || sibling.name === 'tenDichVu') { next[sibling.name] = match.name } } if (sibling.name === 'donViTinh' && !next.donViTinh && match.defaultUnit) { next.donViTinh = match.defaultUnit } } return next }) } return (
{ e.preventDefault(); submit.mutate() }} className="space-y-2 rounded-lg border border-brand-200 bg-brand-50/30 p-3" >
{fields.map(f => { const datalistId = f.datalist ? `dl-${f.datalist}-${f.name}` : undefined const items = f.datalist ? catalogData[f.datalist] : [] return (
handleFieldChange(f.name, e.target.value)} placeholder={f.placeholder} step={f.type === 'number' ? 'any' : undefined} required={f.required} className="text-xs" list={datalistId} /> {datalistId && items.length > 0 && ( {items.map(it => ( ))} )}
) })}
) } type CatalogItem = { id: string code: string name: string defaultUnit?: string | null category?: string | null } type FieldDef = { name: string label: string type: 'text' | 'number' | 'date' required?: boolean placeholder?: string /** Catalog source cho datalist autocomplete */ datalist?: 'units' | 'materials' | 'services' | 'work-items' /** Field nào của catalog item là value: 'code' hoặc 'name' (default: 'name') */ datalistField?: 'code' | 'name' } // Per-type field config + datalist source. User type/pick → smart-fill sibling // fields qua handleFieldChange (vd pick MaSP từ materials → autofill TenSP + // donViTinh từ defaultUnit). const FIELDS_BY_TYPE: Record = { 1: [ // ThauPhu — autocomplete WorkItems + Units { name: 'hangMuc', label: 'Hạng mục *', type: 'text', required: true, datalist: 'work-items', datalistField: 'name' }, { name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, placeholder: 'm2, kg...', datalist: 'units', datalistField: 'code' }, { name: 'khoiLuong', label: 'Khối lượng *', type: 'number', required: true }, { name: 'donGia', label: 'Đơn giá *', type: 'number', required: true }, { name: 'thoiGianHoanThanh', label: 'Hoàn thành', type: 'date' }, { name: 'ghiChu', label: 'Ghi chú', type: 'text' }, ], 2: [ // GiaoKhoan — autocomplete WorkItems + Units { name: 'maCongViec', label: 'Mã CV *', type: 'text', required: true, datalist: 'work-items', datalistField: 'code' }, { name: 'tenCongViec', label: 'Tên công việc *', type: 'text', required: true, datalist: 'work-items', datalistField: 'name' }, { name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' }, { name: 'khoiLuong', label: 'KL *', type: 'number', required: true }, { name: 'donGia', label: 'Đơn giá *', type: 'number', required: true }, { name: 'thoiGianHoanThanh', label: 'Hoàn thành', type: 'date' }, ], 3: [ // NhaCungCap — autocomplete Materials + Units { name: 'maSP', label: 'Mã SP *', type: 'text', required: true, datalist: 'materials', datalistField: 'code' }, { name: 'tenSP', label: 'Tên SP *', type: 'text', required: true, datalist: 'materials', datalistField: 'name' }, { name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' }, { name: 'soLuong', label: 'SL *', type: 'number', required: true }, { name: 'donGia', label: 'Đơn giá *', type: 'number', required: true }, { name: 'thoiGianGiao', label: 'Giao hàng', type: 'date' }, { name: 'xuatXu', label: 'Xuất xứ', type: 'text' }, ], 4: [ // DichVu — autocomplete Services + Units { name: 'maDichVu', label: 'Mã DV *', type: 'text', required: true, datalist: 'services', datalistField: 'code' }, { name: 'tenDichVu', label: 'Tên DV *', type: 'text', required: true, datalist: 'services', datalistField: 'name' }, { name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' }, { name: 'thoiGian', label: 'Thời gian *', type: 'number', required: true }, { name: 'donGia', label: 'Đơn giá *', type: 'number', required: true }, ], 5: [ // MuaBan — autocomplete Materials + Units { name: 'maSP', label: 'Mã SP *', type: 'text', required: true, datalist: 'materials', datalistField: 'code' }, { name: 'tenSP', label: 'Tên SP *', type: 'text', required: true, datalist: 'materials', datalistField: 'name' }, { name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' }, { name: 'soLuong', label: 'SL *', type: 'number', required: true }, { name: 'donGia', label: 'Đơn giá *', type: 'number', required: true }, { name: 'thueVAT', label: 'VAT (%)', type: 'number', placeholder: '10' }, ], 6: [ // NguyenTacNcc — autocomplete Materials + Units { name: 'nhomSP', label: 'Nhóm SP *', type: 'text', required: true }, { name: 'tenSP', label: 'Tên SP *', type: 'text', required: true, datalist: 'materials', datalistField: 'name' }, { name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' }, { name: 'donGiaToiThieu', label: 'Giá min *', type: 'number', required: true }, { name: 'donGiaToiDa', label: 'Giá max *', type: 'number', required: true }, ], 7: [ // NguyenTacDv — autocomplete Services + Units { name: 'loaiDichVu', label: 'Loại DV *', type: 'text', required: true }, { name: 'tenDichVu', label: 'Tên DV *', type: 'text', required: true, datalist: 'services', datalistField: 'name' }, { name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, datalist: 'units', datalistField: 'code' }, { name: 'donGiaToiThieu', label: 'Giá min *', type: 'number', required: true }, { name: 'donGiaToiDa', label: 'Giá max *', type: 'number', required: true }, ], } // Build payload — convert string form values to typed fields BE expects. // thanhTien auto compute = soLuong * donGia (or khoiLuong * donGia). function buildPayload(contractType: number, order: number, form: Record): Record { const num = (k: string) => Number(form[k] ?? 0) const str = (k: string) => form[k] ?? null const date = (k: string) => form[k] ? new Date(form[k]).toISOString() : null const common = { id: '00000000-0000-0000-0000-000000000000', order, ghiChu: str('ghiChu') } switch (contractType) { case 1: // ThauPhu return { ...common, hangMuc: form.hangMuc, donViTinh: form.donViTinh, khoiLuong: num('khoiLuong'), donGia: num('donGia'), thanhTien: num('khoiLuong') * num('donGia'), thoiGianHoanThanh: date('thoiGianHoanThanh') } case 2: // GiaoKhoan return { ...common, maCongViec: form.maCongViec, tenCongViec: form.tenCongViec, donViTinh: form.donViTinh, khoiLuong: num('khoiLuong'), donGia: num('donGia'), thanhTien: num('khoiLuong') * num('donGia'), thoiGianHoanThanh: date('thoiGianHoanThanh'), yeuCauKyThuat: str('yeuCauKyThuat') } case 3: // NhaCungCap return { ...common, maSP: form.maSP, tenSP: form.tenSP, thongSoKyThuat: str('thongSoKyThuat'), donViTinh: form.donViTinh, soLuong: num('soLuong'), donGia: num('donGia'), thanhTien: num('soLuong') * num('donGia'), thoiGianGiao: date('thoiGianGiao'), xuatXu: str('xuatXu') } case 4: // DichVu return { ...common, maDichVu: form.maDichVu, tenDichVu: form.tenDichVu, moTa: str('moTa'), donViTinh: form.donViTinh, thoiGian: num('thoiGian'), donGia: num('donGia'), thanhTien: num('thoiGian') * num('donGia'), tuNgay: date('tuNgay'), denNgay: date('denNgay') } case 5: // MuaBan — thanhTien = SL * DonGia * (1 + VAT/100) return { ...common, maSP: form.maSP, tenSP: form.tenSP, moTa: str('moTa'), donViTinh: form.donViTinh, soLuong: num('soLuong'), donGia: num('donGia'), thueVAT: num('thueVAT'), thanhTien: num('soLuong') * num('donGia') * (1 + num('thueVAT') / 100), xuatXu: str('xuatXu') } case 6: // NguyenTacNcc return { ...common, nhomSP: form.nhomSP, tenSP: form.tenSP, donViTinh: form.donViTinh, donGiaToiThieu: num('donGiaToiThieu'), donGiaToiDa: num('donGiaToiDa'), dieuKienGiaoHang: str('dieuKienGiaoHang'), dieuKienThanhToan: str('dieuKienThanhToan') } case 7: // NguyenTacDv return { ...common, loaiDichVu: form.loaiDichVu, tenDichVu: form.tenDichVu, donViTinh: form.donViTinh, donGiaToiThieu: num('donGiaToiThieu'), donGiaToiDa: num('donGiaToiDa'), phamViDichVu: str('phamViDichVu'), sla: str('sla') } } return common } // ===== Edit dialog — populated từ row data, PUT thay POST ===== function EditRowDialog({ target, contractId, contractType, onClose, onSaved, }: { target: { row: Record } | null contractId: string contractType: number onClose: () => void onSaved: () => void }) { const [form, setForm] = useState>({}) // Populate khi target thay đổi (open dialog với row data) useEffect(() => { if (!target) return const init: Record = {} 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 ( } >
{fields.map(f => (
setForm(s => ({ ...s, [f.name]: e.target.value }))} placeholder={f.placeholder} step={f.type === 'number' ? 'any' : undefined} required={f.required} />
))}
) }