[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

@ -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> 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>
)
}

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