[CLAUDE] Phase4: Report MVP + Docs Consolidation (rules, architecture, schema-diagram)
Backend Report: - Application/Reports/Dtos/DashboardStatsDto: 5 KPI + PhaseCount + SupplierCount + ProjectCount + MonthlyValue - Application/Reports/Queries/GetDashboardStats handler: total/active/overdue/published this month/totalValueActive + byPhase + top 5 NCC/du an + 12 thang monthly (fill zero khi thang empty) - Application/Reports/Services/IContractExcelExporter interface - Infrastructure/Reports/ContractExcelExporter: ClosedXML workbook 10 cot, header style bold+blue, number format #,##0, formula SUM, auto-fit, freeze header - Application/Reports/Commands/ExportContractsToExcelCommand: filter phase/supplier/project/date range - Api/Controllers/ReportsController: GET /reports/dashboard, GET /reports/contracts/export - DI register IContractExcelExporter (Scoped) Frontend fe-admin: - types/reports.ts: DashboardStats type - components/BarChart.tsx: generic horizontal bar chart — chi Tailwind, khong thu vien ngoai - pages/DashboardPage.tsx REWRITE: 5 KPI card (FileText/TrendingUp/AlertTriangle/CheckCircle2/Coins) + by-phase bar + monthly 12-month chart + top 5 NCC + top 5 du an + skeleton loader - pages/ReportsPage.tsx MOI: filter phase/fromDate/toDate → export Excel button - Route /reports vao App.tsx E2E verified: - GET /api/reports/dashboard → 200 voi day du KPI + monthly fill 12 thang - GET /api/reports/contracts/export → 200 xlsx 7229 bytes (Microsoft Excel 2007+) Docs consolidation (theo yeu cau user): - docs/rules.md MOI: 9 section coding conventions (ngon ngu UI/code/DB/docs, BE Clean Arch, CQRS+MediatR, Validation FluentValidation, Error handling, Async, Entity rules, DI, Package pinning, FE React/TS erasableSyntaxOnly, path alias, TanStack Query, Permission guard, Toast+error, DB convention, Git commit format, Docs structure, Testing, Security) - docs/architecture.md MOI: layered overview ASCII art, request lifecycle (1 POST/api/contracts qua 10 step), workflow state machine 9 phase, permission model, data flow sequence diagram 4 actor (Drafter/Manager/CCM/BOD/HRA), deployment architecture Phase 5, skill library, non-functional table - docs/database/schema-diagram.md MOI: full ERD 19 table mermaid + data flow diagram + vong doi 1 HD (create → 7 transition → gen ma → publish) + index strategy table + relationship cardinality + soft delete behavior + SQL queries (inbox/dashboard/gen ma) + migration history - docs/gotchas.md UPDATE: 17 → 26 pitfalls, them section "Claude Code harness quirks" (Edit File not read, DI build pass nhung runtime fail) + "Contract workflow" (ma HD gen 2 lan, BE-FE NEXT_PHASES sync, race condition) + "Permission matrix" (cache real-time, MenuKey typo) - docs/STATUS.md: Phase 4 MVP done, docs entry points section liet ke het, next Phase 5 Production - docs/HANDOFF.md: phase table them Phase 4 row, file tree update voi Reports, test points day du, git state commit 7 - docs/changelog/migration-todos.md: tick Phase 4 MVP items + them iteration 2 list - docs/changelog/sessions/2026-04-21-1430-phase4-report.md: session log voi thong so cumulative (BE 3100 LOC, 30 docs) - CLAUDE.md root: update Tai lieu quan trong section them rules.md, architecture.md, schema-diagram.md, .claude/skills (13 links now) Bug fix: - TS unused import ContractPhaseLabel trong DashboardPage - DI thieu register IContractExcelExporter — build pass but runtime would fail (added) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -1,27 +1,136 @@
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { FileText, CheckCircle2, AlertTriangle, TrendingUp, Coins } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { BarChart } from '@/components/BarChart'
|
||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||
import { api } from '@/lib/api'
|
||||
import type { DashboardStats } from '@/types/reports'
|
||||
|
||||
const fmtMoney = (v: number) => {
|
||||
if (v >= 1_000_000_000) return (v / 1_000_000_000).toFixed(1) + ' tỷ'
|
||||
if (v >= 1_000_000) return (v / 1_000_000).toFixed(1) + ' tr'
|
||||
return v.toLocaleString('vi-VN')
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, hint, tone = 'default' }: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
hint?: string
|
||||
tone?: 'default' | 'warn' | 'good'
|
||||
}) {
|
||||
const toneClass = tone === 'warn' ? 'text-amber-600' : tone === 'good' ? 'text-emerald-600' : 'text-brand-600'
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-slate-500">{label}</div>
|
||||
<Icon className={`h-4 w-4 ${toneClass}`} />
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-bold text-slate-900">{value}</div>
|
||||
{hint && <div className="mt-0.5 text-xs text-slate-400">{hint}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const { user } = useAuth()
|
||||
const stats = useQuery({
|
||||
queryKey: ['dashboard-stats'],
|
||||
queryFn: async () => (await api.get<DashboardStats>('/reports/dashboard')).data,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
if (stats.isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader title="Tổng quan" />
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="h-24 animate-pulse rounded-lg bg-slate-100" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const d = stats.data
|
||||
if (!d) return null
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Tổng quan</h1>
|
||||
<p className="mt-2 text-slate-600">
|
||||
Xin chào <span className="font-medium">{user?.fullName}</span>. Vai trò:{' '}
|
||||
<span className="font-mono text-sm">{user?.roles.join(', ')}</span>
|
||||
</p>
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{[
|
||||
{ label: 'HĐ đang xử lý', value: '—', hint: 'Sẽ hiển thị sau Phase 3' },
|
||||
{ label: 'HĐ chờ tôi duyệt', value: '—', hint: 'Sẽ hiển thị sau Phase 3' },
|
||||
{ label: 'Tổng NCC', value: '—', hint: 'Sẽ hiển thị sau Phase 1 đợt 2' },
|
||||
].map(card => (
|
||||
<div key={card.label} className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="text-xs font-medium text-slate-500">{card.label}</div>
|
||||
<div className="mt-2 text-3xl font-bold text-slate-900">{card.value}</div>
|
||||
<div className="mt-1 text-xs text-slate-400">{card.hint}</div>
|
||||
<div className="p-6">
|
||||
<PageHeader title="Tổng quan" description="Tình hình HĐ toàn hệ thống — cập nhật real-time khi refresh." />
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
|
||||
<StatCard icon={FileText} label="Tổng HĐ" value={d.totalContracts} />
|
||||
<StatCard icon={TrendingUp} label="HĐ đang xử lý" value={d.activeContracts} tone="good" />
|
||||
<StatCard icon={AlertTriangle} label="HĐ quá hạn SLA" value={d.overdueContracts} tone="warn" hint="SLA đã trôi" />
|
||||
<StatCard icon={CheckCircle2} label="Phát hành tháng này" value={d.publishedThisMonth} tone="good" />
|
||||
<StatCard icon={Coins} label="Tổng giá trị active" value={fmtMoney(d.totalValueActive)} hint="VND" />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* By Phase */}
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-5">
|
||||
<h2 className="mb-4 text-sm font-semibold text-slate-700">HĐ theo phase</h2>
|
||||
<div className="space-y-2">
|
||||
{d.byPhase.length === 0 && <div className="py-6 text-center text-sm text-slate-400">Chưa có HĐ nào</div>}
|
||||
{d.byPhase
|
||||
.slice()
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map(p => {
|
||||
const total = d.byPhase.reduce((s, x) => s + x.count, 0) || 1
|
||||
const pct = (p.count / total) * 100
|
||||
return (
|
||||
<div key={p.phase} className="flex items-center gap-3">
|
||||
<div className="w-36">
|
||||
<PhaseBadge phase={p.phase} />
|
||||
</div>
|
||||
<div className="h-2 flex-1 rounded-full bg-slate-100">
|
||||
<div className="h-full rounded-full bg-brand-500" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<div className="w-12 text-right font-mono text-xs text-slate-600">{p.count}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Monthly value */}
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-5">
|
||||
<h2 className="mb-4 text-sm font-semibold text-slate-700">Giá trị HĐ theo tháng (12 tháng gần nhất)</h2>
|
||||
<BarChart
|
||||
data={d.monthlyValue.map(m => ({
|
||||
label: `${String(m.month).padStart(2, '0')}/${m.year}`,
|
||||
value: m.totalValue,
|
||||
sublabel: `${m.count} HĐ`,
|
||||
}))}
|
||||
formatValue={fmtMoney}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Top suppliers */}
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-5">
|
||||
<h2 className="mb-4 text-sm font-semibold text-slate-700">Top NCC theo số HĐ</h2>
|
||||
<BarChart
|
||||
data={d.topSuppliers.map(s => ({
|
||||
label: s.supplierName,
|
||||
value: s.count,
|
||||
sublabel: `Tổng ${fmtMoney(s.totalValue)} VND`,
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Top projects */}
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-5">
|
||||
<h2 className="mb-4 text-sm font-semibold text-slate-700">Top dự án theo số HĐ</h2>
|
||||
<BarChart
|
||||
data={d.topProjects.map(p => ({
|
||||
label: p.projectName,
|
||||
value: p.count,
|
||||
sublabel: `Tổng ${fmtMoney(p.totalValue)} VND`,
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
92
fe-admin/src/pages/ReportsPage.tsx
Normal file
92
fe-admin/src/pages/ReportsPage.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Download, FileSpreadsheet } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { ContractPhase, ContractPhaseLabel } from '@/types/contracts'
|
||||
|
||||
export function ReportsPage() {
|
||||
const [phase, setPhase] = useState('')
|
||||
const [fromDate, setFromDate] = useState('')
|
||||
const [toDate, setToDate] = useState('')
|
||||
|
||||
const exportMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const params: Record<string, string> = {}
|
||||
if (phase) params.phase = phase
|
||||
if (fromDate) params.fromDate = fromDate
|
||||
if (toDate) params.toDate = toDate
|
||||
const res = await api.get('/reports/contracts/export', { params, responseType: 'blob' })
|
||||
const filename = res.headers['content-disposition']?.match(/filename="?([^";]+)"?/)?.[1] ?? 'contracts.xlsx'
|
||||
return { blob: res.data as Blob, filename }
|
||||
},
|
||||
onSuccess: ({ blob, filename }) => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('Đã tải file')
|
||||
},
|
||||
onError: err => toast.error(getErrorMessage(err)),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader
|
||||
title="Báo cáo"
|
||||
description="Xuất danh sách HĐ ra Excel theo bộ lọc."
|
||||
/>
|
||||
|
||||
<div className="max-w-2xl rounded-lg border border-slate-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
Xuất danh sách HĐ (.xlsx)
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label>Phase</Label>
|
||||
<Select value={phase} onChange={e => setPhase(e.target.value)}>
|
||||
<option value="">Tất cả phase</option>
|
||||
{Object.values(ContractPhase).map(p => (
|
||||
<option key={p} value={p}>{ContractPhaseLabel[p]}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Từ ngày</Label>
|
||||
<Input type="date" value={fromDate} onChange={e => setFromDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Đến ngày</Label>
|
||||
<Input type="date" value={toDate} onChange={e => setToDate(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-2">
|
||||
<Button onClick={() => exportMut.mutate()} disabled={exportMut.isPending}>
|
||||
<Download className="h-4 w-4" />
|
||||
{exportMut.isPending ? 'Đang xuất…' : 'Tải Excel'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 max-w-2xl rounded-lg border border-dashed border-slate-300 bg-slate-50 p-5 text-sm text-slate-500">
|
||||
<strong className="text-slate-700">Báo cáo khác (Phase 4 iteration 2):</strong>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>HĐ quá hạn SLA theo phase / role</li>
|
||||
<li>Biểu đồ giá trị HĐ theo tháng / dự án</li>
|
||||
<li>Export Approvals history</li>
|
||||
<li>Export từng HĐ ra PDF</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user