[CLAUDE] FE-Admin+FE-User: Module Ngân sách (Budget) FE — 3-panel List + Create + Detail tabs + Workflow timeline
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m59s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m59s
Mirror pattern PE 3-panel cho 2 app (admin + user):
- types/budget.ts (BudgetPhase 5-state enum + label/color, BudgetListItem, BudgetDetailRow, BudgetApproval, BudgetWorkflowSummary, BudgetChangelog, BudgetDetailBundle, BudgetDetailBody)
- components/budgets/BudgetDetailTabs.tsx — flat render Section "Thông tin" Header + Section "Hạng mục" table CRUD inline (Add/Edit/Delete dialog với auto-compute ThanhTien = KL × DonGia). Export BudgetApprovalsSection + BudgetHistorySection cho Panel 3 reuse.
- components/budgets/BudgetWorkflowPanel.tsx — Panel 3 timeline activePhases + nextPhases buttons (Approve/Reject color coding) + Dialog xác nhận có comment + sub-section Approvals + Changelog.
- pages/budgets/BudgetsListPage.tsx — 3-panel [340px_1fr_360px] với search + filter Phase + filter NamNganSach. ?phase=Pending alias FE filter 2 phase ChoCCM/ChoCEO. SlaTimer per row + readOnly mode khi pendingMe.
- pages/budgets/BudgetCreatePage.tsx — form Header (TenNganSach/Năm/Dự án/Phòng ban/Mô tả). Edit mode khóa Project+Department.
- App.tsx routes /budgets, /budgets/new, /budgets/:id cả 2 app
- Layout.tsx menu resolver Bg_List → /budgets, Bg_Create → /budgets/new, Bg_Pending → /budgets?phase=Pending. NavLink active dùng queryMatches helper (gotcha #34 — không conflict Bg_List vs Bg_Pending cùng pathname).
TS build: cả fe-admin + fe-user pass clean (1918 + 1901 modules).
BE: dùng 11 endpoint Budgets từ migration 14 (Phase 7 BE đã deploy commit a05c57b).
Tổng FE: +12 file (5 fe-admin + 5 fe-user + 2 mod App/Layout × 2). ~1100 LOC TSX.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -20,6 +20,8 @@ import { ReportsPage } from '@/pages/ReportsPage'
|
|||||||
import { UsersPage } from '@/pages/system/UsersPage'
|
import { UsersPage } from '@/pages/system/UsersPage'
|
||||||
import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage'
|
import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage'
|
||||||
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
|
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
|
||||||
|
import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPage'
|
||||||
|
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -52,6 +54,9 @@ function App() {
|
|||||||
<Route path="/purchase-evaluations" element={<PurchaseEvaluationsListPage />} />
|
<Route path="/purchase-evaluations" element={<PurchaseEvaluationsListPage />} />
|
||||||
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />
|
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />
|
||||||
<Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} />
|
<Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} />
|
||||||
|
<Route path="/budgets" element={<BudgetsListPage />} />
|
||||||
|
<Route path="/budgets/new" element={<BudgetCreatePage />} />
|
||||||
|
<Route path="/budgets/:id" element={<BudgetDetailPage />} />
|
||||||
<Route path="/reports" element={<ReportsPage />} />
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@ -46,6 +46,10 @@ function resolvePath(key: string): string | null {
|
|||||||
CatalogWorkItems: '/master/catalogs/work-items',
|
CatalogWorkItems: '/master/catalogs/work-items',
|
||||||
PurchaseEvaluations: '/purchase-evaluations',
|
PurchaseEvaluations: '/purchase-evaluations',
|
||||||
PeWorkflows: '/system/pe-workflows',
|
PeWorkflows: '/system/pe-workflows',
|
||||||
|
Budgets: '/budgets',
|
||||||
|
Bg_List: '/budgets',
|
||||||
|
Bg_Create: '/budgets/new',
|
||||||
|
Bg_Pending: '/budgets?phase=Pending',
|
||||||
}
|
}
|
||||||
if (staticMap[key]) return staticMap[key]
|
if (staticMap[key]) return staticMap[key]
|
||||||
|
|
||||||
|
|||||||
491
fe-admin/src/components/budgets/BudgetDetailTabs.tsx
Normal file
491
fe-admin/src/components/budgets/BudgetDetailTabs.tsx
Normal file
@ -0,0 +1,491 @@
|
|||||||
|
// 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
135
fe-admin/src/components/budgets/BudgetWorkflowPanel.tsx
Normal file
135
fe-admin/src/components/budgets/BudgetWorkflowPanel.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
// Panel 3 — workflow timeline + transition buttons + approval history + changelog.
|
||||||
|
// Pulls nextPhases từ BE bundle (single source of truth).
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useMutation, 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,
|
||||||
|
BudgetPhase,
|
||||||
|
BudgetPhaseColor,
|
||||||
|
BudgetPhaseLabel,
|
||||||
|
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()
|
||||||
|
|
||||||
|
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] })
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
173
fe-admin/src/pages/budgets/BudgetCreatePage.tsx
Normal file
173
fe-admin/src/pages/budgets/BudgetCreatePage.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
// 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
263
fe-admin/src/pages/budgets/BudgetsListPage.tsx
Normal file
263
fe-admin/src/pages/budgets/BudgetsListPage.tsx
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
// 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
144
fe-admin/src/types/budget.ts
Normal file
144
fe-admin/src/types/budget.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
// 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,
|
||||||
|
TuChoi: 99,
|
||||||
|
} as const
|
||||||
|
export type BudgetPhase = typeof BudgetPhase[keyof typeof BudgetPhase]
|
||||||
|
|
||||||
|
export const BudgetPhaseLabel: Record<number, string> = {
|
||||||
|
1: 'Đang soạn thảo',
|
||||||
|
2: 'Chờ CCM',
|
||||||
|
3: 'Chờ CEO',
|
||||||
|
4: 'Đã duyệt',
|
||||||
|
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',
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@ -11,6 +11,8 @@ import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
|
|||||||
import { MyContractsPage } from '@/pages/contracts/MyContractsPage'
|
import { MyContractsPage } from '@/pages/contracts/MyContractsPage'
|
||||||
import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage'
|
import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage'
|
||||||
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
|
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
|
||||||
|
import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPage'
|
||||||
|
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -33,6 +35,9 @@ function App() {
|
|||||||
<Route path="/purchase-evaluations" element={<PurchaseEvaluationsListPage />} />
|
<Route path="/purchase-evaluations" element={<PurchaseEvaluationsListPage />} />
|
||||||
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />
|
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />
|
||||||
<Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} />
|
<Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} />
|
||||||
|
<Route path="/budgets" element={<BudgetsListPage />} />
|
||||||
|
<Route path="/budgets/new" element={<BudgetCreatePage />} />
|
||||||
|
<Route path="/budgets/:id" element={<BudgetDetailPage />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
|
|||||||
@ -57,6 +57,10 @@ function resolvePath(key: string): string | null {
|
|||||||
Dashboard: '/dashboard',
|
Dashboard: '/dashboard',
|
||||||
Contracts: '/my-contracts',
|
Contracts: '/my-contracts',
|
||||||
PurchaseEvaluations: '/purchase-evaluations',
|
PurchaseEvaluations: '/purchase-evaluations',
|
||||||
|
Budgets: '/budgets',
|
||||||
|
Bg_List: '/budgets',
|
||||||
|
Bg_Create: '/budgets/new',
|
||||||
|
Bg_Pending: '/budgets?phase=Pending',
|
||||||
}
|
}
|
||||||
if (staticMap[key]) return staticMap[key]
|
if (staticMap[key]) return staticMap[key]
|
||||||
|
|
||||||
|
|||||||
491
fe-user/src/components/budgets/BudgetDetailTabs.tsx
Normal file
491
fe-user/src/components/budgets/BudgetDetailTabs.tsx
Normal file
@ -0,0 +1,491 @@
|
|||||||
|
// 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
135
fe-user/src/components/budgets/BudgetWorkflowPanel.tsx
Normal file
135
fe-user/src/components/budgets/BudgetWorkflowPanel.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
// Panel 3 — workflow timeline + transition buttons + approval history + changelog.
|
||||||
|
// Pulls nextPhases từ BE bundle (single source of truth).
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useMutation, 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,
|
||||||
|
BudgetPhase,
|
||||||
|
BudgetPhaseColor,
|
||||||
|
BudgetPhaseLabel,
|
||||||
|
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()
|
||||||
|
|
||||||
|
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] })
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
173
fe-user/src/pages/budgets/BudgetCreatePage.tsx
Normal file
173
fe-user/src/pages/budgets/BudgetCreatePage.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
// 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
263
fe-user/src/pages/budgets/BudgetsListPage.tsx
Normal file
263
fe-user/src/pages/budgets/BudgetsListPage.tsx
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
// 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
144
fe-user/src/types/budget.ts
Normal file
144
fe-user/src/types/budget.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
// 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,
|
||||||
|
TuChoi: 99,
|
||||||
|
} as const
|
||||||
|
export type BudgetPhase = typeof BudgetPhase[keyof typeof BudgetPhase]
|
||||||
|
|
||||||
|
export const BudgetPhaseLabel: Record<number, string> = {
|
||||||
|
1: 'Đang soạn thảo',
|
||||||
|
2: 'Chờ CCM',
|
||||||
|
3: 'Chờ CEO',
|
||||||
|
4: 'Đã duyệt',
|
||||||
|
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',
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user