[CLAUDE] PurchaseEvaluation: Mig 57 ghi chu gia de xuat PRO/CCM + so phan cach VND + sua chinh ta + guard #70
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m52s

Theo Tra Sol + anh Kiet FDC (Zalo): A) o nhap tien VndInlineEdit + BudgetCell nhay phan cach vi-VN (300.000.000) on-keystroke (o dialog da co san). B) them o ghi chu PRO + CCM canh nut Luu trong khoi Gia de xuat (giai thich vi sao chon Min/Max) — Mig 57 AddPeSuggestedPriceNotes (+ProSuggestedPriceNote +CcmSuggestedPriceNote nvarchar(1000) null, additive no-backfill no-table); 2 setter command +Note absolute-set rides role-gate PRO/CCM/Admin; DTO +2 field; controller body +note. C) sua chinh ta 'd. Ban so sanh' -> 'd. Bang so sanh gia' (2 app). GUARD gotcha #70: o gia + ghi chu echo nhau absolute-set -> them peFetching khoa nut Luu toi khi pe-detail refetch land, tranh stale-echo mat du lieu (mirror bang ngan sach S76; em-main review bat impl-frontend sot guard). BE slnx 0-warn 0-err; FE build PASS x2; test 344->351 (+7); PeDetailTabs/PeWorkspaceCreateView 2 app SHA256-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-19 14:08:45 +07:00
parent 3b98845976
commit 94e0e12f77
16 changed files with 6714 additions and 36 deletions

View File

@ -989,7 +989,7 @@ function VndInlineEdit({
type="text"
inputMode="numeric"
value={text}
onChange={e => setText(e.target.value.replace(/[^\d.]/g, ''))}
onChange={e => setText(formatVndInput(parseVnd(e.target.value)))}
placeholder="0"
aria-label={label}
className="h-7 pr-6 font-mono text-right text-[13px]"
@ -1101,7 +1101,7 @@ function BudgetCell({ value, editable, allowNegative = false, saving, onSave }:
type="text"
inputMode="numeric"
value={text}
onChange={e => setText(e.target.value.replace(/[^\d.]/g, ''))}
onChange={e => setText(formatVndInput(parseVnd(e.target.value)))}
placeholder="0"
className="h-7 min-w-0 flex-1 px-1.5 text-right font-mono text-[12px]"
/>
@ -1512,16 +1512,17 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
}
// PRO Min/Max — ABSOLUTE SET cả cặp (mirror budget CCM dual-field: field không đổi
// echo giá trị hiện tại để không bị clear).
// PRO Min/Max + note — ABSOLUTE SET cả 3 field (mirror S74 CcmNote: field không đổi
// echo giá trị hiện tại để không bị clear). Lưu giá → echo note hiện tại; lưu note →
// echo min/max hiện tại.
const proPriceMut = useMutation({
mutationFn: async (body: { minPrice: number | null; maxPrice: number | null }) =>
mutationFn: async (body: { minPrice: number | null; maxPrice: number | null; note: string | null }) =>
api.put(`/purchase-evaluations/${ev.id}/suggested-price/pro`, body),
onSuccess: () => { toast.success('Đã lưu giá đề xuất (PRO)'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
const ccmPriceMut = useMutation({
mutationFn: async (body: { ccmPrice: number | null }) =>
mutationFn: async (body: { ccmPrice: number | null; note: string | null }) =>
api.put(`/purchase-evaluations/${ev.id}/suggested-price/ccm`, body),
onSuccess: () => { toast.success('Đã lưu giá đề xuất (CCM)'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
@ -1529,9 +1530,21 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
const canEditPro = !readOnly && ev.canEditProSuggestedPrice
const canEditCcm = !readOnly && ev.canEditCcmSuggestedPrice
// Ghi chú PRO/CCM inline-edit state (Textarea). Echo cùng body absolute-set khi lưu giá.
const [proNoteText, setProNoteText] = useState(ev.proSuggestedPriceNote ?? '')
useEffect(() => { setProNoteText(ev.proSuggestedPriceNote ?? '') }, [ev.proSuggestedPriceNote])
const [ccmNoteText, setCcmNoteText] = useState(ev.ccmSuggestedPriceNote ?? '')
useEffect(() => { setCcmNoteText(ev.ccmSuggestedPriceNote ?? '') }, [ev.ccmSuggestedPriceNote])
// [gotcha #70] khoá nút Lưu tới khi pe-detail refetch land — lưu giá xong lưu
// ghi chú ngay (hoặc ngược lại) sẽ echo ev.* CŨ từ snapshot → mất dữ liệu.
// Mirror peFetching của bảng ngân sách (S76).
const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0
const hasAnyValue = ev.proSuggestedMinPrice != null
|| ev.proSuggestedMaxPrice != null
|| ev.ccmSuggestedPrice != null
|| !!ev.proSuggestedPriceNote
|| !!ev.ccmSuggestedPriceNote
const approved = ev.phase === PurchaseEvaluationPhase.DaDuyet && ev.approvedPriceAmount != null
// Ẩn hoàn toàn khi không edit được + chưa có giá nào + chưa chốt → tránh khối rỗng.
@ -1552,9 +1565,9 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
{canEditPro ? (
<VndInlineEdit
initial={ev.proSuggestedMinPrice}
saving={proPriceMut.isPending}
saving={proPriceMut.isPending || peFetching}
label="Giá đề xuất PRO — Min"
onSave={v => proPriceMut.mutate({ minPrice: v, maxPrice: ev.proSuggestedMaxPrice })}
onSave={v => proPriceMut.mutate({ minPrice: v, maxPrice: ev.proSuggestedMaxPrice, note: ev.proSuggestedPriceNote })}
/>
) : (
<span className="font-semibold text-slate-800">
@ -1567,9 +1580,9 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
{canEditPro ? (
<VndInlineEdit
initial={ev.proSuggestedMaxPrice}
saving={proPriceMut.isPending}
saving={proPriceMut.isPending || peFetching}
label="Giá đề xuất PRO — Max"
onSave={v => proPriceMut.mutate({ minPrice: ev.proSuggestedMinPrice, maxPrice: v })}
onSave={v => proPriceMut.mutate({ minPrice: ev.proSuggestedMinPrice, maxPrice: v, note: ev.proSuggestedPriceNote })}
/>
) : (
<span className="font-semibold text-slate-800">
@ -1580,6 +1593,42 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
</div>
</div>
{/* Ghi chú PRO — vì sao chọn Min/Max. Editable khi canEditPro (Textarea + nút Lưu),
read-only hiện text khi có note. Lưu qua proPriceMut (echo min/max hiện tại). */}
{(canEditPro || ev.proSuggestedPriceNote) && (
<div className="flex items-start gap-3 text-[12px]">
<span className="w-44 shrink-0 pt-1 text-slate-500">Ghi chú (PRO)</span>
<div className="min-w-0 flex-1">
{canEditPro ? (
<div className="space-y-1">
<textarea
value={proNoteText}
onChange={e => setProNoteText(e.target.value)}
placeholder="Ghi chú: vì sao chọn Min / Max…"
rows={2}
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
/>
<div className="flex justify-end">
<Button
onClick={() => proPriceMut.mutate({
minPrice: ev.proSuggestedMinPrice,
maxPrice: ev.proSuggestedMaxPrice,
note: proNoteText.trim() || null,
})}
disabled={proNoteText === (ev.proSuggestedPriceNote ?? '') || proPriceMut.isPending || peFetching}
className="h-6 px-2 text-[11px]"
>
{proPriceMut.isPending ? '…' : 'Lưu ghi chú'}
</Button>
</div>
</div>
) : (
<p className="whitespace-pre-wrap text-slate-700">{ev.proSuggestedPriceNote}</p>
)}
</div>
</div>
)}
{/* CCM — 1 giá */}
<div className="flex items-center gap-3 text-[12px]">
<span className="w-44 shrink-0 text-slate-500">Giá đ xuất (CCM)</span>
@ -1587,9 +1636,9 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
{canEditCcm ? (
<VndInlineEdit
initial={ev.ccmSuggestedPrice}
saving={ccmPriceMut.isPending}
saving={ccmPriceMut.isPending || peFetching}
label="Giá đề xuất CCM"
onSave={v => ccmPriceMut.mutate({ ccmPrice: v })}
onSave={v => ccmPriceMut.mutate({ ccmPrice: v, note: ev.ccmSuggestedPriceNote })}
/>
) : (
<span className="font-semibold text-slate-800">
@ -1599,6 +1648,41 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
</div>
</div>
{/* Ghi chú CCM — vì sao 1 giá. Editable khi canEditCcm, read-only hiện text khi có note.
Lưu qua ccmPriceMut (echo ccmPrice hiện tại). */}
{(canEditCcm || ev.ccmSuggestedPriceNote) && (
<div className="flex items-start gap-3 text-[12px]">
<span className="w-44 shrink-0 pt-1 text-slate-500">Ghi chú (CCM)</span>
<div className="min-w-0 flex-1">
{canEditCcm ? (
<div className="space-y-1">
<textarea
value={ccmNoteText}
onChange={e => setCcmNoteText(e.target.value)}
placeholder="Ghi chú: vì sao chọn mức giá này…"
rows={2}
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
/>
<div className="flex justify-end">
<Button
onClick={() => ccmPriceMut.mutate({
ccmPrice: ev.ccmSuggestedPrice,
note: ccmNoteText.trim() || null,
})}
disabled={ccmNoteText === (ev.ccmSuggestedPriceNote ?? '') || ccmPriceMut.isPending || peFetching}
className="h-6 px-2 text-[11px]"
>
{ccmPriceMut.isPending ? '…' : 'Lưu ghi chú'}
</Button>
</div>
</div>
) : (
<p className="whitespace-pre-wrap text-slate-700">{ev.ccmSuggestedPriceNote}</p>
)}
</div>
</div>
)}
{/* Giá CHỐT duyệt — chỉ khi DaDuyet + approvedPriceAmount != null. */}
{approved && (
<div className="flex items-center gap-3 rounded border border-emerald-200 bg-emerald-50 px-2 py-1.5 text-[12px]">
@ -1658,7 +1742,7 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
<SuggestedPriceRows ev={ev} readOnly={readOnly} />
<div>
<div className="flex gap-3">
<span className="w-44 shrink-0 text-[12px] text-slate-500">d. Bản so sánh</span>
<span className="w-44 shrink-0 text-[12px] text-slate-500">d. Bảng so sánh giá</span>
<div className="min-w-0 flex-1">
<GeneralAttachmentsSection
evaluationId={ev.id}

View File

@ -275,7 +275,7 @@ export function PeWorkspaceCreateView({
value={<span className="text-slate-400"> (auto-tính từ báo giá NCC sau khi chọn winner)</span>}
/>
<FormRow
label="d. Bản so sánh"
label="d. Bảng so sánh giá"
value={<LockedHint text="Tải bảng so sánh sau khi tạo phiếu." />}
/>

View File

@ -458,6 +458,10 @@ export type PeDetailBundle = {
proSuggestedMinPrice: number | null
proSuggestedMaxPrice: number | null
ccmSuggestedPrice: number | null
// Ghi chú giải thích vì sao chọn Min/Max (PRO) / vì sao 1 giá (CCM). Lưu kèm
// body /suggested-price/pro|ccm (absolute-set field `note`).
proSuggestedPriceNote: string | null
ccmSuggestedPriceNote: string | null
approvedPriceAmount: number | null
approvedPriceSource: string | null
canEditProSuggestedPrice: boolean