From e53cd3a3b2d61d0091eb5a03a797b4736be2aa83 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Thu, 23 Apr 2026 15:18:53 +0700 Subject: [PATCH] [CLAUDE] App+Api+FE+Scripts: Edit detail row inline + deps audit helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 ". 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) --- .../contracts/ContractDetailsTab.tsx | 192 ++++++++++++++---- .../contracts/ContractDetailsTab.tsx | 192 ++++++++++++++---- scripts/deps-audit.ps1 | 103 ++++++++++ .../Controllers/ContractsController.cs | 49 +++++ .../Contracts/ContractDetailsFeatures.cs | 138 +++++++++++++ 5 files changed, 594 insertions(+), 80 deletions(-) create mode 100644 scripts/deps-audit.ps1 diff --git a/fe-admin/src/components/contracts/ContractDetailsTab.tsx b/fe-admin/src/components/contracts/ContractDetailsTab.tsx index fe7c75a..3de7170 100644 --- a/fe-admin/src/components/contracts/ContractDetailsTab.tsx +++ b/fe-admin/src/components/contracts/ContractDetailsTab.tsx @@ -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 } + const fmtMoney = (v: number) => v.toLocaleString('vi-VN') // Map ContractType → URL slug + render config @@ -38,21 +43,23 @@ const TYPE_TO_SLUG: Record = { 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: () => { - 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) => 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 && } + {bundle.type === 1 && } + {bundle.type === 2 && } + {bundle.type === 3 && } + {bundle.type === 4 && } + {bundle.type === 5 && } + {bundle.type === 6 && } + {bundle.type === 7 && } {canEdit && ( { - 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.
)} + + setEditTarget(null)} + onSaved={() => { invalidate(); setEditTarget(null) }} + /> ) } @@ -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 ( - +
+ {onEdit && ( + + )} + +
) } @@ -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) => void; canEdit: boolean }) { return ( {fmtMoney(r.thanhTien)} {r.thoiGianHoanThanh ? new Date(r.thoiGianHoanThanh).toLocaleDateString('vi-VN') : '—'} {r.ghiChu ?? ''} - {canEdit && onDelete(r.id)} />} + {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} ) } -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) => void; canEdit: boolean }) { return ( {fmtMoney(r.donGia)} {fmtMoney(r.thanhTien)} {r.thoiGianHoanThanh ? new Date(r.thoiGianHoanThanh).toLocaleDateString('vi-VN') : '—'} - {canEdit && onDelete(r.id)} />} + {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} ) } -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) => void; canEdit: boolean }) { return ( {fmtMoney(r.donGia)} {fmtMoney(r.thanhTien)} {r.thoiGianGiao ? new Date(r.thoiGianGiao).toLocaleDateString('vi-VN') : '—'} - {canEdit && onDelete(r.id)} />} + {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} ) } -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) => void; canEdit: boolean }) { return ( {fmtMoney(r.thoiGian)} {fmtMoney(r.donGia)} {fmtMoney(r.thanhTien)} - {canEdit && onDelete(r.id)} />} + {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} ) } -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) => void; canEdit: boolean }) { return ( {fmtMoney(r.donGia)} {r.thueVAT}% {fmtMoney(r.thanhTien)} - {canEdit && onDelete(r.id)} />} + {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} ) } -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) => void; canEdit: boolean }) { return ( {rows.length === 0 && Chưa có SP.} @@ -270,14 +299,14 @@ function NguyenTacNccTable({ rows, onDelete, canEdit }: { rows: NguyenTacNccDeta {fmtMoney(r.donGiaToiThieu)} {fmtMoney(r.donGiaToiDa)} {r.dieuKienThanhToan ?? '—'} - {canEdit && onDelete(r.id)} />} + {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} ) } -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) => void; canEdit: boolean }) { return ( {rows.length === 0 && Chưa có DV.} @@ -290,7 +319,7 @@ function NguyenTacDvTable({ rows, onDelete, canEdit }: { rows: NguyenTacDvDetail {fmtMoney(r.donGiaToiThieu)} {fmtMoney(r.donGiaToiDa)} {r.sla ?? '—'} - {canEdit && onDelete(r.id)} />} + {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} @@ -569,3 +598,86 @@ function buildPayload(contractType: number, order: number, form: 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} + /> +
+ ))} +
+
+ ) +} diff --git a/fe-user/src/components/contracts/ContractDetailsTab.tsx b/fe-user/src/components/contracts/ContractDetailsTab.tsx index fe7c75a..3de7170 100644 --- a/fe-user/src/components/contracts/ContractDetailsTab.tsx +++ b/fe-user/src/components/contracts/ContractDetailsTab.tsx @@ -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 } + const fmtMoney = (v: number) => v.toLocaleString('vi-VN') // Map ContractType → URL slug + render config @@ -38,21 +43,23 @@ const TYPE_TO_SLUG: Record = { 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: () => { - 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) => 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 && } + {bundle.type === 1 && } + {bundle.type === 2 && } + {bundle.type === 3 && } + {bundle.type === 4 && } + {bundle.type === 5 && } + {bundle.type === 6 && } + {bundle.type === 7 && } {canEdit && ( { - 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.
)} + + setEditTarget(null)} + onSaved={() => { invalidate(); setEditTarget(null) }} + /> ) } @@ -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 ( - +
+ {onEdit && ( + + )} + +
) } @@ -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) => void; canEdit: boolean }) { return ( {fmtMoney(r.thanhTien)} {r.thoiGianHoanThanh ? new Date(r.thoiGianHoanThanh).toLocaleDateString('vi-VN') : '—'} {r.ghiChu ?? ''} - {canEdit && onDelete(r.id)} />} + {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} ) } -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) => void; canEdit: boolean }) { return ( {fmtMoney(r.donGia)} {fmtMoney(r.thanhTien)} {r.thoiGianHoanThanh ? new Date(r.thoiGianHoanThanh).toLocaleDateString('vi-VN') : '—'} - {canEdit && onDelete(r.id)} />} + {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} ) } -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) => void; canEdit: boolean }) { return ( {fmtMoney(r.donGia)} {fmtMoney(r.thanhTien)} {r.thoiGianGiao ? new Date(r.thoiGianGiao).toLocaleDateString('vi-VN') : '—'} - {canEdit && onDelete(r.id)} />} + {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} ) } -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) => void; canEdit: boolean }) { return ( {fmtMoney(r.thoiGian)} {fmtMoney(r.donGia)} {fmtMoney(r.thanhTien)} - {canEdit && onDelete(r.id)} />} + {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} ) } -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) => void; canEdit: boolean }) { return ( {fmtMoney(r.donGia)} {r.thueVAT}% {fmtMoney(r.thanhTien)} - {canEdit && onDelete(r.id)} />} + {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} ) } -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) => void; canEdit: boolean }) { return ( {rows.length === 0 && Chưa có SP.} @@ -270,14 +299,14 @@ function NguyenTacNccTable({ rows, onDelete, canEdit }: { rows: NguyenTacNccDeta {fmtMoney(r.donGiaToiThieu)} {fmtMoney(r.donGiaToiDa)} {r.dieuKienThanhToan ?? '—'} - {canEdit && onDelete(r.id)} />} + {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} ) } -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) => void; canEdit: boolean }) { return ( {rows.length === 0 && Chưa có DV.} @@ -290,7 +319,7 @@ function NguyenTacDvTable({ rows, onDelete, canEdit }: { rows: NguyenTacDvDetail {fmtMoney(r.donGiaToiThieu)} {fmtMoney(r.donGiaToiDa)} {r.sla ?? '—'} - {canEdit && onDelete(r.id)} />} + {canEdit && onEdit(r as unknown as Record) : undefined} onDelete={() => onDelete(r.id)} />} ))} @@ -569,3 +598,86 @@ function buildPayload(contractType: number, order: number, form: 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} + /> +
+ ))} +
+
+ ) +} diff --git a/scripts/deps-audit.ps1 b/scripts/deps-audit.ps1 new file mode 100644 index 0000000..156981e --- /dev/null +++ b/scripts/deps-audit.ps1 @@ -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 +} diff --git a/src/Backend/SolutionErp.Api/Controllers/ContractsController.cs b/src/Backend/SolutionErp.Api/Controllers/ContractsController.cs index c992f26..439173b 100644 --- a/src/Backend/SolutionErp.Api/Controllers/ContractsController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/ContractsController.cs @@ -164,6 +164,55 @@ public class ContractsController(IMediator mediator) : ControllerBase return NoContent(); } + [HttpPut("{id:guid}/details/thau-phu/{detailId:guid}")] + public async Task 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 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 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 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 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 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 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")] diff --git a/src/Backend/SolutionErp.Application/Contracts/ContractDetailsFeatures.cs b/src/Backend/SolutionErp.Application/Contracts/ContractDetailsFeatures.cs index 18b87c5..6db3dfa 100644 --- a/src/Backend/SolutionErp.Application/Contracts/ContractDetailsFeatures.cs +++ b/src/Backend/SolutionErp.Application/Contracts/ContractDetailsFeatures.cs @@ -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 +{ + 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 +{ + 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 +{ + 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 +{ + 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 +{ + 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 +{ + 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 +{ + 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;