Files
solution-erp/fe-user/src/pages/office/ProposalsListPage.tsx
pqhuy1987 de1c378279
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m53s
[CLAUDE] Domain+App+Infra+Api+FE-Admin+FE-User: S37 Mig 37 enum + Plan G-O3 Đề xuất full-stack
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>
2026-05-28 15:51:14 +07:00

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