[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:
@ -17,8 +17,6 @@ import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pag
|
||||
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
|
||||
import { PurchaseEvaluationWorkspacePage } from '@/pages/pe/PurchaseEvaluationWorkspacePage'
|
||||
import { WorkflowMatrixViewPage } from '@/pages/pe/WorkflowMatrixViewPage'
|
||||
import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPage'
|
||||
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
|
||||
import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage'
|
||||
import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage'
|
||||
import { HrmConfigsPage } from '@/pages/hrm/HrmConfigsPage'
|
||||
@ -62,9 +60,6 @@ function App() {
|
||||
<Route path="/purchase-evaluations/workspace" element={<PurchaseEvaluationWorkspacePage />} />
|
||||
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />
|
||||
<Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} />
|
||||
<Route path="/budgets" element={<BudgetsListPage />} />
|
||||
<Route path="/budgets/new" element={<BudgetCreatePage />} />
|
||||
<Route path="/budgets/:id" element={<BudgetDetailPage />} />
|
||||
{/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */}
|
||||
<Route path="/employees" element={<EmployeesListPage />} />
|
||||
<Route path="/employees/new" element={<EmployeeCreatePage />} />
|
||||
|
||||
@ -57,10 +57,6 @@ function resolvePath(key: string): string | null {
|
||||
Dashboard: '/dashboard',
|
||||
Contracts: '/my-contracts',
|
||||
PurchaseEvaluations: '/purchase-evaluations',
|
||||
Budgets: '/budgets',
|
||||
Bg_List: '/budgets',
|
||||
Bg_Create: '/budgets/new',
|
||||
Bg_Pending: '/budgets?phase=Pending',
|
||||
// [Plan CA Hotfix 1 S29 2026-05-22] 4 master + 4 catalog leaf moved từ
|
||||
// fe-admin → fe-user. resolvePath PHẢI có route mapping nếu không
|
||||
// MenuLeaf line 238 `if (!path) return null` → sidebar drop silent.
|
||||
|
||||
@ -1,491 +0,0 @@
|
||||
// Detail content cho 1 ngân sách. Flat render (no tabs):
|
||||
// Section 1 = Thông tin Header
|
||||
// Section 2 = Hạng mục (table CRUD inline)
|
||||
// Approvals + Changelog → moved sang Panel 3 (BudgetWorkflowPanel).
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
BudgetPhase,
|
||||
BudgetPhaseColor,
|
||||
BudgetPhaseLabel,
|
||||
type BudgetChangelog,
|
||||
type BudgetDetailBundle,
|
||||
type BudgetDetailBody,
|
||||
type BudgetDetailRow,
|
||||
} from '@/types/budget'
|
||||
|
||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||||
|
||||
export function BudgetDetailTabs({
|
||||
budget,
|
||||
onBack,
|
||||
onDelete,
|
||||
readOnly = false,
|
||||
}: {
|
||||
budget: BudgetDetailBundle
|
||||
onBack: () => void
|
||||
onDelete: () => void
|
||||
/** Menu "Duyệt" — ẩn mọi action thêm/sửa/xóa, chỉ xem + duyệt phase. */
|
||||
readOnly?: boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const isDraft = budget.phase === BudgetPhase.DangSoanThao
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-semibold text-slate-900">{budget.tenNganSach}</h2>
|
||||
<span className={cn('rounded px-1.5 py-0.5 text-[11px] font-medium', BudgetPhaseColor[budget.phase])}>
|
||||
{BudgetPhaseLabel[budget.phase]}
|
||||
</span>
|
||||
{readOnly && (
|
||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[11px] font-medium text-slate-600">
|
||||
chế độ duyệt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[12px] text-slate-500">
|
||||
<span className="font-mono">{budget.maNganSach ?? '—'}</span>
|
||||
<span>·</span>
|
||||
<span>Năm {budget.namNganSach}</span>
|
||||
<span>·</span>
|
||||
<span>{budget.projectName}</span>
|
||||
{budget.drafterName && (<><span>·</span><span>Soạn: {budget.drafterName}</span></>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isDraft && !readOnly && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate(`/budgets/new?id=${budget.id}`)}
|
||||
className="gap-1.5 text-xs"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" /> Sửa header
|
||||
</Button>
|
||||
<Button variant="danger" onClick={onDelete} className="gap-1.5 text-xs">
|
||||
<Trash2 className="h-3.5 w-3.5" /> Xóa
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="ghost" onClick={onBack} className="text-xs">← Đóng</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-200">
|
||||
<Section title="Thông tin">
|
||||
<InfoTab budget={budget} />
|
||||
</Section>
|
||||
<Section title={`Hạng mục (${budget.details.length}) — Tổng: ${fmtMoney(budget.tongNganSach)} đ`}>
|
||||
<ItemsTab budget={budget} readOnly={readOnly} />
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="px-5 py-4">
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</h3>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Exports cho Panel 3 — Approvals history + Changelog =====
|
||||
|
||||
export function BudgetApprovalsSection({ budget }: { budget: BudgetDetailBundle }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-900">
|
||||
Lịch sử duyệt ({budget.approvals.length})
|
||||
</h3>
|
||||
<ApprovalsList budget={budget} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BudgetHistorySection({ budget }: { budget: BudgetDetailBundle }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-900">Lịch sử thay đổi</h3>
|
||||
<HistoryList budget={budget} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Section: Thông tin Header =====
|
||||
function InfoTab({ budget }: { budget: BudgetDetailBundle }) {
|
||||
return (
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
||||
<Field label="Tên ngân sách" value={budget.tenNganSach} />
|
||||
<Field label="Mã ngân sách" value={<span className="font-mono">{budget.maNganSach ?? '—'}</span>} />
|
||||
<Field label="Năm ngân sách" value={budget.namNganSach} />
|
||||
<Field label="Dự án" value={budget.projectName} />
|
||||
<Field label="Phòng ban" value={budget.departmentName ?? '—'} />
|
||||
<Field label="Người soạn" value={budget.drafterName ?? '—'} />
|
||||
<Field label="Tổng ngân sách" value={<span className="font-semibold text-brand-700">{fmtMoney(budget.tongNganSach)} đ</span>} />
|
||||
<Field label="Trạng thái" value={<span className={cn('rounded px-1.5 py-0.5 text-[11px]', BudgetPhaseColor[budget.phase])}>{BudgetPhaseLabel[budget.phase]}</span>} />
|
||||
{budget.description && (
|
||||
<div className="col-span-2">
|
||||
<dt className="text-[11px] uppercase tracking-wide text-slate-400">Mô tả</dt>
|
||||
<dd className="mt-0.5 whitespace-pre-wrap text-slate-800">{budget.description}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-[11px] uppercase tracking-wide text-slate-400">{label}</dt>
|
||||
<dd className="mt-0.5 text-slate-800">{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Section: Hạng mục table CRUD =====
|
||||
function ItemsTab({ budget, readOnly = false }: { budget: BudgetDetailBundle; readOnly?: boolean }) {
|
||||
const qc = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editRow, setEditRow] = useState<BudgetDetailRow | null>(null)
|
||||
const isDraft = budget.phase === BudgetPhase.DangSoanThao
|
||||
const canMutate = !readOnly && isDraft
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async (rowId: string) => api.delete(`/budgets/${budget.id}/details/${rowId}`),
|
||||
onSuccess: () => {
|
||||
toast.success('Đã xóa hạng mục.')
|
||||
qc.invalidateQueries({ queryKey: ['budget-detail', budget.id] })
|
||||
qc.invalidateQueries({ queryKey: ['budget-list'] })
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
{canMutate && (
|
||||
<div className="mb-3 flex justify-end">
|
||||
<Button onClick={() => setOpen(true)} className="gap-1.5 text-xs">
|
||||
<Plus className="h-3.5 w-3.5" /> Thêm hạng mục
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{budget.details.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
{canMutate ? 'Chưa có hạng mục nào. Thêm để bắt đầu lập ngân sách.' : 'Chưa có hạng mục.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th className="px-2 py-2 text-left">#</th>
|
||||
<th className="px-2 py-2 text-left">Nhóm</th>
|
||||
<th className="px-2 py-2 text-left">Mã / Nội dung</th>
|
||||
<th className="px-2 py-2 text-left">ĐVT</th>
|
||||
<th className="px-2 py-2 text-right">KL</th>
|
||||
<th className="px-2 py-2 text-right">Đơn giá</th>
|
||||
<th className="px-2 py-2 text-right">Thành tiền</th>
|
||||
{canMutate && <th className="px-2 py-2"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{budget.details.map((d, idx) => (
|
||||
<tr key={d.id} className="align-top">
|
||||
<td className="px-2 py-2 text-slate-400">{idx + 1}</td>
|
||||
<td className="px-2 py-2">
|
||||
<div className="font-mono text-[11px] text-slate-500">{d.groupCode}</div>
|
||||
<div className="text-[12px] text-slate-700">{d.groupName}</div>
|
||||
</td>
|
||||
<td className="px-2 py-2 max-w-md">
|
||||
{d.itemCode && <div className="font-mono text-[11px] text-slate-500">{d.itemCode}</div>}
|
||||
<div className="text-slate-800">{d.noiDung}</div>
|
||||
{d.ghiChu && <div className="mt-0.5 text-[11px] italic text-slate-500">{d.ghiChu}</div>}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-slate-600">{d.donViTinh ?? '—'}</td>
|
||||
<td className="px-2 py-2 text-right tabular-nums">{fmtMoney(d.khoiLuong)}</td>
|
||||
<td className="px-2 py-2 text-right tabular-nums">{fmtMoney(d.donGia)}</td>
|
||||
<td className="px-2 py-2 text-right font-medium tabular-nums text-slate-900">
|
||||
{fmtMoney(d.thanhTien)}
|
||||
</td>
|
||||
{canMutate && (
|
||||
<td className="px-2 py-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
onClick={() => setEditRow(d)}
|
||||
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
|
||||
title="Sửa"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Xóa hạng mục này?')) remove.mutate(d.id)
|
||||
}}
|
||||
className="rounded p-1 text-slate-400 hover:bg-red-50 hover:text-red-600"
|
||||
title="Xóa"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-slate-200 bg-slate-50 font-semibold">
|
||||
<td colSpan={6} className="px-2 py-2 text-right text-slate-600">Tổng:</td>
|
||||
<td className="px-2 py-2 text-right tabular-nums text-brand-700">
|
||||
{fmtMoney(budget.tongNganSach)}
|
||||
</td>
|
||||
{canMutate && <td></td>}
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<DetailRowDialog
|
||||
budgetId={budget.id}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{editRow && (
|
||||
<DetailRowDialog
|
||||
budgetId={budget.id}
|
||||
existing={editRow}
|
||||
onClose={() => setEditRow(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Dialog: Thêm / Sửa hạng mục =====
|
||||
function DetailRowDialog({
|
||||
budgetId,
|
||||
existing,
|
||||
onClose,
|
||||
}: {
|
||||
budgetId: string
|
||||
existing?: BudgetDetailRow
|
||||
onClose: () => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [form, setForm] = useState<BudgetDetailBody>({
|
||||
groupCode: existing?.groupCode ?? '',
|
||||
groupName: existing?.groupName ?? '',
|
||||
itemCode: existing?.itemCode ?? null,
|
||||
noiDung: existing?.noiDung ?? '',
|
||||
donViTinh: existing?.donViTinh ?? null,
|
||||
khoiLuong: existing?.khoiLuong ?? 0,
|
||||
donGia: existing?.donGia ?? 0,
|
||||
thanhTien: existing?.thanhTien ?? 0,
|
||||
ghiChu: existing?.ghiChu ?? null,
|
||||
})
|
||||
|
||||
// Auto-compute thành tiền khi đổi KL/đơn giá (UX nicety)
|
||||
function setQty(v: number) {
|
||||
const next = { ...form, khoiLuong: v, thanhTien: v * form.donGia }
|
||||
setForm(next)
|
||||
}
|
||||
function setPrice(v: number) {
|
||||
const next = { ...form, donGia: v, thanhTien: form.khoiLuong * v }
|
||||
setForm(next)
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
groupCode: form.groupCode,
|
||||
groupName: form.groupName,
|
||||
itemCode: form.itemCode || null,
|
||||
noiDung: form.noiDung,
|
||||
donViTinh: form.donViTinh || null,
|
||||
khoiLuong: form.khoiLuong,
|
||||
donGia: form.donGia,
|
||||
thanhTien: form.thanhTien,
|
||||
ghiChu: form.ghiChu || null,
|
||||
}
|
||||
if (existing) {
|
||||
return api.put(`/budgets/${budgetId}/details/${existing.id}`, payload)
|
||||
}
|
||||
return api.post(`/budgets/${budgetId}/details`, payload)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(existing ? 'Đã cập nhật hạng mục.' : 'Đã thêm hạng mục.')
|
||||
qc.invalidateQueries({ queryKey: ['budget-detail', budgetId] })
|
||||
qc.invalidateQueries({ queryKey: ['budget-list'] })
|
||||
onClose()
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={existing ? 'Sửa hạng mục' : 'Thêm hạng mục'}
|
||||
footer={<>
|
||||
<Button variant="ghost" onClick={onClose}>Hủy</Button>
|
||||
<Button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={!form.groupCode || !form.groupName || !form.noiDung || save.isPending}
|
||||
>
|
||||
{existing ? 'Lưu' : 'Thêm'}
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Mã nhóm *</Label>
|
||||
<Input
|
||||
value={form.groupCode}
|
||||
onChange={e => setForm({ ...form, groupCode: e.target.value })}
|
||||
placeholder="A.I"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Tên nhóm *</Label>
|
||||
<Input
|
||||
value={form.groupName}
|
||||
onChange={e => setForm({ ...form, groupName: e.target.value })}
|
||||
placeholder="Vật tư xây dựng"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Mã hạng mục</Label>
|
||||
<Input
|
||||
value={form.itemCode ?? ''}
|
||||
onChange={e => setForm({ ...form, itemCode: e.target.value || null })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Nội dung *</Label>
|
||||
<Input
|
||||
value={form.noiDung}
|
||||
onChange={e => setForm({ ...form, noiDung: e.target.value })}
|
||||
placeholder="Bê tông M250"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div>
|
||||
<Label>ĐVT</Label>
|
||||
<Input
|
||||
value={form.donViTinh ?? ''}
|
||||
onChange={e => setForm({ ...form, donViTinh: e.target.value || null })}
|
||||
placeholder="m³"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Khối lượng</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={form.khoiLuong}
|
||||
onChange={e => setQty(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Đơn giá</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.donGia}
|
||||
onChange={e => setPrice(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Thành tiền</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.thanhTien}
|
||||
onChange={e => setForm({ ...form, thanhTien: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Ghi chú</Label>
|
||||
<Input
|
||||
value={form.ghiChu ?? ''}
|
||||
onChange={e => setForm({ ...form, ghiChu: e.target.value || null })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Sub: Approvals list =====
|
||||
function ApprovalsList({ budget }: { budget: BudgetDetailBundle }) {
|
||||
if (budget.approvals.length === 0)
|
||||
return <p className="text-sm text-slate-500">Chưa có bước duyệt nào.</p>
|
||||
return (
|
||||
<ol className="space-y-2">
|
||||
{budget.approvals.map(a => (
|
||||
<li key={a.id} className="rounded border border-slate-200 bg-white p-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', BudgetPhaseColor[a.fromPhase])}>
|
||||
{BudgetPhaseLabel[a.fromPhase]}
|
||||
</span>
|
||||
<span className="mx-2">→</span>
|
||||
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', BudgetPhaseColor[a.toPhase])}>
|
||||
{BudgetPhaseLabel[a.toPhase]}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">{new Date(a.approvedAt).toLocaleString('vi-VN')}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
{a.approverName ?? 'Hệ thống'}{a.comment && ` · ${a.comment}`}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Sub: Changelog list =====
|
||||
function HistoryList({ budget }: { budget: BudgetDetailBundle }) {
|
||||
const logs = useQuery({
|
||||
queryKey: ['budget-changelog', budget.id],
|
||||
queryFn: async () => (await api.get<BudgetChangelog[]>(`/budgets/${budget.id}/changelogs`)).data,
|
||||
})
|
||||
if (logs.isLoading) return <p className="text-sm text-slate-500">Đang tải…</p>
|
||||
if (!logs.data || logs.data.length === 0)
|
||||
return <p className="text-sm text-slate-500">Chưa có lịch sử.</p>
|
||||
return (
|
||||
<ol className="space-y-1.5 text-sm">
|
||||
{logs.data.map(l => (
|
||||
<li key={l.id} className="border-l-2 border-slate-200 py-1 pl-3">
|
||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>{l.userName ?? 'Hệ thống'}</span>
|
||||
<span>{new Date(l.createdAt).toLocaleString('vi-VN')}</span>
|
||||
</div>
|
||||
<div className="text-slate-800">{l.summary}</div>
|
||||
{l.contextNote && <div className="text-xs text-slate-500">{l.contextNote}</div>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
@ -1,225 +0,0 @@
|
||||
// Panel 3 — workflow timeline + transition buttons + approval history + changelog.
|
||||
// Pulls nextPhases từ BE bundle (single source of truth).
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
ApprovalDecision,
|
||||
ApprovalStage,
|
||||
BudgetPhase,
|
||||
BudgetPhaseColor,
|
||||
BudgetPhaseLabel,
|
||||
type BudgetDepartmentApproval,
|
||||
type BudgetDetailBundle,
|
||||
} from '@/types/budget'
|
||||
import { BudgetApprovalsSection, BudgetHistorySection } from './BudgetDetailTabs'
|
||||
|
||||
export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle }) {
|
||||
const [target, setTarget] = useState<number | null>(null)
|
||||
const [comment, setComment] = useState('')
|
||||
const qc = useQueryClient()
|
||||
|
||||
// 2-stage dept approvals (Migration 16) — fetch riêng để FE render timeline.
|
||||
const { data: deptApprovals = [] } = useQuery<BudgetDepartmentApproval[]>({
|
||||
queryKey: ['budget-dept-approvals', budget.id],
|
||||
queryFn: async () => (await api.get(`/budgets/${budget.id}/department-approvals`)).data,
|
||||
})
|
||||
|
||||
const transition = useMutation({
|
||||
mutationFn: async () =>
|
||||
api.post(`/budgets/${budget.id}/transitions`, {
|
||||
targetPhase: target,
|
||||
decision: target === BudgetPhase.TuChoi ? ApprovalDecision.Reject : ApprovalDecision.Approve,
|
||||
comment: comment || null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Đã chuyển phase.')
|
||||
qc.invalidateQueries({ queryKey: ['budget-detail', budget.id] })
|
||||
qc.invalidateQueries({ queryKey: ['budget-list'] })
|
||||
qc.invalidateQueries({ queryKey: ['budget-changelog', budget.id] })
|
||||
qc.invalidateQueries({ queryKey: ['budget-dept-approvals', budget.id] })
|
||||
setTarget(null)
|
||||
setComment('')
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const next = budget.workflow.nextPhases
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">Quy trình</h3>
|
||||
<p className="mt-0.5 text-[11px] text-slate-500">{budget.workflow.policyDescription}</p>
|
||||
</div>
|
||||
|
||||
<ol className="space-y-1.5">
|
||||
{budget.workflow.activePhases
|
||||
.filter(p => p !== BudgetPhase.TuChoi)
|
||||
.map(p => {
|
||||
const isCurrent = budget.phase === p
|
||||
const isPast = isPastPhase(budget.phase, p, budget.workflow.activePhases)
|
||||
return (
|
||||
<li key={p}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded border px-2 py-1.5 text-xs',
|
||||
isCurrent && 'border-brand-300 bg-brand-50 font-medium',
|
||||
isPast && 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
!isCurrent && !isPast && 'border-slate-200 text-slate-500',
|
||||
)}
|
||||
>
|
||||
<span className={cn('rounded px-1.5 py-0.5 text-[10px]', BudgetPhaseColor[p])}>{p}</span>
|
||||
<span className="truncate">{BudgetPhaseLabel[p]}</span>
|
||||
{isCurrent && <span className="ml-auto text-[10px] text-brand-700">● hiện tại</span>}
|
||||
{isPast && <span className="ml-auto text-[10px] text-emerald-600">✓</span>}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{next.length > 0 && (
|
||||
<div>
|
||||
<Label className="text-xs">Chuyển tiếp:</Label>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{next.map(p => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setTarget(p)}
|
||||
className={cn(
|
||||
'rounded border px-2 py-1 text-[11px] transition',
|
||||
p === BudgetPhase.TuChoi
|
||||
? 'border-red-200 text-red-700 hover:bg-red-50'
|
||||
: p === BudgetPhase.DangSoanThao
|
||||
? 'border-amber-300 text-amber-700 hover:bg-amber-50'
|
||||
: 'border-brand-300 text-brand-700 hover:bg-brand-50',
|
||||
)}
|
||||
>
|
||||
→ {BudgetPhaseLabel[p]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{target !== null && (
|
||||
<Dialog
|
||||
open
|
||||
onClose={() => setTarget(null)}
|
||||
title={`Chuyển → ${BudgetPhaseLabel[target]}`}
|
||||
footer={<>
|
||||
<Button variant="ghost" onClick={() => setTarget(null)}>Hủy</Button>
|
||||
<Button onClick={() => transition.mutate()} disabled={transition.isPending}>Xác nhận</Button>
|
||||
</>}
|
||||
>
|
||||
<Label>Ghi chú (tùy chọn)</Label>
|
||||
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{deptApprovals.length > 0 && (
|
||||
<div className="border-t border-slate-200 pt-4">
|
||||
<BudgetDeptApprovalsSection rows={deptApprovals} currentPhase={budget.phase} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-200 pt-4">
|
||||
<BudgetApprovalsSection budget={budget} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 pt-4">
|
||||
<BudgetHistorySection budget={budget} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 2-stage dept approval timeline (Migration 16) — mirror PE/Contract pattern.
|
||||
function BudgetDeptApprovalsSection({
|
||||
rows,
|
||||
currentPhase,
|
||||
}: {
|
||||
rows: BudgetDepartmentApproval[]
|
||||
currentPhase: number
|
||||
}) {
|
||||
const grouped = new Map<number, Map<string, BudgetDepartmentApproval[]>>()
|
||||
for (const r of rows) {
|
||||
if (!grouped.has(r.phaseAtApproval)) grouped.set(r.phaseAtApproval, new Map())
|
||||
const byDept = grouped.get(r.phaseAtApproval)!
|
||||
if (!byDept.has(r.departmentId)) byDept.set(r.departmentId, [])
|
||||
byDept.get(r.departmentId)!.push(r)
|
||||
}
|
||||
const phaseOrder = [...grouped.keys()].sort((a, b) => a - b)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">Tiến trình duyệt 2-cấp phòng ban</h3>
|
||||
<p className="mt-0.5 text-[11px] text-slate-500">NV Review → TPB Confirm. Phase chỉ chuyển khi có Confirm.</p>
|
||||
<div className="mt-2 space-y-3">
|
||||
{phaseOrder.map(phase => {
|
||||
const byDept = grouped.get(phase)!
|
||||
return (
|
||||
<div key={phase}>
|
||||
<div className={cn(
|
||||
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||
BudgetPhaseColor[phase] ?? 'bg-slate-100 text-slate-700',
|
||||
)}>
|
||||
{BudgetPhaseLabel[phase] ?? `Phase ${phase}`}
|
||||
</div>
|
||||
<div className="mt-1 space-y-1.5">
|
||||
{[...byDept.entries()].map(([deptId, stages]) => {
|
||||
const review = stages.find(s => s.stage === ApprovalStage.Review)
|
||||
const confirm = stages.find(s => s.stage === ApprovalStage.Confirm)
|
||||
const deptName = stages[0]?.departmentName ?? '(không rõ phòng)'
|
||||
const isPending = phase === currentPhase && review && !confirm
|
||||
return (
|
||||
<div key={deptId} className={cn(
|
||||
'rounded border px-2 py-1.5 text-[11px]',
|
||||
isPending ? 'border-amber-300 bg-amber-50' : 'border-slate-200 bg-slate-50',
|
||||
)}>
|
||||
<div className="font-medium text-slate-700">{deptName}</div>
|
||||
<div className="mt-1 grid grid-cols-[60px_1fr] gap-x-2 gap-y-0.5">
|
||||
<span className="text-slate-500">Review:</span>
|
||||
<span className={review ? 'text-slate-700' : 'text-slate-400'}>
|
||||
{review
|
||||
? <>✓ {review.approverName} <span className="text-slate-500">— {fmtTime(review.approvedAt)}</span>{review.comment && <span className="text-slate-500"> · "{review.comment}"</span>}</>
|
||||
: '— chưa có'}
|
||||
</span>
|
||||
<span className="text-slate-500">Confirm:</span>
|
||||
<span className={confirm ? 'text-emerald-700' : 'text-amber-700'}>
|
||||
{confirm
|
||||
? <>✓ {confirm.approverName}{confirm.isBypassed && <span className="ml-1 rounded bg-fuchsia-100 px-1 text-[9px] text-fuchsia-700">bypass</span>} <span className="text-slate-500">— {fmtTime(confirm.approvedAt)}</span>{confirm.comment && <span className="text-slate-500"> · "{confirm.comment}"</span>}</>
|
||||
: '⏳ chờ TPB confirm'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function isPastPhase(current: number, p: number, active: number[]): boolean {
|
||||
const orderedIdx = active.indexOf(p)
|
||||
const currentIdx = active.indexOf(current)
|
||||
if (orderedIdx < 0 || currentIdx < 0) return false
|
||||
return orderedIdx < currentIdx && p !== BudgetPhase.TuChoi
|
||||
}
|
||||
@ -6,7 +6,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { Check, ChevronDown, ChevronRight, Download, Eye, Paperclip, Pencil, Plus, Trash2, Upload, Wallet } from 'lucide-react'
|
||||
import { Check, ChevronDown, ChevronRight, Download, Eye, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
@ -40,9 +40,8 @@ import {
|
||||
type PeQuote,
|
||||
type PeSupplier,
|
||||
} from '@/types/purchaseEvaluation'
|
||||
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
|
||||
import { SupplierType, SupplierTypeLabel } from '@/types/master'
|
||||
import type { Paged, Supplier } from '@/types/master'
|
||||
import type { Supplier } from '@/types/master'
|
||||
|
||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||||
|
||||
@ -174,9 +173,10 @@ export function PeDetailTabs({
|
||||
const gia = computeGiaChaoThau(evaluation)
|
||||
if (gia == null || gia <= 0) missing.push("Đơn vị được chọn chưa có giá chào thầu")
|
||||
}
|
||||
// 3. Chưa nhập Ngân sách (không link Budget entity VÀ không nhập manual amount)
|
||||
if (evaluation.budgetId == null && (evaluation.budgetManualAmount == null || evaluation.budgetManualAmount <= 0)) {
|
||||
missing.push("Chưa nhập Ngân sách")
|
||||
// 3. Chưa nhập Ngân sách kỳ này (S61 — row 3 bảng tổng hợp, drafter nhập).
|
||||
// Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0.
|
||||
if (evaluation.budgetPeriodAmount == null || evaluation.budgetPeriodAmount <= 0) {
|
||||
missing.push("Chưa nhập Ngân sách kỳ này")
|
||||
}
|
||||
// 4. Chưa đính kèm Bảng so sánh (attachment với supplier-row null — chuẩn Section 3)
|
||||
if (!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null)) {
|
||||
@ -286,12 +286,9 @@ export function PeDetailTabs({
|
||||
? <LevelOpinionsSectionV2 ev={evaluation} />
|
||||
: <DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />}
|
||||
</Section>
|
||||
{/* S22+4 — Feature 2: Section "Điều chỉnh ngân sách" cho phép Drafter
|
||||
(Nháp/Trả lại) HOẶC Approver currentLevel (Đang duyệt) HOẶC Admin
|
||||
sửa Budget link / Manual amount. BE PATCH /budget-adjust riêng. */}
|
||||
<Section title="5. Điều chỉnh ngân sách">
|
||||
<BudgetAdjustSection ev={evaluation} readOnly={readOnly} />
|
||||
</Section>
|
||||
{/* S61 — Section "Điều chỉnh ngân sách" cũ (BudgetAdjustSection) XÓA:
|
||||
module Budget bỏ hẳn, bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ trong Section 3
|
||||
thay thế (PRO/CCM/drafter nhập trực tiếp theo capability flag BE). */}
|
||||
</div>
|
||||
|
||||
{/* Action bar bottom — workspace mode + canEdit + !readOnly. 3 nút:
|
||||
@ -680,9 +677,11 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
|
||||
diaDiem: diaDiem || null,
|
||||
moTa: moTa || null,
|
||||
paymentTerms: paymentTerms || null,
|
||||
budgetId: ev.budgetId,
|
||||
budgetManualName: ev.budgetManualName,
|
||||
budgetManualAmount: ev.budgetManualAmount,
|
||||
// S61 — module Budget cũ XÓA HẲN; PE giữ 2 ô ngân sách mới (echo lại
|
||||
// giá trị hiện tại để PUT update không xóa nhầm — drafter sửa qua bảng
|
||||
// TỔNG HỢP NGÂN SÁCH / PATCH budget-adjust).
|
||||
budgetPeriodAmount: ev.budgetPeriodAmount,
|
||||
expectedRemainingAmount: ev.expectedRemainingAmount,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
@ -854,325 +853,434 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 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 + phase editable (DangSoanThao /
|
||||
// TraLai). Read-only khi pendingMe=1 hoặc phase đã gửi duyệt / đã duyệt /
|
||||
// từ chối. Empty values hiển thị empty (per user 2026-05-07).
|
||||
function BudgetFieldRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||||
const canEdit = !readOnly && isEditablePhase(ev.phase)
|
||||
const qc = useQueryClient()
|
||||
// ===== b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt) =====
|
||||
// Module Budget cũ XÓA HẲN → ngân sách gói thầu per (Dự án × Hạng mục) compute
|
||||
// BE trả `ev.budgetSummary`. 2 block:
|
||||
// A. NGÂN SÁCH (gói thầu): full / ban hành lần đầu (CCM) / hiệu chỉnh (CCM) /
|
||||
// dự trù PRO + ghi chú (PRO) — editable theo capability flag canEditCcm/canEditPro.
|
||||
// B. THỰC HIỆN: 9 dòng công thức Excel — drafter nhập row3 (NS kỳ này) + row8
|
||||
// (giá trị thực hiện dự kiến còn lại) qua PATCH /budget-adjust.
|
||||
// budgetSummary=null → phiếu cũ chưa gắn Hạng mục → banner nhắc gắn.
|
||||
|
||||
// Detect mode khi mount/refresh: prefer manual mode nếu đã có data manual + ko link.
|
||||
// Session 20 turn 6: user yêu cầu manual mode chỉ nhập số tiền — bỏ Tên field
|
||||
// khỏi UI. State manualName drop, BE save luôn null cho field này. Data cũ với
|
||||
// tên vẫn hiển thị OK ở read-only display (ev.budgetManualName).
|
||||
const initialManual = (ev.budgetManualName !== null || ev.budgetManualAmount !== null) && !ev.budgetId
|
||||
const [manualMode, setManualMode] = useState(initialManual)
|
||||
const [budgetId, setBudgetId] = useState(ev.budgetId ?? '')
|
||||
const [manualAmount, setManualAmount] = useState(ev.budgetManualAmount ?? 0)
|
||||
// fmtVnd: "1.234.567 đ". fmtPct: 1 chữ số thập phân, guard chia-0 (denom<=0 → null).
|
||||
const fmtVnd = (v: number) => `${Math.round(v).toLocaleString('vi-VN')} đ`
|
||||
const fmtVndSigned = (v: number) =>
|
||||
v < 0 ? `(${Math.round(Math.abs(v)).toLocaleString('vi-VN')}) đ` : `${Math.round(v).toLocaleString('vi-VN')} đ`
|
||||
const fmtPct = (num: number, denom: number): string | null =>
|
||||
denom > 0 ? `${((num / denom) * 100).toFixed(1)}%` : null
|
||||
|
||||
// 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 && manualAmount !== (ev.budgetManualAmount ?? 0))
|
||||
|| (!manualMode && budgetId !== (ev.budgetId ?? ''))
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = manualMode
|
||||
? { budgetId: null, budgetManualName: 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>}
|
||||
/>
|
||||
)
|
||||
// Inline-edit số tiền VND (reuse formatVndInput/parseVnd module-level). allowNegative
|
||||
// cho dòng "hiệu chỉnh tăng giảm" (CCM nhập số âm). onSave nhận number|null.
|
||||
function VndInlineEdit({
|
||||
initial, allowNegative = false, onSave, saving, label,
|
||||
}: {
|
||||
initial: number | null
|
||||
allowNegative?: boolean
|
||||
onSave: (v: number | null) => void
|
||||
saving: boolean
|
||||
label?: string
|
||||
}) {
|
||||
const [text, setText] = useState(initial != null ? Math.abs(initial).toLocaleString('vi-VN') : '')
|
||||
const [neg, setNeg] = useState((initial ?? 0) < 0)
|
||||
const parse = (): number | null => {
|
||||
const n = parseVnd(text)
|
||||
if (n === 0 && text.trim() === '') return null
|
||||
return allowNegative && neg ? -n : n
|
||||
}
|
||||
|
||||
// Editable mode (canEdit=true)
|
||||
const dirty = parse() !== initial
|
||||
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="relative max-w-xs">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formatVndInput(manualAmount)}
|
||||
onChange={e => setManualAmount(parseVnd(e.target.value))}
|
||||
placeholder="0"
|
||||
className="pr-10 font-mono text-right text-sm"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||||
</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 ?? '')
|
||||
setManualAmount(ev.budgetManualAmount ?? 0)
|
||||
}}
|
||||
className="text-[11px] text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
Hủy thay đổi
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{allowNegative && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNeg(v => !v)}
|
||||
className={cn(
|
||||
'h-6 w-6 shrink-0 rounded border text-xs font-bold',
|
||||
neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
|
||||
)}
|
||||
title="Đảo dấu âm/dương"
|
||||
>
|
||||
{neg ? '−' : '+'}
|
||||
</button>
|
||||
)}
|
||||
<div className="relative w-40">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value.replace(/[^\d.]/g, ''))}
|
||||
placeholder="0"
|
||||
aria-label={label}
|
||||
className="h-7 pr-6 font-mono text-right text-[13px]"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-[11px] font-medium text-slate-500">đ</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => onSave(parse())}
|
||||
disabled={!dirty || saving}
|
||||
className="h-7 px-2 text-[11px]"
|
||||
>
|
||||
{saving ? '…' : 'Lưu'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 1 dòng bảng — label trái | value phải (right-align) | cột 3 (% hoặc ghi chú).
|
||||
// tone: 'brand' = nền brand đậm chữ trắng (dòng tổng) · 'brand-soft' = nền brand-50.
|
||||
function BudgetRow({
|
||||
label, sub, value, third, tone, danger, mono = true,
|
||||
}: {
|
||||
label: React.ReactNode
|
||||
sub?: React.ReactNode
|
||||
value: React.ReactNode
|
||||
third?: React.ReactNode
|
||||
tone?: 'brand' | 'brand-soft' | 'blue-soft'
|
||||
danger?: boolean
|
||||
mono?: boolean
|
||||
}) {
|
||||
const toneCls =
|
||||
tone === 'brand' ? 'bg-[#1F7DC1] text-white font-semibold'
|
||||
: tone === 'brand-soft' ? 'bg-[#1F7DC1]/10'
|
||||
: tone === 'blue-soft' ? 'bg-blue-50'
|
||||
: ''
|
||||
return (
|
||||
<div className={cn('flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]', toneCls)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={cn(tone === 'brand' ? 'text-white' : 'text-slate-700')}>{label}</div>
|
||||
{sub && <div className={cn('text-[10px]', tone === 'brand' ? 'text-white/70' : 'text-slate-400')}>{sub}</div>}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'w-48 shrink-0 text-right tabular-nums',
|
||||
mono && 'font-mono',
|
||||
danger ? 'font-semibold text-red-600' : tone === 'brand' ? 'font-bold' : 'text-slate-900',
|
||||
)}>
|
||||
{value}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'w-24 shrink-0 text-right text-[11px]',
|
||||
tone === 'brand' ? 'text-white/80' : 'text-slate-500',
|
||||
)}>
|
||||
{third}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Section "Điều chỉnh ngân sách" (S22+4 — Feature 2) =====
|
||||
// Cho phép Drafter (DangSoanThao/TraLai) HOẶC Approver currentLevel (ChoDuyet)
|
||||
// HOẶC Admin sửa BudgetId + BudgetManualName + BudgetManualAmount qua endpoint
|
||||
// PATCH /budget-adjust riêng. Audit changelog tự động.
|
||||
function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||||
const { user: currentUser } = useAuth()
|
||||
// Block tiêu đề (A / B)
|
||||
function BudgetBlockHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border-b border-slate-200 bg-slate-100 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-600">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||||
const qc = useQueryClient()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const bs = ev.budgetSummary
|
||||
|
||||
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
||||
const isDrafter = currentUser?.id != null && ev.drafterUserId === currentUser.id
|
||||
const isDrafterPhase = ev.phase === PurchaseEvaluationPhase.DangSoanThao
|
||||
|| ev.phase === PurchaseEvaluationPhase.TraLai
|
||||
// F4 Approver scope (Mig 30): phase ChoDuyet + actor in currentApproval.approvers
|
||||
// + currentLevel có flag AllowApproverEditBudget=true (admin Designer tick per slot).
|
||||
const actorInCurrentLevel = ev.currentApproval?.approvers?.some(a => a.userId === currentUser?.id) ?? false
|
||||
const approverEditBudgetAllowed = ev.currentLevelOptions?.allowApproverEditBudget ?? false
|
||||
const isApproverChoDuyet = ev.phase === PurchaseEvaluationPhase.ChoDuyet
|
||||
&& actorInCurrentLevel
|
||||
&& approverEditBudgetAllowed
|
||||
// Drafter nhập được row3 (NS kỳ này) + row8 (giá trị thực hiện dự kiến còn lại)
|
||||
// khi phiếu DangSoanThao/TraLai + !readOnly. Mirror predicate row3/row8 spec.
|
||||
const drafterEditable = !readOnly && isEditablePhase(ev.phase)
|
||||
|
||||
// S23 t2 bug fix: F4 Approver scope BYPASS readOnly (mirror F3 itemsReadOnly
|
||||
// pattern). Khi admin tick AllowApproverEditBudget cho slot + actor match +
|
||||
// Phase=ChoDuyet → button "Điều chỉnh" enable trong menu Duyệt (readOnly=true)
|
||||
// dù chế độ chỉ-đọc. Drafter + Admin vẫn cần !readOnly (chỉ active từ Workspace).
|
||||
const canAdjust = isAdmin
|
||||
|| (!readOnly && isDrafter && isDrafterPhase)
|
||||
|| isApproverChoDuyet
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||
}
|
||||
|
||||
const initialManual = (ev.budgetManualName !== null || ev.budgetManualAmount !== null) && !ev.budgetId
|
||||
const [manualMode, setManualMode] = useState(initialManual)
|
||||
const [budgetId, setBudgetId] = useState(ev.budgetId ?? '')
|
||||
const [manualAmount, setManualAmount] = useState(ev.budgetManualAmount ?? 0)
|
||||
// S59 UAT vòng 4 (anh chốt "chỗ tên ngân sách bỏ đi"): bỏ ô "Tên (không bắt buộc)"
|
||||
// — user không hiểu ý nghĩa; manual budget chỉ còn Số tiền. Tên cũ (phiếu trước)
|
||||
// vẫn hiển thị read-only, sẽ về null khi Lưu điều chỉnh lần tới.
|
||||
|
||||
const eligibleBudgets = useQuery({
|
||||
queryKey: ['eligible-budgets-adjust', ev.projectId],
|
||||
queryFn: async () => (await api.get<Paged<BudgetListItem>>('/budgets', {
|
||||
params: { pageSize: 100, projectId: ev.projectId, phase: BudgetPhase.DaDuyet },
|
||||
})).data.items,
|
||||
enabled: editing && canAdjust,
|
||||
// PUT /budget/pro — chỉ khi canEditPro. proEstimateAmount + proNote.
|
||||
const proMut = useMutation({
|
||||
mutationFn: async (body: { proEstimateAmount: number | null; proNote: string | null }) =>
|
||||
api.put(`/purchase-evaluations/${ev.id}/budget/pro`, body),
|
||||
onSuccess: () => { toast.success('Đã lưu dự trù PRO'); invalidate() },
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
// PUT /budget/ccm — chỉ khi canEditCcm. initialAmount + adjustmentAmount.
|
||||
const ccmMut = useMutation({
|
||||
mutationFn: async (body: { initialAmount: number | null; adjustmentAmount: number | null }) =>
|
||||
api.put(`/purchase-evaluations/${ev.id}/budget/ccm`, body),
|
||||
onSuccess: () => { toast.success('Đã lưu ngân sách ban hành'); invalidate() },
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
// PATCH /budget-adjust — ABSOLUTE-SET: BE set thẳng CẢ 2 field (thiếu field =
|
||||
// null = CLEAR). Mọi call-site PHẢI gửi đủ cặp {budgetPeriodAmount,
|
||||
// expectedRemainingAmount} (field không đổi → echo giá trị hiện tại từ ev).
|
||||
const adjustMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = manualMode
|
||||
? { budgetId: null, budgetManualName: null, budgetManualAmount: manualAmount > 0 ? manualAmount : null }
|
||||
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
|
||||
await api.patch(`/purchase-evaluations/${ev.id}/budget-adjust`, payload)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Đã điều chỉnh ngân sách')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||
setEditing(false)
|
||||
},
|
||||
mutationFn: async (body: { budgetPeriodAmount?: number | null; expectedRemainingAmount?: number | null }) =>
|
||||
api.patch(`/purchase-evaluations/${ev.id}/budget-adjust`, body),
|
||||
onSuccess: () => { toast.success('Đã lưu'); invalidate() },
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
// History defer S22+5 — changelog fetch separate endpoint, KHÔNG có trong
|
||||
// PeDetailBundle. UAT user xem ở Panel "Lịch sử thay đổi" thông qua tab History.
|
||||
// proNote inline-edit state (Textarea — không dùng VndInlineEdit)
|
||||
const [proNoteText, setProNoteText] = useState(bs?.proNote ?? '')
|
||||
useEffect(() => { setProNoteText(bs?.proNote ?? '') }, [bs?.proNote])
|
||||
|
||||
// Display read mode
|
||||
const displayLink = ev.budget ? (
|
||||
<span>
|
||||
<span className="font-mono text-[11px] text-brand-700">{ev.budget.maNganSach ?? '—'}</span>
|
||||
{' · '}{ev.budget.tenNganSach}
|
||||
{' · '}<span className="font-semibold text-slate-900">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
|
||||
</span>
|
||||
) : (ev.budgetManualAmount != null || ev.budgetManualName) ? (
|
||||
<span>
|
||||
{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="italic text-slate-400">Chưa có ngân sách</span>
|
||||
// Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
|
||||
if (!bs) {
|
||||
return (
|
||||
<div className="rounded border border-amber-200 bg-amber-50 px-3 py-2.5 text-[12px] text-amber-800">
|
||||
Phiếu chưa gắn Hạng mục công việc — gắn Hạng mục để dùng ngân sách gói thầu.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Số liệu Excel =====
|
||||
const full = bs.fullAmount
|
||||
const row1 = bs.previousSubmittedTotal // Ngân sách trình duyệt trước
|
||||
const row2 = bs.previousSelectedTotal // Kỳ trước đã chọn thầu
|
||||
const row3 = ev.budgetPeriodAmount ?? 0 // Ngân sách - kỳ này (drafter)
|
||||
const row4 = bs.currentProposalTotal // Giá trị kỳ này (đề xuất NCC được chọn)
|
||||
const row5 = row1 + row3 // Lũy kế ngân sách đã sử dụng (= 1 + 3)
|
||||
const row6 = row2 + row4 // Lũy kế thực hiện (= 2 + 4)
|
||||
const row7 = full - row5 // Ngân sách còn lại
|
||||
const row8 = ev.expectedRemainingAmount ?? row7 // Giá trị thực hiện dự kiến còn lại
|
||||
const row9 = row4 + row8 // Giá trị tổng thực hiện dự kiến (= 4 + 8)
|
||||
|
||||
const cmpPeriod = row3 - row4 // So sánh với ngân sách kỳ này (row3 − row4)
|
||||
const cmp56 = row5 - row6 // So với NS (row5 − row6)
|
||||
const cmpFull = full - row9 // So sánh với Ngân sách full (full − row9)
|
||||
|
||||
// Cờ tô màu cảnh báo
|
||||
const proposalOver = bs.currentProposalTotal > (ev.budgetPeriodAmount ?? 0) && ev.budgetPeriodAmount != null
|
||||
const remainingOver = ev.expectedRemainingAmount != null && ev.expectedRemainingAmount > row7
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Read mode + Edit toggle */}
|
||||
{!editing && (
|
||||
<div className="flex items-start justify-between gap-3 rounded border border-emerald-200 bg-emerald-50/40 px-3 py-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Wallet className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
|
||||
<div className="text-sm text-slate-700">{displayLink}</div>
|
||||
</div>
|
||||
{canAdjust && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setManualMode(initialManual)
|
||||
setBudgetId(ev.budgetId ?? '')
|
||||
setManualAmount(ev.budgetManualAmount ?? 0)
|
||||
setEditing(true)
|
||||
}}
|
||||
variant="ghost"
|
||||
className="h-7 shrink-0 px-2 text-xs"
|
||||
>
|
||||
<Pencil className="h-3 w-3" /> Điều chỉnh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-hidden rounded-lg border border-slate-300">
|
||||
<div className="bg-[#1F7DC1] px-3 py-2 text-[12px] font-bold uppercase tracking-wide text-white">
|
||||
Tổng hợp ngân sách trình ký
|
||||
</div>
|
||||
|
||||
{/* Edit mode */}
|
||||
{editing && canAdjust && (
|
||||
<div className="space-y-3 rounded border border-emerald-300 bg-emerald-50/30 p-3">
|
||||
{isApproverChoDuyet && (
|
||||
<div className="rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-800">
|
||||
ⓘ Bạn đang điều chỉnh ngân sách lúc phiếu đang duyệt — thay đổi sẽ được ghi vào lịch sử.
|
||||
</div>
|
||||
)}
|
||||
<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"
|
||||
{/* ===== Block A — NGÂN SÁCH (gói thầu) ===== */}
|
||||
<BudgetBlockHeader>A. Ngân sách (gói thầu)</BudgetBlockHeader>
|
||||
|
||||
{/* Dòng 1 — Ngân sách (full gói thầu) — brand đậm */}
|
||||
<BudgetRow
|
||||
tone="brand"
|
||||
label={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
Ngân sách (full gói thầu)
|
||||
{bs.fullIsEstimate && (
|
||||
<span className="rounded bg-white/20 px-1.5 py-0.5 text-[9px] font-semibold uppercase">dự trù PRO</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
value={fmtVnd(full)}
|
||||
/>
|
||||
|
||||
{/* Dòng 2 — Ban hành lần đầu (CCM editable) */}
|
||||
<BudgetRow
|
||||
label="Ngân sách Ban hành lần đầu"
|
||||
value={
|
||||
bs.canEditCcm ? (
|
||||
<VndInlineEdit
|
||||
initial={bs.initialAmount}
|
||||
saving={ccmMut.isPending}
|
||||
label="Ngân sách ban hành lần đầu"
|
||||
onSave={v => ccmMut.mutate({ initialAmount: v, adjustmentAmount: bs.adjustmentAmount })}
|
||||
/>
|
||||
Nhập tay (không link Budget)
|
||||
</label>
|
||||
{!manualMode ? (
|
||||
<div>
|
||||
<Label className="text-[11px]">Chọn Budget từ danh sách</Label>
|
||||
<Select value={budgetId} onChange={e => setBudgetId(e.target.value)} className="text-sm">
|
||||
<option value="">— (huỷ link)</option>
|
||||
{eligibleBudgets.data?.map(b => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-xs">
|
||||
<Label className="text-[11px]">Số tiền (VND)</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formatVndInput(manualAmount)}
|
||||
onChange={e => setManualAmount(parseVnd(e.target.value))}
|
||||
placeholder="0"
|
||||
className="pr-10 font-mono text-right text-sm"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||||
) : bs.initialAmount != null ? fmtVnd(bs.initialAmount) : <span className="text-slate-400">—</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Dòng 3 — Hiệu chỉnh V0 tăng giảm (CCM editable, cho phép âm) */}
|
||||
<BudgetRow
|
||||
label="Ngân sách V0 / hiệu chỉnh tăng giảm"
|
||||
value={
|
||||
bs.canEditCcm ? (
|
||||
<VndInlineEdit
|
||||
initial={bs.adjustmentAmount}
|
||||
allowNegative
|
||||
saving={ccmMut.isPending}
|
||||
label="Ngân sách hiệu chỉnh tăng giảm"
|
||||
onSave={v => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: v })}
|
||||
/>
|
||||
) : bs.adjustmentAmount != null ? (
|
||||
<span className={cn(bs.adjustmentAmount < 0 && 'text-red-600')}>{fmtVndSigned(bs.adjustmentAmount)}</span>
|
||||
) : <span className="text-slate-400">—</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Dòng 4 — Dự trù PRO (PRO editable) */}
|
||||
<BudgetRow
|
||||
label="Dự trù PRO"
|
||||
value={
|
||||
bs.canEditPro ? (
|
||||
<VndInlineEdit
|
||||
initial={bs.proEstimateAmount}
|
||||
saving={proMut.isPending}
|
||||
label="Dự trù PRO"
|
||||
onSave={v => proMut.mutate({ proEstimateAmount: v, proNote: proNoteText || null })}
|
||||
/>
|
||||
) : bs.proEstimateAmount != null ? fmtVnd(bs.proEstimateAmount) : <span className="text-slate-400">—</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Dòng 5 — Ghi chú từ PRO (PRO editable — Textarea) */}
|
||||
<div className="flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
|
||||
<div className="min-w-0 flex-1 text-slate-700">Ghi chú từ PRO</div>
|
||||
<div className="w-72 shrink-0">
|
||||
{bs.canEditPro ? (
|
||||
<div className="space-y-1">
|
||||
<textarea
|
||||
value={proNoteText}
|
||||
onChange={e => setProNoteText(e.target.value)}
|
||||
placeholder="Ghi chú dự trù…"
|
||||
rows={2}
|
||||
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => proMut.mutate({ proEstimateAmount: bs.proEstimateAmount, proNote: proNoteText || null })}
|
||||
disabled={proNoteText === (bs.proNote ?? '') || proMut.isPending}
|
||||
className="h-6 px-2 text-[11px]"
|
||||
>
|
||||
{proMut.isPending ? '…' : 'Lưu ghi chú'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap text-right text-[12px] text-slate-600">
|
||||
{bs.proNote || <span className="text-slate-400">—</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setEditing(false)}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => adjustMut.mutate()}
|
||||
disabled={adjustMut.isPending}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
{adjustMut.isPending ? 'Đang lưu…' : 'Lưu điều chỉnh'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* History defer S22+5 — UAT user xem Panel 3 "Lịch sử thay đổi" */}
|
||||
{/* ===== Block B — THỰC HIỆN ===== */}
|
||||
<BudgetBlockHeader>B. Thực hiện</BudgetBlockHeader>
|
||||
|
||||
{/* 1 — Ngân sách trình duyệt trước */}
|
||||
<BudgetRow
|
||||
label="1. Ngân sách trình duyệt trước"
|
||||
value={bs.previousSubmittedCount === 0
|
||||
? <span className="font-sans text-slate-400">Chưa chọn</span>
|
||||
: fmtVnd(row1)}
|
||||
/>
|
||||
|
||||
{/* 2 — Kỳ trước đã chọn thầu */}
|
||||
<BudgetRow
|
||||
label="2. Kỳ trước đã chọn thầu"
|
||||
value={bs.previousSelectedCount === 0
|
||||
? <span className="font-sans text-slate-400">Chưa chọn</span>
|
||||
: fmtVnd(row2)}
|
||||
/>
|
||||
|
||||
{/* 3 — Ngân sách - kỳ này (drafter editable) + % /full */}
|
||||
<BudgetRow
|
||||
label="3. Ngân sách - kỳ này"
|
||||
value={
|
||||
drafterEditable ? (
|
||||
<VndInlineEdit
|
||||
initial={ev.budgetPeriodAmount}
|
||||
saving={adjustMut.isPending}
|
||||
label="Ngân sách kỳ này"
|
||||
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
|
||||
/>
|
||||
) : ev.budgetPeriodAmount != null ? fmtVnd(row3) : <span className="text-slate-400">—</span>
|
||||
}
|
||||
third={fmtPct(row3, full) ?? undefined}
|
||||
/>
|
||||
|
||||
{/* 4 — Đề xuất kỳ này (block con bg-blue-soft): NCC + giá trị + so sánh */}
|
||||
<BudgetRow
|
||||
tone="blue-soft"
|
||||
label="4. Đề xuất kỳ này — Tên thầu phụ / NCC"
|
||||
value={
|
||||
<span className="font-sans text-slate-700">
|
||||
{ev.selectedSupplierName ?? <span className="text-slate-400">— (chưa chọn)</span>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<BudgetRow
|
||||
tone="blue-soft"
|
||||
label="Giá trị kỳ này"
|
||||
value={
|
||||
proposalOver ? (
|
||||
<span className="inline-block rounded bg-[#C00000] px-2 py-0.5 font-bold text-white">{fmtVnd(row4)}</span>
|
||||
) : fmtVnd(row4)
|
||||
}
|
||||
/>
|
||||
<BudgetRow
|
||||
tone="blue-soft"
|
||||
label="So sánh với ngân sách kỳ này"
|
||||
sub="= 3 − 4"
|
||||
value={<span className={cn(cmpPeriod < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(cmpPeriod)}</span>}
|
||||
third={fmtPct(cmpPeriod, row3) ?? undefined}
|
||||
danger={cmpPeriod < 0}
|
||||
/>
|
||||
|
||||
{/* 5 — Lũy kế ngân sách đã sử dụng (= 1 + 3) */}
|
||||
<BudgetRow
|
||||
label="5. Lũy kế ngân sách đã sử dụng"
|
||||
sub="= 1 + 3"
|
||||
value={fmtVnd(row5)}
|
||||
/>
|
||||
|
||||
{/* 6 — Lũy kế thực hiện (= 2 + 4) + So với NS (5 − 6) */}
|
||||
<BudgetRow
|
||||
label="6. Lũy kế thực hiện"
|
||||
sub="= 2 + 4"
|
||||
value={fmtVnd(row6)}
|
||||
/>
|
||||
<BudgetRow
|
||||
label="So với NS"
|
||||
sub="= 5 − 6"
|
||||
value={<span className={cn(cmp56 < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(cmp56)}</span>}
|
||||
third={fmtPct(cmp56, row5) ?? undefined}
|
||||
danger={cmp56 < 0}
|
||||
/>
|
||||
|
||||
{/* 7 — Ngân sách còn lại (= full − 5) + % /full */}
|
||||
<BudgetRow
|
||||
label="7. Ngân sách còn lại"
|
||||
sub="= Ngân sách full − 5"
|
||||
value={fmtVnd(row7)}
|
||||
third={fmtPct(row7, full) ?? undefined}
|
||||
/>
|
||||
|
||||
{/* 8 — Giá trị thực hiện dự kiến còn lại (drafter editable) — đỏ nhạt khi > row7 */}
|
||||
<div className={cn(
|
||||
'flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]',
|
||||
remainingOver && 'bg-red-50',
|
||||
)}>
|
||||
<div className={cn('min-w-0 flex-1', remainingOver ? 'text-red-700' : 'text-slate-700')}>
|
||||
8. Giá trị thực hiện dự kiến còn lại
|
||||
<div className="text-[10px] text-slate-400">mặc định = 7 nếu chưa nhập</div>
|
||||
</div>
|
||||
<div className="w-48 shrink-0 text-right">
|
||||
{drafterEditable ? (
|
||||
<VndInlineEdit
|
||||
initial={ev.expectedRemainingAmount}
|
||||
saving={adjustMut.isPending}
|
||||
label="Giá trị thực hiện dự kiến còn lại"
|
||||
onSave={v => adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })}
|
||||
/>
|
||||
) : (
|
||||
<span className={cn('font-mono tabular-nums', remainingOver ? 'font-semibold text-red-700' : 'text-slate-900')}>
|
||||
{fmtVnd(row8)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-24 shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* 9 — Giá trị tổng thực hiện dự kiến (= 4 + 8) — brand đậm */}
|
||||
<BudgetRow
|
||||
tone="brand"
|
||||
label="9. Giá trị tổng thực hiện dự kiến"
|
||||
sub="= 4 + 8"
|
||||
value={fmtVnd(row9)}
|
||||
/>
|
||||
<BudgetRow
|
||||
tone="brand-soft"
|
||||
label="So sánh với Ngân sách full"
|
||||
sub="= Ngân sách full − 9"
|
||||
value={<span className={cn(cmpFull < 0 && 'font-bold text-red-600')}>{fmtVndSigned(cmpFull)}</span>}
|
||||
third={fmtPct(cmpFull, full) ?? undefined}
|
||||
danger={cmpFull < 0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1197,7 +1305,9 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<NccSelectorRow ev={ev} readOnly={readOnly} />
|
||||
<BudgetFieldRow ev={ev} readOnly={readOnly} />
|
||||
{/* b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt). Thay BudgetFieldRow
|
||||
+ BudgetAdjustSection cũ (module Budget bỏ hẳn). */}
|
||||
<PeBudgetSummaryTable ev={ev} readOnly={readOnly} />
|
||||
<FormRow
|
||||
label="c. Giá chào thầu"
|
||||
value={
|
||||
@ -1620,21 +1730,9 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [editDetail, setEditDetail] = useState<PeDetailRow | null>(null)
|
||||
|
||||
// Budget comparison — fetch full Budget bundle nếu có link để so sánh per-row.
|
||||
const budgetBundle = useQuery({
|
||||
queryKey: ['budget-detail-for-pe', ev.budgetId],
|
||||
queryFn: async () => (await api.get<{ details: { groupCode: string; itemCode: string | null; thanhTien: number }[]; tongNganSach: number }>(
|
||||
`/budgets/${ev.budgetId}`)).data,
|
||||
enabled: !!ev.budgetId,
|
||||
})
|
||||
const budgetRowMap = (() => {
|
||||
const m = new Map<string, number>()
|
||||
budgetBundle.data?.details.forEach(d => {
|
||||
m.set(`${d.groupCode}|${d.itemCode ?? ''}`, d.thanhTien)
|
||||
})
|
||||
return m
|
||||
})()
|
||||
const showBudgetCol = !!ev.budgetId
|
||||
// S61 — Budget comparison per-row (cột "NS link" + Δ) XÓA: module Budget bỏ hẳn,
|
||||
// không còn link PE → Budget entity row-by-row. So sánh ngân sách giờ ở bảng
|
||||
// TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (Section 2 — PeBudgetSummaryTable).
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -1658,8 +1756,6 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
|
||||
detail={d}
|
||||
ev={ev}
|
||||
readOnly={readOnly}
|
||||
budgetRowMap={budgetRowMap}
|
||||
showBudgetCol={showBudgetCol}
|
||||
onEditDetail={() => setEditDetail(d)}
|
||||
/>
|
||||
))}
|
||||
@ -1675,13 +1771,11 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
|
||||
// Card 1 hạng mục — tầng 1 header + tầng 2 NCC grid inline expand.
|
||||
// Mặc định mở (expanded=true) vì user demo chỉ 1 hạng mục, đỡ click.
|
||||
function HangMucCard({
|
||||
detail, ev, readOnly, budgetRowMap, showBudgetCol, onEditDetail,
|
||||
detail, ev, readOnly, onEditDetail,
|
||||
}: {
|
||||
detail: PeDetailRow
|
||||
ev: PeDetailBundle
|
||||
readOnly: boolean
|
||||
budgetRowMap: Map<string, number>
|
||||
showBudgetCol: boolean
|
||||
onEditDetail: () => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
@ -1707,9 +1801,6 @@ function HangMucCard({
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const bgValue = budgetRowMap.get(`${detail.groupCode}|${detail.itemCode ?? ''}`)
|
||||
const delta = bgValue != null ? detail.thanhTienNganSach - bgValue : null
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
{/* Header row — hạng mục info + actions. Session 20 turn 11: flex-wrap +
|
||||
@ -1741,20 +1832,9 @@ function HangMucCard({
|
||||
<span className="ml-1 text-xs font-normal text-slate-500">đ</span>
|
||||
</div>
|
||||
</div>
|
||||
{showBudgetCol && bgValue != null && (
|
||||
<div className="border-l border-slate-200 pl-3">
|
||||
<div className="text-[10px] uppercase text-slate-400">NS link</div>
|
||||
<div className="font-mono text-[11px]">{fmtMoney(bgValue)}</div>
|
||||
<div className={cn(
|
||||
'font-mono text-[10px]',
|
||||
delta! > 0 && 'text-red-600',
|
||||
delta! < 0 && 'text-emerald-600',
|
||||
delta === 0 && 'text-slate-500',
|
||||
)}>
|
||||
Δ {delta! > 0 ? '+' : ''}{fmtMoney(delta!)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* [S61 Mig 50] Cột "NS link" so sánh BudgetDetails cũ ĐÃ GỠ — module
|
||||
Budget cũ xóa hẳn; so sánh ngân sách giờ ở bảng "Tổng hợp ngân sách
|
||||
trình ký" cấp phiếu (PeBudgetSummaryTable). */}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="flex flex-shrink-0 gap-1">
|
||||
|
||||
@ -18,8 +18,7 @@ import {
|
||||
PurchaseEvaluationTypeLabel,
|
||||
type PeDetailBundle,
|
||||
} from '@/types/purchaseEvaluation'
|
||||
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
|
||||
import type { Paged, Project } from '@/types/master'
|
||||
import type { Project } from '@/types/master'
|
||||
|
||||
// VND format helpers (mirror PeDetailTabs.tsx — session 20)
|
||||
const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0
|
||||
@ -81,29 +80,13 @@ export function PeHeaderForm({
|
||||
diaDiem: '',
|
||||
moTa: '',
|
||||
paymentTerms: '',
|
||||
budgetId: '' as string,
|
||||
// Mig 17 — manual budget fallback (toggle "Nhập tay" thay vì link)
|
||||
budgetManual: false,
|
||||
budgetManualName: '',
|
||||
budgetManualAmount: 0,
|
||||
})
|
||||
|
||||
const eligibleBudgets = useQuery({
|
||||
queryKey: ['eligible-budgets', form.projectId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<Paged<BudgetListItem>>('/budgets', {
|
||||
params: { pageSize: 100, projectId: form.projectId, phase: BudgetPhase.DaDuyet },
|
||||
})
|
||||
return res.data.items
|
||||
},
|
||||
enabled: !!form.projectId,
|
||||
// [S61 Mig 50] "Ngân sách - kỳ này" — thay budgetId/budgetManual* cũ (module
|
||||
// Budget xóa hẳn; bảng Tổng hợp ngân sách gói thầu nằm ở PeDetailTabs).
|
||||
budgetPeriodAmount: 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (existing.data) {
|
||||
// S59: manual-mode detect theo CẢ amount (phiếu mới name=null sau khi bỏ ô Tên).
|
||||
const hasManual = existing.data.budgetManualAmount !== null || existing.data.budgetManualName !== null
|
||||
|| existing.data.budgetManualAmount !== null
|
||||
setForm({
|
||||
type: existing.data.type,
|
||||
tenGoiThau: existing.data.tenGoiThau,
|
||||
@ -112,27 +95,16 @@ export function PeHeaderForm({
|
||||
diaDiem: existing.data.diaDiem ?? '',
|
||||
moTa: existing.data.moTa ?? '',
|
||||
paymentTerms: existing.data.paymentTerms ?? '',
|
||||
budgetId: existing.data.budgetId ?? '',
|
||||
// Auto-toggle manual mode khi load existing có manual data hoặc không có link
|
||||
budgetManual: hasManual && !existing.data.budgetId,
|
||||
budgetManualName: existing.data.budgetManualName ?? '',
|
||||
budgetManualAmount: existing.data.budgetManualAmount ?? 0,
|
||||
budgetPeriodAmount: existing.data.budgetPeriodAmount ?? 0,
|
||||
})
|
||||
}
|
||||
}, [existing.data])
|
||||
|
||||
// Manual mode: clear budgetId, gửi manualName/Amount. Link mode: clear manual.
|
||||
const payloadBudgetFields = form.budgetManual
|
||||
? {
|
||||
budgetId: null,
|
||||
budgetManualName: null, // S59 anh chốt bỏ "Tên ngân sách" — manual chỉ còn Số tiền
|
||||
budgetManualAmount: form.budgetManualAmount > 0 ? form.budgetManualAmount : null,
|
||||
}
|
||||
: {
|
||||
budgetId: form.budgetId || null,
|
||||
budgetManualName: null,
|
||||
budgetManualAmount: null,
|
||||
}
|
||||
// [S61] PUT UpdateDraft null-safe: budgetPeriodAmount null = GIỮ giá trị cũ
|
||||
// BE-side; gửi số > 0 mới set. (Clear hẳn → dùng bảng Tổng hợp/budget-adjust.)
|
||||
const payloadBudgetFields = {
|
||||
budgetPeriodAmount: form.budgetPeriodAmount > 0 ? form.budgetPeriodAmount : null,
|
||||
}
|
||||
|
||||
const mut = useMutation({
|
||||
mutationFn: async () => {
|
||||
@ -239,58 +211,23 @@ export function PeHeaderForm({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1.5 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={form.budgetManual}
|
||||
onChange={e => setForm({ ...form, budgetManual: e.target.checked })}
|
||||
className="h-3.5 w-3.5 rounded border-slate-300"
|
||||
/>
|
||||
Nhập tay (không link)
|
||||
</label>
|
||||
{/* [S61 Mig 50] Ô đơn "Ngân sách kỳ này" — thay picker Budget cũ + toggle
|
||||
nhập tay. Số phân bổ cho RIÊNG phiếu này (row 3 bảng Tổng hợp). */}
|
||||
<Label>Ngân sách kỳ này</Label>
|
||||
<div className="relative max-w-xs">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formatVndInput(form.budgetPeriodAmount)}
|
||||
onChange={e => setForm({ ...form, budgetPeriodAmount: parseVnd(e.target.value) })}
|
||||
placeholder="0"
|
||||
className="pr-10 font-mono text-right"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||||
</div>
|
||||
{!form.budgetManual ? (
|
||||
<>
|
||||
<Select
|
||||
value={form.budgetId}
|
||||
disabled={!form.projectId}
|
||||
onChange={e => setForm({ ...form, budgetId: 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="mt-1 text-[11px] text-slate-500">
|
||||
{!form.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>
|
||||
<Label className="text-[11px]">Số tiền</Label>
|
||||
<div className="relative max-w-xs">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formatVndInput(form.budgetManualAmount)}
|
||||
onChange={e => setForm({ ...form, budgetManualAmount: parseVnd(e.target.value) })}
|
||||
placeholder="0"
|
||||
className="pr-10 font-mono text-right"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-slate-500">VND — nhập số, tự format dấu chấm ngàn (vd 1.000.000)</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
Số phân bổ cho riêng phiếu này — bắt buộc trước khi gửi duyệt. Ngân sách full gói thầu xem ở bảng "Tổng hợp ngân sách trình ký" trong phiếu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@ -18,8 +18,7 @@ import { Select } from '@/components/ui/Select'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { PurchaseEvaluationTypeLabel } from '@/types/purchaseEvaluation'
|
||||
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
|
||||
import type { Paged, Project } from '@/types/master'
|
||||
import type { Project } from '@/types/master'
|
||||
|
||||
// VND format helpers (mirror PeDetailTabs.tsx — session 20)
|
||||
const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0
|
||||
@ -59,11 +58,9 @@ export function PeWorkspaceCreateView({
|
||||
diaDiem: '',
|
||||
moTa: '',
|
||||
paymentTerms: '',
|
||||
budgetId: '',
|
||||
// Mig 17 — manual budget fallback
|
||||
budgetManual: false,
|
||||
budgetManualName: '',
|
||||
budgetManualAmount: 0,
|
||||
// [S61 Mig 50] "Ngân sách - kỳ này" — thay budgetId/budgetManual* (module
|
||||
// Budget cũ xóa hẳn; bảng Tổng hợp ngân sách gói thầu ở PeDetailTabs).
|
||||
budgetPeriodAmount: 0,
|
||||
// Mig 23 — Pin quy trình duyệt V2 (User tự chọn lúc tạo)
|
||||
approvalWorkflowId: '',
|
||||
})
|
||||
@ -104,20 +101,9 @@ export function PeWorkspaceCreateView({
|
||||
},
|
||||
})
|
||||
|
||||
const eligibleBudgets = useQuery({
|
||||
queryKey: ['eligible-budgets', form.projectId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<Paged<BudgetListItem>>('/budgets', {
|
||||
params: { pageSize: 100, projectId: form.projectId, phase: BudgetPhase.DaDuyet },
|
||||
})
|
||||
return res.data.items
|
||||
},
|
||||
enabled: !!form.projectId,
|
||||
})
|
||||
|
||||
const budgetPayload = form.budgetManual
|
||||
? { budgetId: null, budgetManualName: form.budgetManualName || null, budgetManualAmount: form.budgetManualAmount > 0 ? form.budgetManualAmount : null }
|
||||
: { budgetId: form.budgetId || null, budgetManualName: null, budgetManualAmount: null }
|
||||
const budgetPayload = {
|
||||
budgetPeriodAmount: form.budgetPeriodAmount > 0 ? form.budgetPeriodAmount : null,
|
||||
}
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async () => {
|
||||
@ -224,7 +210,7 @@ export function PeWorkspaceCreateView({
|
||||
const loc = p?.location ?? ''
|
||||
setForm(f => {
|
||||
const untouched = !f.diaDiem || f.diaDiem === lastAutoLoc.current
|
||||
return { ...f, projectId: id, budgetId: '', diaDiem: untouched ? loc : f.diaDiem }
|
||||
return { ...f, projectId: id, diaDiem: untouched ? loc : f.diaDiem }
|
||||
})
|
||||
lastAutoLoc.current = loc
|
||||
}}
|
||||
@ -258,55 +244,26 @@ export function PeWorkspaceCreateView({
|
||||
value={<span className="text-slate-400">— (sau khi thêm NCC tham gia + chốt winner)</span>}
|
||||
/>
|
||||
|
||||
{/* b. Ngân sách — editable inline (Mig 17 toggle pattern) */}
|
||||
{/* [S61 Mig 50] b. Ngân sách kỳ này — ô đơn thay picker Budget cũ +
|
||||
toggle nhập tay (module Budget xóa hẳn). Số phân bổ cho RIÊNG
|
||||
phiếu này (row 3 bảng "Tổng hợp ngân sách trình ký"). */}
|
||||
<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={form.budgetManual}
|
||||
onChange={e => setForm({ ...form, budgetManual: e.target.checked })}
|
||||
className="h-3.5 w-3.5 rounded border-slate-300"
|
||||
<span className="w-44 shrink-0 pt-1.5 text-[12px] text-slate-500">b. Ngân sách kỳ này</span>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="relative max-w-xs">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formatVndInput(form.budgetPeriodAmount)}
|
||||
onChange={e => setForm({ ...form, budgetPeriodAmount: parseVnd(e.target.value) })}
|
||||
placeholder="0"
|
||||
className="pr-10 font-mono text-right text-sm"
|
||||
/>
|
||||
Nhập tay (không link)
|
||||
</label>
|
||||
{!form.budgetManual ? (
|
||||
<>
|
||||
<Select
|
||||
value={form.budgetId}
|
||||
disabled={!form.projectId}
|
||||
onChange={e => setForm({ ...form, budgetId: 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>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{!form.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="relative max-w-xs">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formatVndInput(form.budgetManualAmount)}
|
||||
onChange={e => setForm({ ...form, budgetManualAmount: parseVnd(e.target.value) })}
|
||||
placeholder="0"
|
||||
className="pr-10 font-mono text-right text-sm"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Bắt buộc trước khi gửi duyệt. Ngân sách full gói thầu xem ở bảng “Tổng hợp ngân sách trình ký” sau khi tạo phiếu.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -26,11 +26,8 @@ export const MenuKeys = {
|
||||
ApprovalWorkflowsV2: 'ApprovalWorkflowsV2',
|
||||
AwV2_DuyetNcc: 'AwV2_DuyetNcc',
|
||||
AwV2_DuyetNccPhuongAn: 'AwV2_DuyetNccPhuongAn',
|
||||
// Module Ngân sách (Phase 7)
|
||||
Budgets: 'Budgets',
|
||||
Bg_List: 'Bg_List',
|
||||
Bg_Create: 'Bg_Create',
|
||||
Bg_Pending: 'Bg_Pending',
|
||||
// [S61 Mig 50] Module Ngân sách cũ (Budgets + Bg_*) XÓA — thay bằng bảng
|
||||
// "Tổng hợp ngân sách trình ký" per (Dự án, Hạng mục) trong phiếu PE.
|
||||
// Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26)
|
||||
Hrm: 'Hrm',
|
||||
HrmHoSo: 'Hrm_HoSo',
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -1,168 +0,0 @@
|
||||
// Types cho module Ngân sách (Budget) — mirror BE Domain.Budgets.
|
||||
// Workflow simple 3-step (BudgetPolicy.Default hardcoded):
|
||||
// DangSoanThao → ChoCCM → ChoCEO → DaDuyet (+ TuChoi từ DangSoanThao).
|
||||
|
||||
export const BudgetPhase = {
|
||||
DangSoanThao: 1,
|
||||
ChoCCM: 2,
|
||||
ChoCEO: 3,
|
||||
DaDuyet: 4,
|
||||
TraLai: 98,
|
||||
TuChoi: 99,
|
||||
} as const
|
||||
export type BudgetPhase = typeof BudgetPhase[keyof typeof BudgetPhase]
|
||||
|
||||
export const BudgetPhaseLabel: Record<number, string> = {
|
||||
1: 'Nháp',
|
||||
2: 'Chờ CCM',
|
||||
3: 'Chờ CEO',
|
||||
4: 'Đã duyệt',
|
||||
98: 'Trả lại',
|
||||
99: 'Từ chối',
|
||||
}
|
||||
|
||||
export const BudgetPhaseColor: Record<number, string> = {
|
||||
1: 'bg-slate-100 text-slate-700',
|
||||
2: 'bg-indigo-100 text-indigo-700',
|
||||
3: 'bg-pink-100 text-pink-700',
|
||||
4: 'bg-emerald-100 text-emerald-700',
|
||||
98: 'bg-yellow-100 text-yellow-800',
|
||||
99: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
// Mirror BE BudgetEntityType enum
|
||||
export const BudgetEntityType = {
|
||||
Header: 1,
|
||||
Detail: 2,
|
||||
Workflow: 3,
|
||||
} as const
|
||||
|
||||
// Mirror BE ChangelogAction enum (reuse từ Contracts.ChangelogAction)
|
||||
export const ChangelogAction = {
|
||||
Insert: 1,
|
||||
Update: 2,
|
||||
Delete: 3,
|
||||
Transition: 4,
|
||||
} as const
|
||||
|
||||
// Mirror BE ApprovalDecision enum (reuse từ Contracts.ApprovalDecision)
|
||||
export const ApprovalDecision = {
|
||||
Approve: 1,
|
||||
Reject: 2,
|
||||
AutoApprove: 3,
|
||||
} as const
|
||||
|
||||
// 2-stage department approval (Migration 16) — Stage 1=Review NV, 2=Confirm TPB.
|
||||
export const ApprovalStage = {
|
||||
Review: 1,
|
||||
Confirm: 2,
|
||||
} as const
|
||||
export type ApprovalStage = typeof ApprovalStage[keyof typeof ApprovalStage]
|
||||
|
||||
export type BudgetDepartmentApproval = {
|
||||
id: string
|
||||
phaseAtApproval: number
|
||||
departmentId: string
|
||||
departmentName: string | null
|
||||
stage: number // 1=Review, 2=Confirm
|
||||
approverUserId: string
|
||||
approverName: string | null
|
||||
approverRoleSnapshot: string | null // "TPB" | "NV" | "NV(bypass)"
|
||||
comment: string | null
|
||||
approvedAt: string
|
||||
isBypassed: boolean
|
||||
}
|
||||
|
||||
export type BudgetListItem = {
|
||||
id: string
|
||||
maNganSach: string | null
|
||||
tenNganSach: string
|
||||
namNganSach: number
|
||||
phase: number
|
||||
projectId: string
|
||||
projectName: string
|
||||
tongNganSach: number
|
||||
slaDeadline: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type BudgetDetailRow = {
|
||||
id: string
|
||||
groupCode: string
|
||||
groupName: string
|
||||
itemCode: string | null
|
||||
noiDung: string
|
||||
donViTinh: string | null
|
||||
khoiLuong: number
|
||||
donGia: number
|
||||
thanhTien: number
|
||||
order: number
|
||||
ghiChu: string | null
|
||||
}
|
||||
|
||||
export type BudgetApproval = {
|
||||
id: string
|
||||
fromPhase: number
|
||||
toPhase: number
|
||||
approverUserId: string | null
|
||||
approverName: string | null
|
||||
decision: number
|
||||
comment: string | null
|
||||
approvedAt: string
|
||||
}
|
||||
|
||||
export type BudgetWorkflowSummary = {
|
||||
policyName: string
|
||||
policyDescription: string
|
||||
activePhases: number[]
|
||||
nextPhases: number[]
|
||||
}
|
||||
|
||||
export type BudgetChangelog = {
|
||||
id: string
|
||||
entityType: number
|
||||
entityId: string | null
|
||||
action: number
|
||||
phaseAtChange: number | null
|
||||
userId: string | null
|
||||
userName: string | null
|
||||
summary: string | null
|
||||
fieldChangesJson: string | null
|
||||
contextNote: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type BudgetDetailBundle = {
|
||||
id: string
|
||||
maNganSach: string | null
|
||||
tenNganSach: string
|
||||
description: string | null
|
||||
namNganSach: number
|
||||
phase: number
|
||||
projectId: string
|
||||
projectName: string
|
||||
departmentId: string | null
|
||||
departmentName: string | null
|
||||
drafterUserId: string | null
|
||||
drafterName: string | null
|
||||
tongNganSach: number
|
||||
slaDeadline: string | null
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
details: BudgetDetailRow[]
|
||||
approvals: BudgetApproval[]
|
||||
workflow: BudgetWorkflowSummary
|
||||
}
|
||||
|
||||
// Body shape POST/PUT detail — mirror BudgetDetailBody record BE.
|
||||
export type BudgetDetailBody = {
|
||||
groupCode: string
|
||||
groupName: string
|
||||
itemCode: string | null
|
||||
noiDung: string
|
||||
donViTinh: string | null
|
||||
khoiLuong: number
|
||||
donGia: number
|
||||
thanhTien: number
|
||||
ghiChu: string | null
|
||||
}
|
||||
@ -130,16 +130,6 @@ export type WorkflowSummary = {
|
||||
nextPhases: number[]
|
||||
}
|
||||
|
||||
// Snapshot ngân sách link cho Contract (cùng shape với BudgetSummaryDto BE).
|
||||
export type ContractBudgetSummary = {
|
||||
id: string
|
||||
maNganSach: string | null
|
||||
tenNganSach: string
|
||||
namNganSach: number
|
||||
phase: number
|
||||
tongNganSach: number
|
||||
}
|
||||
|
||||
export type ContractDetail = {
|
||||
id: string
|
||||
maHopDong: string | null
|
||||
@ -162,9 +152,8 @@ export type ContractDetail = {
|
||||
draftData: string | null
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
budgetId: string | null
|
||||
budget: ContractBudgetSummary | null
|
||||
// Mig 17 — manual budget fallback khi không link Budget entity.
|
||||
// [S61 Mig 50] budgetId/budget link DROP (module Budget cũ xóa) — HĐ GIỮ
|
||||
// ngân sách nhập tay.
|
||||
budgetManualName: string | null
|
||||
budgetManualAmount: number | null
|
||||
approvals: ContractApproval[]
|
||||
|
||||
@ -130,6 +130,9 @@ export type PeListItem = {
|
||||
drafterName: string | null
|
||||
departmentId: string | null
|
||||
departmentName: string | null
|
||||
// S61 — 2 cột ngân sách mới (list DTO mirror detail; chưa render ở list UI)
|
||||
budgetPeriodAmount: number | null
|
||||
expectedRemainingAmount: number | null
|
||||
}
|
||||
|
||||
export type PeSupplier = {
|
||||
@ -272,14 +275,28 @@ export type PeChangelog = {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// Snapshot ngân sách link (compact — cùng shape BudgetSummaryDto BE).
|
||||
export type BudgetSummary = {
|
||||
id: string
|
||||
maNganSach: string | null
|
||||
tenNganSach: string
|
||||
namNganSach: number
|
||||
phase: number
|
||||
tongNganSach: number
|
||||
// S61 — Ngân sách gói thầu (PeWorkItemBudgets: 1 record/cặp Dự án × Hạng mục).
|
||||
// BE compute + trả kèm PE detail GET. fullAmount = (initial??0)+(adjustment??0);
|
||||
// cả 2 null → fallback proEstimate??0 + fullIsEstimate=true (badge "dự trù PRO").
|
||||
// canEditPro = role Procurement|Admin · canEditCcm = CostControl|Admin — BE-computed
|
||||
// capability flag (pattern S54 — FE KHÔNG đoán role). budgetId=null khi phiếu chưa
|
||||
// gắn Hạng mục công việc (phiếu cũ) → totals=0 + FE banner nhắc gắn hạng mục.
|
||||
export type PeBudgetSummary = {
|
||||
budgetId: string | null
|
||||
proEstimateAmount: number | null
|
||||
proNote: string | null
|
||||
initialAmount: number | null
|
||||
adjustmentAmount: number | null // CCM "NS V0/hiệu chỉnh" — cho phép ÂM
|
||||
fullAmount: number
|
||||
fullIsEstimate: boolean
|
||||
canEditPro: boolean
|
||||
canEditCcm: boolean
|
||||
// Lũy kế các phiếu cùng (ProjectId, WorkItemId), Id != this, CreatedAt < this:
|
||||
previousSubmittedTotal: number // SUM BudgetPeriodAmount WHERE Phase IN (ChoDuyet, DaDuyet)
|
||||
previousSubmittedCount: number
|
||||
previousSelectedTotal: number // SUM quote ThanhTien của SelectedSupplier WHERE Phase=DaDuyet
|
||||
previousSelectedCount: number
|
||||
currentProposalTotal: number // SUM ThanhTien quotes của SelectedSupplier phiếu NÀY (0 khi chưa chọn)
|
||||
}
|
||||
|
||||
// Mirror BE PeDepartmentKind enum
|
||||
@ -405,11 +422,10 @@ export type PeDetailBundle = {
|
||||
slaDeadline: string | null
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
budgetId: string | null
|
||||
budget: BudgetSummary | null
|
||||
// Mig 17 — manual budget fallback khi không link Budget entity. Cả 2 cùng null OK.
|
||||
budgetManualName: string | null
|
||||
budgetManualAmount: number | null
|
||||
// S61 — Ngân sách PE mới (module Budget cũ XÓA HẲN — budgetId/budget/budgetManual* DROP):
|
||||
budgetPeriodAmount: number | null // 'Ngân sách - kỳ này' (row 3 Excel) — drafter nhập
|
||||
expectedRemainingAmount: number | null // 'Giá trị thực hiện dự kiến còn lại' (row 8) — null = FE default NS còn lại
|
||||
budgetSummary: PeBudgetSummary | null // bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (BE compute)
|
||||
// Mig 23 — Pin schema mới ApprovalWorkflowsV2 (User chọn lúc create).
|
||||
approvalWorkflowId: string | null
|
||||
approvalWorkflowCode: string | null
|
||||
|
||||
Reference in New Issue
Block a user