[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
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:
@ -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}
|
||||
|
||||
@ -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." />}
|
||||
/>
|
||||
|
||||
|
||||
@ -456,6 +456,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
|
||||
|
||||
Reference in New Issue
Block a user