All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m53s
Phase 10.3 G-O3 Đề xuất (Proposal) — Mig 37 enum extend +5 values + Mig 38 Proposal schema + BE CQRS 8 endpoint + FE 2 app SHA256 IDENTICAL. Mig 37 (em main solo): extend ApprovalWorkflowApplicableType enum +5 values ProposalGeneral=4 / LeaveRequest=5 / OtRequest=6 / VehicleBooking=7 / ItTicket=8 cookie-cutter Mig 22 pattern (Up/Down empty — enum mức Domain). Mig 38 (em main solo): 4 entity Proposal (Code DX/YYYY/NNN) + ProposalAttachment + ProposalLevelOpinion (UNIQUE composite PEId+LevelId mirror PE Mig 26) + ProposalCodeSequence (Prefix PK atomic seq). 4 EF Config + 2 DbContext mod. BE CQRS (em main solo ~700 LOC ProposalFeatures.cs sau Implementer truncate phase exploration gotcha #53 5th + 529 Overload): - 4 Header handler (List paged + GetById detail + Create + UpdateDraft owner-OR-admin) - 4 Workflow handler (Submit gen MaDeXuat atomic + Approve UPSERT LevelOpinion advance + Reject + Return) - SERIALIZABLE transaction CodeGen - DTOs nested LevelOpinion với Step+Level metadata JOIN ProposalsController 8 endpoint /api/proposals (List/GetById/Create/Update/Submit/Approve/Reject/Return) class-level [Authorize] + handler-level owner-OR-admin guard. DbInitializer: SeedSampleProposalWorkflowV2Async ~40 LOC seed QT-DX-V2-001 IsUserSelectable=true NOT gated DemoSeed per gotcha #51. SeedMenuTreeAsync +4 row (Off_DeXuat sub-group + 3 leaf). FE 2 app (em main solo + Implementer 529 fail fallback): - types/proposal.ts × 2 SHA256 IDENTICAL 95607052ff1138f2 - ProposalsListPage.tsx × 2 IDENTICAL 603f0d9cf74cd09a — table 6 cột + Status badge + filter - ProposalCreatePage.tsx × 2 IDENTICAL 6aed3a76563dd576 — Form Header card - ProposalDetailPage.tsx × 2 IDENTICAL 3dc229ea8dcc9bc0 — 3 Section + WorkflowActions - Pattern 16-bis 8× cumulative (App.tsx + menuKeys + Layout staticMap 3 entry) Verify: - dotnet build PASS 0 error 2 warning pre-existing DocxRenderer - dotnet test 130/130 PASS baseline preserve - npm build × 2 PASS (fe-admin 14.72s + fe-user 6.40s) - SHA256 verify 4 file × 2 app all IDENTICAL Pattern reinforced cumulative S37: - Pattern 12-bis cross-module mirror 11× (PE V2 → Proposal V2 ApproveV2) - Pattern 16-bis 4-place mirror cross-app 8× - gotcha #53 5th occurrence Implementer mid-exploration truncation + 529 Overload 1× — em main solo fallback proven Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
210 lines
7.7 KiB
TypeScript
210 lines
7.7 KiB
TypeScript
// Đề xuất danh sách — Phase 10.3 G-O3 (S37 2026-05-28).
|
|
// Table 6 cột với Status badge color + filter status + search.
|
|
// File MIRROR SHA256 identical với fe-user counterpart.
|
|
import { useMemo, useState } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
import { Plus, Search } from 'lucide-react'
|
|
import { PageHeader } from '@/components/PageHeader'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { Input } from '@/components/ui/Input'
|
|
import { api } from '@/lib/api'
|
|
import { cn } from '@/lib/cn'
|
|
import {
|
|
PROPOSAL_STATUS_BADGE,
|
|
PROPOSAL_STATUS_LABELS,
|
|
type PagedResult,
|
|
type ProposalListItemDto,
|
|
type ProposalStatusValue,
|
|
} from '@/types/proposal'
|
|
|
|
const PAGE_SIZE = 20
|
|
|
|
function formatVnd(n: number | null): string {
|
|
if (n === null || n === undefined) return '—'
|
|
return n.toLocaleString('vi-VN') + ' đ'
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
const d = new Date(iso)
|
|
return d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
|
}
|
|
|
|
export function ProposalsListPage() {
|
|
const navigate = useNavigate()
|
|
const [searchParams] = useSearchParams()
|
|
const initialStatus = searchParams.get('status')
|
|
const initialInbox = searchParams.get('inboxOnly') === 'true'
|
|
|
|
const [status, setStatus] = useState<number | null>(initialStatus ? Number(initialStatus) : null)
|
|
const [inboxOnly, setInboxOnly] = useState(initialInbox)
|
|
const [search, setSearch] = useState('')
|
|
const [page, setPage] = useState(1)
|
|
|
|
const list = useQuery({
|
|
queryKey: ['proposals', { status, inboxOnly, search, page }],
|
|
queryFn: async () =>
|
|
(await api.get<PagedResult<ProposalListItemDto>>('/proposals', {
|
|
params: {
|
|
status: status || undefined,
|
|
inboxOnly: inboxOnly || undefined,
|
|
search: search.trim() || undefined,
|
|
page,
|
|
pageSize: PAGE_SIZE,
|
|
},
|
|
})).data,
|
|
})
|
|
|
|
const items = list.data?.items ?? []
|
|
const total = list.data?.total ?? 0
|
|
const totalPages = list.data?.totalPages ?? 1
|
|
|
|
const statusOptions: Array<{ value: number | null; label: string }> = useMemo(
|
|
() => [
|
|
{ value: null, label: 'Tất cả' },
|
|
{ value: 1, label: PROPOSAL_STATUS_LABELS[1] },
|
|
{ value: 2, label: PROPOSAL_STATUS_LABELS[2] },
|
|
{ value: 3, label: PROPOSAL_STATUS_LABELS[3] },
|
|
{ value: 4, label: PROPOSAL_STATUS_LABELS[4] },
|
|
{ value: 5, label: PROPOSAL_STATUS_LABELS[5] },
|
|
],
|
|
[],
|
|
)
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<PageHeader
|
|
title="Đề xuất"
|
|
description="Quản lý đề xuất nội bộ — Workflow V2 dynamic theo Quy trình admin config"
|
|
actions={
|
|
<Button onClick={() => navigate('/proposals/new')}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Tạo Đề xuất
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<div className="rounded-lg border bg-card p-4">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="flex items-center gap-1">
|
|
{statusOptions.map((opt) => (
|
|
<button
|
|
key={opt.value ?? 'all'}
|
|
type="button"
|
|
onClick={() => {
|
|
setStatus(opt.value)
|
|
setPage(1)
|
|
}}
|
|
className={cn(
|
|
'rounded-md border px-3 py-1.5 text-sm transition',
|
|
status === opt.value
|
|
? 'border-primary bg-primary/10 text-primary font-medium'
|
|
: 'border-input bg-background hover:bg-accent',
|
|
)}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={inboxOnly}
|
|
onChange={(e) => {
|
|
setInboxOnly(e.target.checked)
|
|
setPage(1)
|
|
}}
|
|
className="h-4 w-4"
|
|
/>
|
|
Inbox duyệt
|
|
</label>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<Search className="h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
value={search}
|
|
onChange={(e) => {
|
|
setSearch(e.target.value)
|
|
setPage(1)
|
|
}}
|
|
placeholder="Tìm mã hoặc tiêu đề..."
|
|
className="w-64"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-lg border bg-card">
|
|
<table className="w-full text-sm">
|
|
<thead className="border-b bg-muted/50">
|
|
<tr>
|
|
<th className="px-4 py-2 text-left font-medium">Mã</th>
|
|
<th className="px-4 py-2 text-left font-medium">Tiêu đề</th>
|
|
<th className="px-4 py-2 text-left font-medium">Trạng thái</th>
|
|
<th className="px-4 py-2 text-right font-medium">Số tiền dự kiến</th>
|
|
<th className="px-4 py-2 text-left font-medium">Người soạn</th>
|
|
<th className="px-4 py-2 text-left font-medium">Ngày tạo</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{list.isLoading && (
|
|
<tr>
|
|
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
|
|
Đang tải...
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{!list.isLoading && items.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
|
|
Chưa có đề xuất nào.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{items.map((p) => (
|
|
<tr
|
|
key={p.id}
|
|
onClick={() => navigate(`/proposals/${p.id}`)}
|
|
className="cursor-pointer border-b transition hover:bg-accent/50"
|
|
>
|
|
<td className="px-4 py-2 font-mono text-xs">{p.maDeXuat ?? '—'}</td>
|
|
<td className="px-4 py-2 max-w-md truncate">{p.title}</td>
|
|
<td className="px-4 py-2">
|
|
<span
|
|
className={cn(
|
|
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
|
|
PROPOSAL_STATUS_BADGE[p.status as ProposalStatusValue],
|
|
)}
|
|
>
|
|
{PROPOSAL_STATUS_LABELS[p.status as ProposalStatusValue]}
|
|
</span>
|
|
{p.currentApprovalLevelOrder && (
|
|
<span className="ml-2 text-xs text-muted-foreground">Cấp {p.currentApprovalLevelOrder}</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2 text-right tabular-nums">{formatVnd(p.amountEstimate)}</td>
|
|
<td className="px-4 py-2 text-xs">{p.drafterFullName ?? '—'}</td>
|
|
<td className="px-4 py-2 text-xs">{formatDate(p.createdAt)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between border-t px-4 py-2 text-sm">
|
|
<div className="text-muted-foreground">
|
|
{total} đề xuất — Trang {page} / {totalPages}
|
|
</div>
|
|
<div className="flex gap-1">
|
|
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
|
Trước
|
|
</Button>
|
|
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
|
Sau
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|