[CLAUDE] PurchaseEvaluation: ngan sach goi thau theo Excel anh Kiet - bang tong hop 2 block + nhap theo role PRO/CCM + xoa module Budget cu (Mig 50)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m31s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m31s
- Mig 50 ReplaceBudgetModuleWithPeWorkItemBudgets: bang moi PeWorkItemBudgets (1 record/cap Du an x Hang muc, UNIQUE filtered [IsDeleted]=0) + drop 5 bang Budget cu + PE/Contracts drop BudgetId + backfill BudgetManualAmount->BudgetPeriodAmount TRUOC DropColumn (phieu UAT giu so) + DELETE menu/permission Bg_* IN-list children-first
- BE: PUT {id}/budget/pro (role Procurement) + {id}/budget/ccm (role CostControl, Adjustment cho phep AM) fail-closed Forbidden-truoc-side-effect + EnsureTrackedAsync race-safe (catch unique -> re-fetch winner, loi khac rethrow) + auto-create record khi tao phieu + budgetSummary DTO (luy ke trinh-truoc/chon-thau-truoc/de-xuat-ky-nay + full fallback du-tru-PRO + canEdit flags) + submit-guard (3) doi predicate BudgetPeriodAmount -> "chua nhap Ngan sach ky nay" + PATCH budget-adjust absolute-set 2 field moi + Contract GIU BudgetManual* (HD nhap tay khong doi) + ke thua HD map BudgetPeriodAmount
- FE x2 app SHA256 identical: bang "TONG HOP NGAN SACH TRINH KY" block A (full dam + ban hanh + V0 hieu chinh + du tru PRO + ghi chu, editable theo canEditPro/canEditCcm) + block B 9 dong cong thuc Excel (5=1+3, 6=2+4, 7=full-5, 8 tu nhap default 7, 9=4+8) + to mau vuot ngan sach #C00000 / am do / red-soft row8>row7 + "Chua chon" khi count=0 + banner phieu chua gan Hang muc + o "Ngan sach ky nay" o create/header + XOA pages/components/types budgets + routes + menuKeys + Layout staticMap 4-place
- Tests: +22 PeWorkItemBudgetTests (auto-create x3, ensure/race x2, authz matrix PRO x5 + CCM x3, budgetSummary aggregates x5, adjust x4) - 14 BudgetPolicyTests xoa theo module - 1 test via-BudgetId -> 263 PASS (45 Domain + 218 Infra, 0 fail)
- database-agent advise adopted: khong FK vat ly PE/Contracts->Budgets (DropColumn khong can DropForeignKey) + DropIndex truoc DropColumn (SQL 5074) + IN-list thay LIKE Bg_% (underscore wildcard + miss root) + khong Serializable wrap (nested-tx conflict codegen)
- Reviewer PASS-with-minor 0 blocker (verdict-first survived); 2 minor da sua truoc commit (comment adjustMut absolute-set + dead key budgetId); note: F4 approver-edit-budget UI entry tam drafter-only, BE van cho approver scope - cho UAT anh Kiet
- Scaffold-bug caught: EF tu sinh RenameColumn BudgetManualAmount->ExpectedRemainingAmount (SAI semantics) -> thay bang Add+UPDATE+Drop
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -1,173 +0,0 @@
|
||||
// Create / edit draft Header ngân sách. Hạng mục chỉnh ở Detail tabs sau khi save.
|
||||
// CreateBudgetCommand BE chỉ cho update Tên/Mô tả/Năm khi DangSoanThao
|
||||
// — Project/Department khóa sau create.
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { Wallet } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import type { BudgetDetailBundle } from '@/types/budget'
|
||||
import type { Department, Paged, Project } from '@/types/master'
|
||||
|
||||
export function BudgetCreatePage() {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const [sp] = useSearchParams()
|
||||
const editId = sp.get('id')
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
const projects = useQuery({
|
||||
queryKey: ['all-projects'],
|
||||
queryFn: async () =>
|
||||
(await api.get<Paged<Project>>('/projects', { params: { pageSize: 1000 } })).data.items,
|
||||
})
|
||||
const departments = useQuery({
|
||||
queryKey: ['all-departments'],
|
||||
queryFn: async () =>
|
||||
(await api.get<Paged<Department>>('/departments', { params: { pageSize: 1000 } })).data.items,
|
||||
})
|
||||
const existing = useQuery({
|
||||
queryKey: ['budget-detail', editId],
|
||||
queryFn: async () => (await api.get<BudgetDetailBundle>(`/budgets/${editId}`)).data,
|
||||
enabled: !!editId,
|
||||
})
|
||||
|
||||
const [form, setForm] = useState({
|
||||
tenNganSach: '',
|
||||
description: '',
|
||||
namNganSach: currentYear,
|
||||
projectId: '',
|
||||
departmentId: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (existing.data) {
|
||||
setForm({
|
||||
tenNganSach: existing.data.tenNganSach,
|
||||
description: existing.data.description ?? '',
|
||||
namNganSach: existing.data.namNganSach,
|
||||
projectId: existing.data.projectId,
|
||||
departmentId: existing.data.departmentId ?? '',
|
||||
})
|
||||
}
|
||||
}, [existing.data])
|
||||
|
||||
const mut = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (editId) {
|
||||
return api.put(`/budgets/${editId}`, {
|
||||
id: editId,
|
||||
tenNganSach: form.tenNganSach,
|
||||
description: form.description || null,
|
||||
namNganSach: form.namNganSach,
|
||||
})
|
||||
}
|
||||
return api.post<{ id: string }>('/budgets', {
|
||||
tenNganSach: form.tenNganSach,
|
||||
description: form.description || null,
|
||||
namNganSach: form.namNganSach,
|
||||
projectId: form.projectId,
|
||||
departmentId: form.departmentId || null,
|
||||
})
|
||||
},
|
||||
onSuccess: res => {
|
||||
toast.success(editId ? 'Đã lưu.' : 'Đã tạo ngân sách.')
|
||||
qc.invalidateQueries({ queryKey: ['budget-list'] })
|
||||
const id = editId ?? (res as { data: { id: string } }).data.id
|
||||
navigate(`/budgets?id=${id}`)
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
<header className="flex items-center gap-2">
|
||||
<Wallet className="h-5 w-5 text-slate-500" />
|
||||
<h1 className="text-base font-semibold tracking-tight text-slate-900">
|
||||
{editId ? 'Sửa header ngân sách' : 'Tạo ngân sách mới'}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="max-w-2xl space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div>
|
||||
<Label>Tên ngân sách *</Label>
|
||||
<Input
|
||||
value={form.tenNganSach}
|
||||
onChange={e => setForm({ ...form, tenNganSach: e.target.value })}
|
||||
placeholder="vd Ngân sách thi công Block A — FLOCK 01"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Năm ngân sách *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={2020}
|
||||
max={2100}
|
||||
value={form.namNganSach}
|
||||
onChange={e => setForm({ ...form, namNganSach: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Phòng ban</Label>
|
||||
<Select
|
||||
value={form.departmentId}
|
||||
disabled={!!editId}
|
||||
onChange={e => setForm({ ...form, departmentId: e.target.value })}
|
||||
>
|
||||
<option value="">— (tùy chọn)</option>
|
||||
{departments.data?.map(d => (
|
||||
<option key={d.id} value={d.id}>{d.code} — {d.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Dự án *</Label>
|
||||
<Select
|
||||
value={form.projectId}
|
||||
disabled={!!editId}
|
||||
onChange={e => setForm({ ...form, projectId: e.target.value })}
|
||||
>
|
||||
<option value="">-- Chọn --</option>
|
||||
{projects.data?.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.code} — {p.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
{editId && (
|
||||
<p className="mt-1 text-[11px] text-slate-500">Dự án + Phòng ban khóa sau khi tạo.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Mô tả</Label>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={form.description}
|
||||
onChange={e => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="Ghi chú ngân sách, phạm vi sử dụng, ràng buộc..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => navigate(-1)}>Hủy</Button>
|
||||
<Button
|
||||
onClick={() => mut.mutate()}
|
||||
disabled={!form.tenNganSach || !form.projectId || mut.isPending}
|
||||
>
|
||||
{editId ? 'Lưu' : 'Tạo ngân sách'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,263 +0,0 @@
|
||||
// List + Detail ngân sách — 3-panel: List | Detail (Header + Hạng mục) | Workflow + history.
|
||||
// URL params: phase, projectId, q (search), id (selected), namNganSach.
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { Plus, Search, Wallet, X } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { SlaTimer } from '@/components/SlaTimer'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import type { Paged } from '@/types/master'
|
||||
import {
|
||||
BudgetPhase,
|
||||
BudgetPhaseColor,
|
||||
BudgetPhaseLabel,
|
||||
type BudgetDetailBundle,
|
||||
type BudgetListItem,
|
||||
} from '@/types/budget'
|
||||
import { BudgetDetailTabs } from '@/components/budgets/BudgetDetailTabs'
|
||||
import { BudgetWorkflowPanel } from '@/components/budgets/BudgetWorkflowPanel'
|
||||
|
||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||||
|
||||
export function BudgetsListPage() {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const [sp, setSp] = useSearchParams()
|
||||
const phaseFilter = sp.get('phase') ?? ''
|
||||
const search = sp.get('q') ?? ''
|
||||
const yearFilter = sp.get('namNganSach') ?? ''
|
||||
const selectedId = sp.get('id')
|
||||
// ?phase=Pending → highlight 2 phase chờ duyệt (ChoCCM + ChoCEO).
|
||||
const isPendingMode = phaseFilter === 'Pending'
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ['budget-list', { phaseFilter, search, yearFilter }],
|
||||
queryFn: async () => {
|
||||
const params: Record<string, unknown> = { pageSize: 100, search: search || undefined }
|
||||
if (yearFilter) params.namNganSach = Number(yearFilter)
|
||||
// Phase=Pending là alias FE → BE không filter (FE tự lọc 2 phase chờ).
|
||||
if (phaseFilter && phaseFilter !== 'Pending') params.phase = phaseFilter
|
||||
const res = await api.get<Paged<BudgetListItem>>('/budgets', { params })
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: ['budget-detail', selectedId],
|
||||
queryFn: async () => (await api.get<BudgetDetailBundle>(`/budgets/${selectedId}`)).data,
|
||||
enabled: !!selectedId,
|
||||
})
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: async (id: string) => api.delete(`/budgets/${id}`),
|
||||
onSuccess: () => {
|
||||
toast.success('Đã xóa ngân sách.')
|
||||
setParam('id', null)
|
||||
qc.invalidateQueries({ queryKey: ['budget-list'] })
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
function setParam(key: string, value: string | null) {
|
||||
const next = new URLSearchParams(sp)
|
||||
if (value == null || value === '') next.delete(key)
|
||||
else next.set(key, value)
|
||||
if (key !== 'id') next.delete('page')
|
||||
setSp(next, { replace: key === 'q' })
|
||||
}
|
||||
|
||||
function selectRow(id: string) {
|
||||
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
|
||||
setParam('id', id)
|
||||
} else {
|
||||
navigate(`/budgets/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const allRows = list.data?.items ?? []
|
||||
const rows = isPendingMode
|
||||
? allRows.filter(b => b.phase === BudgetPhase.ChoCCM || b.phase === BudgetPhase.ChoCEO)
|
||||
: allRows
|
||||
const headerTitle = isPendingMode ? 'Ngân sách — Chờ duyệt' : 'Ngân sách dự án'
|
||||
const phaseValues = Object.values(BudgetPhase)
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wallet className="h-5 w-5 text-slate-500" />
|
||||
<h1 className="text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
|
||||
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
|
||||
{rows.length}
|
||||
</span>
|
||||
</div>
|
||||
<Button onClick={() => navigate('/budgets/new')} className="gap-1.5 text-xs">
|
||||
<Plus className="h-3.5 w-3.5" /> Tạo ngân sách
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[340px_1fr_360px]">
|
||||
{/* Panel 1: List */}
|
||||
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
|
||||
<div className="space-y-2 border-b border-slate-200 p-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setParam('q', e.target.value)}
|
||||
placeholder="Tìm mã / tên / dự án…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Select
|
||||
value={isPendingMode ? '' : phaseFilter}
|
||||
onChange={e => setParam('phase', e.target.value)}
|
||||
disabled={isPendingMode}
|
||||
>
|
||||
<option value="">Tất cả phase</option>
|
||||
{phaseValues.map(p => (
|
||||
<option key={p} value={p}>{BudgetPhaseLabel[p]}</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Năm"
|
||||
value={yearFilter}
|
||||
onChange={e => setParam('namNganSach', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{list.isLoading && (
|
||||
<div className="space-y-2 p-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!list.isLoading && rows.length === 0 && (
|
||||
<div className="p-6">
|
||||
<EmptyState
|
||||
icon={Wallet}
|
||||
title={isPendingMode ? 'Không có ngân sách chờ duyệt' : 'Chưa có ngân sách'}
|
||||
description={isPendingMode ? 'Tất cả đã duyệt xong.' : 'Tạo ngân sách mới để bắt đầu.'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{rows.map(b => (
|
||||
<li key={b.id}>
|
||||
<button
|
||||
onClick={() => selectRow(b.id)}
|
||||
className={cn(
|
||||
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
|
||||
selectedId === b.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-slate-900">{b.tenNganSach}</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||
<span className="font-mono">{b.maNganSach ?? '—'}</span>
|
||||
<span>·</span>
|
||||
<span className="truncate">{b.projectName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||
BudgetPhaseColor[b.phase],
|
||||
)}
|
||||
>
|
||||
{BudgetPhaseLabel[b.phase]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
|
||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-600">
|
||||
Năm {b.namNganSach}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums text-slate-700">
|
||||
{fmtMoney(b.tongNganSach)} đ
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-right">
|
||||
<SlaTimer deadline={b.slaDeadline} createdAt={b.createdAt} />
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Panel 2: Detail tabs */}
|
||||
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
|
||||
{!selectedId && (
|
||||
<EmptyState
|
||||
icon={Wallet}
|
||||
title="Chọn ngân sách ở danh sách"
|
||||
description="Chi tiết hạng mục + duyệt sẽ hiển thị ở đây."
|
||||
/>
|
||||
)}
|
||||
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải…</div>}
|
||||
{selectedId && detail.data && (
|
||||
<BudgetDetailTabs
|
||||
budget={detail.data}
|
||||
onBack={() => setParam('id', null)}
|
||||
onDelete={() => del.mutate(detail.data!.id)}
|
||||
readOnly={isPendingMode}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Panel 3: Workflow + history */}
|
||||
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
|
||||
{!selectedId && (
|
||||
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
|
||||
<X className="mx-auto mb-2 h-5 w-5" />
|
||||
Quy trình duyệt sẽ hiện khi chọn ngân sách.
|
||||
</div>
|
||||
)}
|
||||
{selectedId && detail.data && <BudgetWorkflowPanel budget={detail.data} />}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Fullpage detail route cho mobile (/budgets/:id)
|
||||
export function BudgetDetailPage() {
|
||||
const navigate = useNavigate()
|
||||
const id = location.pathname.split('/').pop()!
|
||||
const detail = useQuery({
|
||||
queryKey: ['budget-detail', id],
|
||||
queryFn: async () => (await api.get<BudgetDetailBundle>(`/budgets/${id}`)).data,
|
||||
})
|
||||
const del = useMutation({
|
||||
mutationFn: async () => api.delete(`/budgets/${id}`),
|
||||
onSuccess: () => {
|
||||
toast.success('Đã xóa.')
|
||||
navigate('/budgets')
|
||||
},
|
||||
})
|
||||
if (detail.isLoading) return <div className="p-6 text-sm text-slate-500">Đang tải…</div>
|
||||
if (!detail.data) return <div className="p-6 text-sm text-red-600">Không tìm thấy ngân sách.</div>
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
<BudgetDetailTabs
|
||||
budget={detail.data}
|
||||
onBack={() => navigate('/budgets')}
|
||||
onDelete={() => del.mutate()}
|
||||
/>
|
||||
<BudgetWorkflowPanel budget={detail.data} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -35,7 +35,6 @@ import {
|
||||
type ContractDetail,
|
||||
type ContractListItem,
|
||||
} from '@/types/contracts'
|
||||
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
|
||||
|
||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||||
|
||||
@ -304,9 +303,8 @@ function ContractHeaderForm({
|
||||
const [tenHopDong, setTenHopDong] = useState('')
|
||||
const [noiDung, setNoiDung] = useState('')
|
||||
const [bypass, setBypass] = useState(false)
|
||||
const [budgetId, setBudgetId] = useState('')
|
||||
// Mig 17 — manual budget fallback (toggle "Nhập tay")
|
||||
const [budgetManual, setBudgetManual] = useState(false)
|
||||
// [S61 Mig 50] Module Budget cũ XÓA — HĐ GIỮ ngân sách nhập tay (Tên + Số tiền),
|
||||
// chỉ bỏ chế độ link Budget entity.
|
||||
const [budgetManualName, setBudgetManualName] = useState('')
|
||||
const [budgetManualAmount, setBudgetManualAmount] = useState(0)
|
||||
// [Plan B S29 Chunk D 2026-05-22 Mig 32] V2 workflow pin lúc create — mirror
|
||||
@ -317,8 +315,6 @@ function ContractHeaderForm({
|
||||
|
||||
// Reset type về default khi typeFilter (parent prop) thay đổi
|
||||
useEffect(() => { setType(defaultType) }, [defaultType])
|
||||
// Reset budget khi đổi project (mỗi project có ngân sách riêng)
|
||||
useEffect(() => { setBudgetId('') }, [projectId])
|
||||
|
||||
const suppliers = useQuery({
|
||||
queryKey: ['suppliers-all'],
|
||||
@ -345,21 +341,12 @@ function ContractHeaderForm({
|
||||
return (typeBucket?.history ?? []).filter(w => w.isUserSelectable)
|
||||
},
|
||||
})
|
||||
// Eligible Budgets: cùng Project + Phase=DaDuyet (BE-side filter).
|
||||
const eligibleBudgets = useQuery({
|
||||
queryKey: ['eligible-budgets', projectId],
|
||||
queryFn: async () =>
|
||||
(await api.get<Paged<BudgetListItem>>('/budgets', {
|
||||
params: { pageSize: 100, projectId, phase: BudgetPhase.DaDuyet },
|
||||
})).data.items,
|
||||
enabled: !!projectId,
|
||||
})
|
||||
|
||||
const qc = useQueryClient()
|
||||
// Manual mode: clear budgetId, gửi manualName/Amount. Link mode: clear manual.
|
||||
const budgetPayload = budgetManual
|
||||
? { budgetId: null, budgetManualName: budgetManualName || null, budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null }
|
||||
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
|
||||
// [S61 Mig 50] HĐ chỉ còn ngân sách nhập tay (link Budget entity đã bỏ).
|
||||
const budgetPayload = {
|
||||
budgetManualName: budgetManualName || null,
|
||||
budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null,
|
||||
}
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async () => {
|
||||
@ -440,69 +427,35 @@ function ContractHeaderForm({
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="mb-0">Ngân sách (đối chiếu chi phí)</Label>
|
||||
{/* Toggle "Nhập tay" — Mig 17 fallback khi không link Budget entity */}
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={budgetManual}
|
||||
onChange={e => setBudgetManual(e.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-slate-300"
|
||||
{/* [S61 Mig 50] HĐ giữ ngân sách NHẬP TAY (Tên + Số tiền) — chế độ link
|
||||
Budget entity đã bỏ (module Budget cũ xóa hẳn). */}
|
||||
<Label className="mb-0">Ngân sách (đối chiếu chi phí — nhập tay)</Label>
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
|
||||
<Input
|
||||
value={budgetManualName}
|
||||
onChange={e => setBudgetManualName(e.target.value)}
|
||||
placeholder="vd Tạm tính dự toán T11/2025"
|
||||
maxLength={200}
|
||||
/>
|
||||
Nhập tay (không link)
|
||||
</label>
|
||||
</div>
|
||||
{!budgetManual ? (
|
||||
<>
|
||||
<Select
|
||||
value={budgetId}
|
||||
disabled={!projectId}
|
||||
onChange={e => setBudgetId(e.target.value)}
|
||||
>
|
||||
<option value="">— (không link)</option>
|
||||
{eligibleBudgets.data?.map(b => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{!projectId
|
||||
? 'Chọn dự án trước để xem ngân sách khả dụng.'
|
||||
: eligibleBudgets.data && eligibleBudgets.data.length === 0
|
||||
? 'Dự án này chưa có ngân sách đã duyệt — bật "Nhập tay" để nhập số tiền trực tiếp.'
|
||||
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
|
||||
<Input
|
||||
value={budgetManualName}
|
||||
onChange={e => setBudgetManualName(e.target.value)}
|
||||
placeholder="vd Tạm tính dự toán T11/2025"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">Số tiền (đ)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={budgetManualAmount || ''}
|
||||
onChange={e => setBudgetManualAmount(Number(e.target.value))}
|
||||
placeholder="1000000000"
|
||||
/>
|
||||
{budgetManualAmount > 0 && (
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
≈ {budgetManualAmount.toLocaleString('vi-VN')} đ
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label className="text-[11px]">Số tiền (đ)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={budgetManualAmount || ''}
|
||||
onChange={e => setBudgetManualAmount(Number(e.target.value))}
|
||||
placeholder="1000000000"
|
||||
/>
|
||||
{budgetManualAmount > 0 && (
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
≈ {budgetManualAmount.toLocaleString('vi-VN')} đ
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button type="submit" disabled={create.isPending}>
|
||||
@ -592,10 +545,7 @@ function ContractEditForm({
|
||||
const [giaTri, setGiaTri] = useState(String(contract.giaTri ?? 0))
|
||||
const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '')
|
||||
const [noiDung, setNoiDung] = useState(contract.noiDung ?? '')
|
||||
const [budgetId, setBudgetId] = useState(contract.budgetId ?? '')
|
||||
// Mig 17 — manual budget fallback. Auto-toggle khi load có manual data
|
||||
const hasInitialManual = contract.budgetManualName !== null || contract.budgetManualAmount !== null
|
||||
const [budgetManual, setBudgetManual] = useState(hasInitialManual && !contract.budgetId)
|
||||
// [S61 Mig 50] HĐ giữ ngân sách nhập tay — link Budget entity đã bỏ.
|
||||
const [budgetManualName, setBudgetManualName] = useState(contract.budgetManualName ?? '')
|
||||
const [budgetManualAmount, setBudgetManualAmount] = useState(contract.budgetManualAmount ?? 0)
|
||||
|
||||
@ -603,20 +553,12 @@ function ContractEditForm({
|
||||
queryKey: ['templates-by-type', contract.type],
|
||||
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type: contract.type } })).data,
|
||||
})
|
||||
// Eligible Budgets: cùng Project + Phase=DaDuyet
|
||||
const eligibleBudgets = useQuery({
|
||||
queryKey: ['eligible-budgets', contract.projectId],
|
||||
queryFn: async () =>
|
||||
(await api.get<Paged<BudgetListItem>>('/budgets', {
|
||||
params: { pageSize: 100, projectId: contract.projectId, phase: BudgetPhase.DaDuyet },
|
||||
})).data.items,
|
||||
enabled: isDraft,
|
||||
})
|
||||
|
||||
const qc = useQueryClient()
|
||||
const budgetPayload = budgetManual
|
||||
? { budgetId: null, budgetManualName: budgetManualName || null, budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null }
|
||||
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
|
||||
const budgetPayload = {
|
||||
budgetManualName: budgetManualName || null,
|
||||
budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null,
|
||||
}
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: async () => {
|
||||
@ -716,74 +658,36 @@ function ContractEditForm({
|
||||
<Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} disabled={!isDraft} />
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="mb-0">Ngân sách (đối chiếu chi phí)</Label>
|
||||
{isDraft && (
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={budgetManual}
|
||||
onChange={e => setBudgetManual(e.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-slate-300"
|
||||
/>
|
||||
Nhập tay (không link)
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{/* [S61 Mig 50] HĐ giữ ngân sách NHẬP TAY — link Budget entity đã bỏ
|
||||
(module Budget cũ xóa hẳn). */}
|
||||
<Label className="mb-0">Ngân sách (đối chiếu chi phí — nhập tay)</Label>
|
||||
{isDraft ? (
|
||||
!budgetManual ? (
|
||||
<>
|
||||
<Select value={budgetId} onChange={e => setBudgetId(e.target.value)}>
|
||||
<option value="">— (không link)</option>
|
||||
{eligibleBudgets.data?.map(b => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{eligibleBudgets.data && eligibleBudgets.data.length === 0
|
||||
? 'Dự án này chưa có ngân sách đã duyệt — bật "Nhập tay" để nhập số tiền trực tiếp.'
|
||||
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
|
||||
<Input
|
||||
value={budgetManualName}
|
||||
onChange={e => setBudgetManualName(e.target.value)}
|
||||
placeholder="vd Tạm tính dự toán T11/2025"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">Số tiền (đ)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={budgetManualAmount || ''}
|
||||
onChange={e => setBudgetManualAmount(Number(e.target.value))}
|
||||
placeholder="1000000000"
|
||||
/>
|
||||
{budgetManualAmount > 0 && (
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
≈ {budgetManualAmount.toLocaleString('vi-VN')} đ
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
|
||||
<Input
|
||||
value={budgetManualName}
|
||||
onChange={e => setBudgetManualName(e.target.value)}
|
||||
placeholder="vd Tạm tính dự toán T11/2025"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : contract.budget ? (
|
||||
<a
|
||||
href={`/budgets?id=${contract.budget.id}`}
|
||||
className="block rounded border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700 hover:bg-brand-50 hover:text-brand-700"
|
||||
>
|
||||
<span className="font-mono text-[11px]">{contract.budget.maNganSach ?? '—'}</span>
|
||||
{' · '}{contract.budget.tenNganSach}
|
||||
{' · '}<span className="text-slate-500">{contract.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
|
||||
</a>
|
||||
<div>
|
||||
<Label className="text-[11px]">Số tiền (đ)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={budgetManualAmount || ''}
|
||||
onChange={e => setBudgetManualAmount(Number(e.target.value))}
|
||||
placeholder="1000000000"
|
||||
/>
|
||||
{budgetManualAmount > 0 && (
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
≈ {budgetManualAmount.toLocaleString('vi-VN')} đ
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : contract.budgetManualAmount != null || contract.budgetManualName ? (
|
||||
// Mig 17 — read-only display khi !isDraft + có manual data
|
||||
<div className="block rounded border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">
|
||||
|
||||
Reference in New Issue
Block a user