[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

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:
pqhuy1987
2026-04-28 16:25:22 +07:00
parent e0b4e7f096
commit df12fb19c8
14 changed files with 2430 additions and 0 deletions

View File

@ -46,6 +46,10 @@ function resolvePath(key: string): string | null {
CatalogWorkItems: '/master/catalogs/work-items',
PurchaseEvaluations: '/purchase-evaluations',
PeWorkflows: '/system/pe-workflows',
Budgets: '/budgets',
Bg_List: '/budgets',
Bg_Create: '/budgets/new',
Bg_Pending: '/budgets?phase=Pending',
}
if (staticMap[key]) return staticMap[key]

View 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"> 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"> / 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> 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> 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 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 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>
)
}

View 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
}