[CLAUDE] FE-Admin: PE BudgetFieldRow inline editor — toggle + 2 fields trong Section 2
User feedback 2026-05-07: muốn toggle "Nhập tay" + 2 input fields hiển thị trực
tiếp trong Section 2 "b. Ngân sách" (PeDetailTabs) — KHÔNG cần đi tới "Sửa
header" page. Visible trong cả 3 view (Workspace / Danh sách / Duyệt). Empty
giá trị thì hiển thị empty.
Implementation:
+ BudgetFieldRow component (~125 LOC) thay cho FormRow tĩnh cũ
- Detect mode auto khi mount: prefer manual mode nếu ev.budgetManualName/Amount
set + !ev.budgetId
- canEdit = !readOnly && isDraft (DangSoanThao):
→ Render toggle "Nhập tay" + Select Budget OR 2 input grid 2-col + nút
"Lưu ngân sách" (chỉ hiện khi dirty) + "Hủy thay đổi" reset
→ Save: full PUT /pe/:id với current values (tenGoiThau/diaDiem/moTa/
paymentTerms) + new budget payload conditional (manual mode → clear
budgetId, link mode → clear manual). Invalidate ['pe-detail', 'pe-list'].
- canEdit=false (Duyệt mode hoặc !isDraft):
→ Display only — link card / manual values / empty "—" (không text "chưa
link" verbose nữa per user "giá trị rỗng thì cứ hiển thị rỗng")
Files:
~ fe-admin/src/components/pe/PeDetailTabs.tsx
- import BudgetPhase + BudgetListItem từ types/budget + Paged từ types/master
- new BudgetFieldRow component
- ChonNccSection b. Ngân sách FormRow → <BudgetFieldRow> (1-line replacement)
Verify: npm run build fe-admin pass · 1922 modules · 0 TS error.
Next: Chunk 2 fe-user mirror.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -32,7 +32,8 @@ import {
|
||||
type PeQuote,
|
||||
type PeSupplier,
|
||||
} from '@/types/purchaseEvaluation'
|
||||
import type { Supplier } from '@/types/master'
|
||||
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
|
||||
import type { Paged, Supplier } from '@/types/master'
|
||||
|
||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||||
|
||||
@ -306,6 +307,158 @@ function InfoTab({ ev }: { ev: PeDetailBundle }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ===== b. Ngân sách inline editor (Mig 17) =====
|
||||
// Hiển thị + edit budget link / manual fields ngay trong Section 2 — KHÔNG cần
|
||||
// đi tới "Sửa header" page. Visible trong cả 3 view (Workspace / Danh sách /
|
||||
// Duyệt). Edit chỉ enable khi !readOnly + isDraft (Drafter sửa). Read-only
|
||||
// khi pendingMe=1 hoặc phase đã chuyển khỏi DangSoanThao. Empty values hiển
|
||||
// thị empty (per user 2026-05-07).
|
||||
function BudgetFieldRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||||
const isDraft = ev.phase === PurchaseEvaluationPhase.DangSoanThao
|
||||
const canEdit = !readOnly && isDraft
|
||||
const qc = useQueryClient()
|
||||
|
||||
// Detect mode khi mount/refresh: prefer manual mode nếu đã có data manual + ko link
|
||||
const initialManual = (ev.budgetManualName !== null || ev.budgetManualAmount !== null) && !ev.budgetId
|
||||
const [manualMode, setManualMode] = useState(initialManual)
|
||||
const [budgetId, setBudgetId] = useState(ev.budgetId ?? '')
|
||||
const [manualName, setManualName] = useState(ev.budgetManualName ?? '')
|
||||
const [manualAmount, setManualAmount] = useState(ev.budgetManualAmount ?? 0)
|
||||
|
||||
// Eligible budgets — chỉ fetch khi user có khả năng edit
|
||||
const eligibleBudgets = useQuery({
|
||||
queryKey: ['eligible-budgets', ev.projectId],
|
||||
queryFn: async () => (await api.get<Paged<BudgetListItem>>('/budgets', {
|
||||
params: { pageSize: 100, projectId: ev.projectId, phase: BudgetPhase.DaDuyet },
|
||||
})).data.items,
|
||||
enabled: canEdit,
|
||||
})
|
||||
|
||||
// Dirty detect — compare current state vs ev original
|
||||
const dirty = manualMode !== initialManual
|
||||
|| (manualMode && (manualName !== (ev.budgetManualName ?? '') || manualAmount !== (ev.budgetManualAmount ?? 0)))
|
||||
|| (!manualMode && budgetId !== (ev.budgetId ?? ''))
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = manualMode
|
||||
? { budgetId: null, budgetManualName: manualName || null, budgetManualAmount: manualAmount > 0 ? manualAmount : null }
|
||||
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
|
||||
await api.put(`/purchase-evaluations/${ev.id}`, {
|
||||
id: ev.id,
|
||||
tenGoiThau: ev.tenGoiThau,
|
||||
diaDiem: ev.diaDiem,
|
||||
moTa: ev.moTa,
|
||||
paymentTerms: ev.paymentTerms,
|
||||
...payload,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Đã cập nhật ngân sách')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
// Read-only mode: chỉ display (không toggle, không edit)
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<FormRow
|
||||
label="b. Ngân sách"
|
||||
value={ev.budget ? (
|
||||
<a href={`/budgets?id=${ev.budget.id}`} className="text-brand-600 hover:underline">
|
||||
<span className="font-mono text-[11px]">{ev.budget.maNganSach ?? '—'}</span>
|
||||
{' · '}{ev.budget.tenNganSach}
|
||||
{' · '}<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
|
||||
</a>
|
||||
) : ev.budgetManualAmount != null || ev.budgetManualName ? (
|
||||
<span className="text-slate-700">
|
||||
{ev.budgetManualName && <span>{ev.budgetManualName}</span>}
|
||||
{ev.budgetManualName && ev.budgetManualAmount != null && ' · '}
|
||||
{ev.budgetManualAmount != null && (
|
||||
<span className="font-semibold text-slate-900">{ev.budgetManualAmount.toLocaleString('vi-VN')} đ</span>
|
||||
)}
|
||||
<span className="ml-2 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">nhập tay</span>
|
||||
</span>
|
||||
) : <span className="text-slate-400">—</span>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Editable mode (canEdit=true)
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<span className="w-44 shrink-0 pt-1.5 text-[12px] text-slate-500">b. Ngân sách</span>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={manualMode}
|
||||
onChange={e => setManualMode(e.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-slate-300"
|
||||
/>
|
||||
Nhập tay (không link)
|
||||
</label>
|
||||
{!manualMode ? (
|
||||
<Select
|
||||
value={budgetId}
|
||||
onChange={e => setBudgetId(e.target.value)}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{eligibleBudgets.data?.map(b => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
<Input
|
||||
value={manualName}
|
||||
onChange={e => setManualName(e.target.value)}
|
||||
placeholder="Tên ngân sách (vd Tạm tính T11/2025)"
|
||||
maxLength={200}
|
||||
className="text-sm"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={manualAmount || ''}
|
||||
onChange={e => setManualAmount(Number(e.target.value))}
|
||||
placeholder="Số tiền (đ)"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{dirty && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={save.isPending}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
{save.isPending ? 'Đang lưu…' : 'Lưu ngân sách'}
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setManualMode(initialManual)
|
||||
setBudgetId(ev.budgetId ?? '')
|
||||
setManualName(ev.budgetManualName ?? '')
|
||||
setManualAmount(ev.budgetManualAmount ?? 0)
|
||||
}}
|
||||
className="text-[11px] text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
Hủy thay đổi
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
|
||||
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
|
||||
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId
|
||||
@ -335,27 +488,7 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
|
||||
<span className="font-medium text-emerald-700">✓ {ev.selectedSupplierName}</span>
|
||||
) : <span className="text-slate-400">— (chưa chọn)</span>}
|
||||
/>
|
||||
<FormRow
|
||||
label="b. Ngân sách"
|
||||
value={ev.budget ? (
|
||||
<a href={`/budgets?id=${ev.budget.id}`} className="text-brand-600 hover:underline">
|
||||
<span className="font-mono text-[11px]">{ev.budget.maNganSach ?? '—'}</span>
|
||||
{' · '}{ev.budget.tenNganSach}
|
||||
{' · '}<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
|
||||
</a>
|
||||
) : ev.budgetManualAmount != null || ev.budgetManualName ? (
|
||||
// Mig 17 — manual budget fallback: hiển thị tên + số tiền nhập tay,
|
||||
// không phải link vào /budgets/{id} (không có Budget entity).
|
||||
<span className="text-slate-700">
|
||||
{ev.budgetManualName && <span>{ev.budgetManualName}</span>}
|
||||
{ev.budgetManualName && ev.budgetManualAmount != null && ' · '}
|
||||
{ev.budgetManualAmount != null && (
|
||||
<span className="font-semibold text-slate-900">{ev.budgetManualAmount.toLocaleString('vi-VN')} đ</span>
|
||||
)}
|
||||
<span className="ml-2 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">nhập tay</span>
|
||||
</span>
|
||||
) : <span className="text-slate-400">— (chưa link)</span>}
|
||||
/>
|
||||
<BudgetFieldRow ev={ev} readOnly={readOnly} />
|
||||
<FormRow
|
||||
label="c. Giá chào thầu"
|
||||
value={giaChaoThau != null ? (
|
||||
|
||||
Reference in New Issue
Block a user