diff --git a/fe-admin/src/App.tsx b/fe-admin/src/App.tsx index 9ef83e9..338bddd 100644 --- a/fe-admin/src/App.tsx +++ b/fe-admin/src/App.tsx @@ -20,6 +20,8 @@ import { ReportsPage } from '@/pages/ReportsPage' import { UsersPage } from '@/pages/system/UsersPage' import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage' import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage' +import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPage' +import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage' function App() { return ( @@ -52,6 +54,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> 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 ( +
+
+
+
+

{budget.tenNganSach}

+ + {BudgetPhaseLabel[budget.phase]} + + {readOnly && ( + + chế độ duyệt + + )} +
+
+ {budget.maNganSach ?? '—'} + · + Năm {budget.namNganSach} + · + {budget.projectName} + {budget.drafterName && (<>·Soạn: {budget.drafterName})} +
+
+
+ {isDraft && !readOnly && ( + <> + + + + )} + +
+
+ +
+
+ +
+
+ +
+
+
+ ) +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ) +} + +// ===== Exports cho Panel 3 — Approvals history + Changelog ===== + +export function BudgetApprovalsSection({ budget }: { budget: BudgetDetailBundle }) { + return ( +
+

+ Lịch sử duyệt ({budget.approvals.length}) +

+ +
+ ) +} + +export function BudgetHistorySection({ budget }: { budget: BudgetDetailBundle }) { + return ( +
+

Lịch sử thay đổi

+ +
+ ) +} + +// ===== Section: Thông tin Header ===== +function InfoTab({ budget }: { budget: BudgetDetailBundle }) { + return ( +
+ + {budget.maNganSach ?? '—'}} /> + + + + + {fmtMoney(budget.tongNganSach)} đ} /> + {BudgetPhaseLabel[budget.phase]}} /> + {budget.description && ( +
+
Mô tả
+
{budget.description}
+
+ )} +
+ ) +} + +function Field({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +// ===== 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(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 ( +
+ {canMutate && ( +
+ +
+ )} + {budget.details.length === 0 ? ( +

+ {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.'} +

+ ) : ( +
+ + + + + + + + + + + {canMutate && } + + + + {budget.details.map((d, idx) => ( + + + + + + + + + {canMutate && ( + + )} + + ))} + + + + + + {canMutate && } + + +
#NhómMã / Nội dungĐVTKLĐơn giáThành tiền
{idx + 1} +
{d.groupCode}
+
{d.groupName}
+
+ {d.itemCode &&
{d.itemCode}
} +
{d.noiDung}
+ {d.ghiChu &&
{d.ghiChu}
} +
{d.donViTinh ?? '—'}{fmtMoney(d.khoiLuong)}{fmtMoney(d.donGia)} + {fmtMoney(d.thanhTien)} + +
+ + +
+
Tổng: + {fmtMoney(budget.tongNganSach)} +
+
+ )} + + {open && ( + setOpen(false)} + /> + )} + {editRow && ( + setEditRow(null)} + /> + )} +
+ ) +} + +// ===== 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({ + 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 ( + + + + } + > +
+
+
+ + setForm({ ...form, groupCode: e.target.value })} + placeholder="A.I" + /> +
+
+ + setForm({ ...form, groupName: e.target.value })} + placeholder="Vật tư xây dựng" + /> +
+
+
+ + setForm({ ...form, itemCode: e.target.value || null })} + /> +
+
+ + setForm({ ...form, noiDung: e.target.value })} + placeholder="Bê tông M250" + /> +
+
+
+ + setForm({ ...form, donViTinh: e.target.value || null })} + placeholder="m³" + /> +
+
+ + setQty(Number(e.target.value))} + /> +
+
+ + setPrice(Number(e.target.value))} + /> +
+
+ + setForm({ ...form, thanhTien: Number(e.target.value) })} + /> +
+
+
+ + setForm({ ...form, ghiChu: e.target.value || null })} + /> +
+
+
+ ) +} + +// ===== Sub: Approvals list ===== +function ApprovalsList({ budget }: { budget: BudgetDetailBundle }) { + if (budget.approvals.length === 0) + return

Chưa có bước duyệt nào.

+ return ( +
    + {budget.approvals.map(a => ( +
  1. +
    +
    + + {BudgetPhaseLabel[a.fromPhase]} + + + + {BudgetPhaseLabel[a.toPhase]} + +
    + {new Date(a.approvedAt).toLocaleString('vi-VN')} +
    +
    + {a.approverName ?? 'Hệ thống'}{a.comment && ` · ${a.comment}`} +
    +
  2. + ))} +
+ ) +} + +// ===== Sub: Changelog list ===== +function HistoryList({ budget }: { budget: BudgetDetailBundle }) { + const logs = useQuery({ + queryKey: ['budget-changelog', budget.id], + queryFn: async () => (await api.get(`/budgets/${budget.id}/changelogs`)).data, + }) + if (logs.isLoading) return

Đang tải…

+ if (!logs.data || logs.data.length === 0) + return

Chưa có lịch sử.

+ return ( +
    + {logs.data.map(l => ( +
  1. +
    + {l.userName ?? 'Hệ thống'} + {new Date(l.createdAt).toLocaleString('vi-VN')} +
    +
    {l.summary}
    + {l.contextNote &&
    {l.contextNote}
    } +
  2. + ))} +
+ ) +} diff --git a/fe-admin/src/components/budgets/BudgetWorkflowPanel.tsx b/fe-admin/src/components/budgets/BudgetWorkflowPanel.tsx new file mode 100644 index 0000000..095488a --- /dev/null +++ b/fe-admin/src/components/budgets/BudgetWorkflowPanel.tsx @@ -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(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 ( +
+
+

Quy trình

+

{budget.workflow.policyDescription}

+
+ +
    + {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 ( +
  1. +
    + {p} + {BudgetPhaseLabel[p]} + {isCurrent && ● hiện tại} + {isPast && } +
    +
  2. + ) + })} +
+ + {next.length > 0 && ( +
+ +
+ {next.map(p => ( + + ))} +
+
+ )} + + {target !== null && ( + setTarget(null)} + title={`Chuyển → ${BudgetPhaseLabel[target]}`} + footer={<> + + + } + > + +