[CLAUDE] Domain+App+Infra+Api+FE-Admin+FE-User: S37 Mig 37 enum + Plan G-O3 Đề xuất full-stack
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>
This commit is contained in:
pqhuy1987
2026-05-28 15:51:14 +07:00
parent 37593f95b5
commit de1c378279
35 changed files with 13650 additions and 0 deletions

View File

@ -25,6 +25,9 @@ import { HrmConfigsPage } from '@/pages/hrm/HrmConfigsPage'
import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage'
import { MeetingCalendarPage } from '@/pages/office/MeetingCalendarPage'
import { MeetingRoomsPage } from '@/pages/office/MeetingRoomsPage'
import { ProposalCreatePage } from '@/pages/office/ProposalCreatePage'
import { ProposalDetailPage } from '@/pages/office/ProposalDetailPage'
import { ProposalsListPage } from '@/pages/office/ProposalsListPage'
function App() {
return (
@ -68,6 +71,9 @@ function App() {
{/* Văn phòng số — Phòng họp Booking + Catalog (Phase 10.2 G-O2 — Mig 36 S36) */}
<Route path="/meeting-calendar" element={<MeetingCalendarPage />} />
<Route path="/meeting-rooms" element={<MeetingRoomsPage />} />
<Route path="/proposals" element={<ProposalsListPage />} />
<Route path="/proposals/new" element={<ProposalCreatePage />} />
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route
path="*"

View File

@ -92,6 +92,9 @@ function resolvePath(key: string): string | null {
Off_PhongHop_View: '/meeting-calendar',
Off_PhongHop_Book: '/meeting-calendar',
Off_PhongHop_Manage: '/meeting-rooms',
Off_DeXuat_List: '/proposals',
Off_DeXuat_Create: '/proposals/new',
Off_DeXuat_Inbox: '/proposals?status=2&inboxOnly=true',
}
if (staticMap[key]) return staticMap[key]

View File

@ -48,6 +48,11 @@ export const MenuKeys = {
OffPhongHopView: 'Off_PhongHop_View',
OffPhongHopManage: 'Off_PhongHop_Manage',
OffPhongHopBook: 'Off_PhongHop_Book',
// Phase 10.3 G-O3 (S37) — Đề xuất (Proposal)
OffDeXuat: 'Off_DeXuat',
OffDeXuatList: 'Off_DeXuat_List',
OffDeXuatCreate: 'Off_DeXuat_Create',
OffDeXuatInbox: 'Off_DeXuat_Inbox',
} as const
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]

View File

@ -0,0 +1,191 @@
// Tạo Đề xuất mới — Phase 10.3 G-O3 (S37 2026-05-28).
// Form Header card: Title + Description + AmountEstimate + Department + ApprovalWorkflow.
// File MIRROR SHA256 identical với fe-user counterpart.
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { Save, X } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import type { CreateProposalInput } from '@/types/proposal'
interface DepartmentDto {
id: string
code: string
name: string
}
interface WorkflowDto {
id: string
code: string
name: string
}
function parseVnd(s: string): number {
return Number(s.replace(/[^\d]/g, '')) || 0
}
function formatVnd(n: number): string {
return n > 0 ? n.toLocaleString('vi-VN') : ''
}
export function ProposalCreatePage() {
const navigate = useNavigate()
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [amountStr, setAmountStr] = useState('')
const [departmentId, setDepartmentId] = useState<string>('')
const [approvalWorkflowId, setApprovalWorkflowId] = useState<string>('')
const departments = useQuery({
queryKey: ['departments'],
queryFn: async () => (await api.get<DepartmentDto[]>('/departments')).data,
})
const workflows = useQuery({
queryKey: ['approval-workflows-v2', { applicableType: 4, isUserSelectable: true }],
queryFn: async () =>
(await api.get<WorkflowDto[]>('/approval-workflows-v2', {
params: { applicableType: 4, isUserSelectable: true },
})).data,
})
const create = useMutation({
mutationFn: async () => {
if (!title.trim()) throw new Error('Vui lòng nhập Tiêu đề')
const body: CreateProposalInput = {
title: title.trim(),
description: description.trim() || null,
amountEstimate: amountStr ? parseVnd(amountStr) : null,
departmentId: departmentId || null,
approvalWorkflowId: approvalWorkflowId || null,
}
const res = await api.post<{ id: string }>('/proposals', body)
return res.data.id
},
onSuccess: (id) => {
toast.success('Tạo đề xuất thành công')
navigate(`/proposals/${id}`)
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const onSubmit = (e: FormEvent) => {
e.preventDefault()
create.mutate()
}
return (
<div className="space-y-4">
<PageHeader
title="Tạo Đề xuất mới"
description="Soạn đề xuất → gửi duyệt theo Quy trình admin config"
actions={
<Button variant="outline" onClick={() => navigate('/proposals')}>
<X className="mr-2 h-4 w-4" />
Huỷ
</Button>
}
/>
<form onSubmit={onSubmit} className="space-y-4">
<div className="rounded-lg border bg-card p-6 space-y-4">
<div>
<Label htmlFor="title">
Tiêu đ <span className="text-red-500">*</span>
</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={300}
placeholder="vd. Đề xuất mua sắm máy tính cho Phòng IT"
required
/>
</div>
<div>
<Label htmlFor="description">Nội dung chi tiết</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={5000}
rows={6}
placeholder="Mô tả lý do, nội dung, kết quả mong đợi..."
/>
<div className="text-xs text-muted-foreground mt-1">{description.length}/5000</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="amount">Số tiền dự kiến (VND)</Label>
<Input
id="amount"
value={formatVnd(parseVnd(amountStr))}
onChange={(e) => setAmountStr(e.target.value)}
placeholder="vd. 50,000,000"
inputMode="numeric"
/>
</div>
<div>
<Label htmlFor="department">Phòng ban</Label>
<select
id="department"
value={departmentId}
onChange={(e) => setDepartmentId(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
<option value=""> Không chọn </option>
{departments.data?.map((d) => (
<option key={d.id} value={d.id}>
{d.code} - {d.name}
</option>
))}
</select>
</div>
</div>
<div>
<Label htmlFor="workflow">
Quy trình duyệt <span className="text-red-500">*</span>
</Label>
<select
id="workflow"
value={approvalWorkflowId}
onChange={(e) => setApprovalWorkflowId(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
<option value=""> Chọn quy trình </option>
{workflows.data?.map((w) => (
<option key={w.id} value={w.id}>
{w.code} - {w.name}
</option>
))}
</select>
<div className="text-xs text-muted-foreground mt-1">
Chỉ hiển thị quy trình loại "Đề xuất" admin đã ghim cho user pick
</div>
</div>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => navigate('/proposals')}>
Huỷ
</Button>
<Button type="submit" disabled={create.isPending || !title.trim()}>
<Save className="mr-2 h-4 w-4" />
{create.isPending ? 'Đang lưu...' : 'Lưu nháp'}
</Button>
</div>
</form>
</div>
)
}

View File

@ -0,0 +1,310 @@
// Đề xuất chi tiết — Phase 10.3 G-O3 (S37 2026-05-28).
// 3 Section + WorkflowActions buttons + Ý kiến cấp duyệt V2 dynamic.
// File MIRROR SHA256 identical với fe-user counterpart.
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom'
import {
ArrowLeft, Ban, CheckCircle2, RotateCcw, Send,
} from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
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 {
PROPOSAL_STATUS_BADGE, PROPOSAL_STATUS_LABELS,
ProposalStatus, type ProposalDetailDto, type ProposalStatusValue,
} from '@/types/proposal'
type ActionKind = 'approve' | 'reject' | 'return'
function formatVnd(n: number | null): string {
if (n === null || n === undefined) return '—'
return n.toLocaleString('vi-VN') + ' đ'
}
function formatDateTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('vi-VN')
}
const ACTION_LABEL: Record<ActionKind, { text: string; tone: string }> = {
approve: { text: 'Duyệt', tone: 'bg-emerald-600 hover:bg-emerald-700' },
reject: { text: 'Từ chối', tone: 'bg-red-600 hover:bg-red-700' },
return: { text: 'Trả lại', tone: 'bg-orange-600 hover:bg-orange-700' },
}
export function ProposalDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const qc = useQueryClient()
const [actionDialog, setActionDialog] = useState<ActionKind | null>(null)
const [comment, setComment] = useState('')
const proposal = useQuery({
queryKey: ['proposal', id],
queryFn: async () => (await api.get<ProposalDetailDto>(`/proposals/${id}`)).data,
enabled: !!id,
})
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['proposal', id] })
qc.invalidateQueries({ queryKey: ['proposals'] })
}
const submit = useMutation({
mutationFn: async () => {
await api.post(`/proposals/${id}/submit`)
},
onSuccess: () => {
toast.success('Đã gửi duyệt')
invalidate()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const action = useMutation({
mutationFn: async (kind: ActionKind) => {
await api.post(`/proposals/${id}/${kind}`, { comment: comment.trim() || null })
},
onSuccess: (_, kind) => {
toast.success(`Đã ${ACTION_LABEL[kind].text.toLowerCase()}`)
setActionDialog(null)
setComment('')
invalidate()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
if (proposal.isLoading) {
return (
<div className="space-y-4">
<PageHeader title="Đang tải..." />
</div>
)
}
if (proposal.isError || !proposal.data) {
return (
<div className="space-y-4">
<PageHeader
title="Lỗi"
actions={
<Button variant="outline" onClick={() => navigate('/proposals')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Quay lại
</Button>
}
/>
<div className="rounded-lg border bg-red-50 p-4 text-sm text-red-800">
Không tải đưc dữ liệu đ xuất.
</div>
</div>
)
}
const p = proposal.data
const status = p.status as ProposalStatusValue
const isDraft = status === ProposalStatus.Nhap || status === ProposalStatus.TraLai
const isInWorkflow = status === ProposalStatus.DaGuiDuyet
return (
<div className="space-y-4">
<PageHeader
title={p.maDeXuat ?? '(Chưa có mã)'}
description={p.title}
actions={
<Button variant="outline" onClick={() => navigate('/proposals')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Danh sách
</Button>
}
/>
{/* Status row + action buttons */}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-card p-4">
<div className="flex items-center gap-3">
<span
className={cn(
'inline-flex items-center rounded-md border px-3 py-1 text-sm font-medium',
PROPOSAL_STATUS_BADGE[status],
)}
>
{PROPOSAL_STATUS_LABELS[status]}
</span>
{p.currentApprovalLevelOrder && (
<span className="text-sm text-muted-foreground">
Cấp duyệt hiện tại: <span className="font-semibold">{p.currentApprovalLevelOrder}</span>
</span>
)}
{p.workflowCode && (
<span className="text-sm text-muted-foreground">
Quy trình: <span className="font-mono">{p.workflowCode}</span>
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
{isDraft && (
<Button onClick={() => submit.mutate()} disabled={submit.isPending}>
<Send className="mr-2 h-4 w-4" />
{status === ProposalStatus.TraLai ? 'Gửi duyệt lại' : 'Gửi duyệt'}
</Button>
)}
{isInWorkflow && (
<>
<Button onClick={() => setActionDialog('approve')} className={ACTION_LABEL.approve.tone}>
<CheckCircle2 className="mr-2 h-4 w-4" />
Duyệt
</Button>
<Button onClick={() => setActionDialog('return')} className={ACTION_LABEL.return.tone}>
<RotateCcw className="mr-2 h-4 w-4" />
Trả lại
</Button>
<Button onClick={() => setActionDialog('reject')} className={ACTION_LABEL.reject.tone}>
<Ban className="mr-2 h-4 w-4" />
Từ chối
</Button>
</>
)}
</div>
</div>
{/* Section 1: Thông tin */}
<div className="rounded-lg border bg-card p-6 space-y-3">
<h3 className="font-semibold text-base">1. Thông tin đ xuất</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div>
<Label className="text-muted-foreground">Tiêu đ</Label>
<div className="mt-1 font-medium">{p.title}</div>
</div>
<div>
<Label className="text-muted-foreground">Số tiền dự kiến</Label>
<div className="mt-1 font-medium tabular-nums">{formatVnd(p.amountEstimate)}</div>
</div>
<div>
<Label className="text-muted-foreground">Phòng ban</Label>
<div className="mt-1">{p.departmentName ?? '—'}</div>
</div>
<div>
<Label className="text-muted-foreground">Người soạn</Label>
<div className="mt-1">{p.drafterFullName ?? '—'}</div>
</div>
<div>
<Label className="text-muted-foreground">Quy trình</Label>
<div className="mt-1 text-xs">
{p.workflowCode ? (
<>
<span className="font-mono">{p.workflowCode}</span> - {p.workflowName}
</>
) : '— Chưa chọn —'}
</div>
</div>
<div>
<Label className="text-muted-foreground">Ngày tạo</Label>
<div className="mt-1 text-xs">{formatDateTime(p.createdAt)}</div>
</div>
</div>
{p.description && (
<div className="pt-3 border-t">
<Label className="text-muted-foreground">Nội dung chi tiết</Label>
<div className="mt-1 whitespace-pre-wrap text-sm">{p.description}</div>
</div>
)}
</div>
{/* Section 2: Attachments */}
<div className="rounded-lg border bg-card p-6 space-y-3">
<h3 className="font-semibold text-base">
2. File đính kèm <span className="text-muted-foreground text-sm">({p.attachments.length})</span>
</h3>
{p.attachments.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa file đính kèm.</div>
) : (
<ul className="space-y-2">
{p.attachments.map((a) => (
<li key={a.id} className="flex items-center justify-between rounded border p-2 text-sm">
<div>
<div className="font-medium">{a.fileName}</div>
<div className="text-xs text-muted-foreground">
{a.uploadedByFullName} · {(a.fileSize / 1024).toFixed(1)} KB
</div>
</div>
<a href={a.filePath} target="_blank" rel="noreferrer" className="text-primary text-xs">
Tải xuống
</a>
</li>
))}
</ul>
)}
</div>
{/* Section 3: Ý kiến cấp duyệt V2 dynamic */}
<div className="rounded-lg border bg-card p-6 space-y-3">
<h3 className="font-semibold text-base">3. Ý kiến cấp duyệt</h3>
{p.levelOpinions.length === 0 ? (
<div className="text-sm text-muted-foreground">
{status === ProposalStatus.Nhap
? 'Chưa gửi duyệt — chưa có ý kiến.'
: 'Chưa có cấp nào duyệt.'}
</div>
) : (
<div className="space-y-3">
{p.levelOpinions.map((o) => (
<div key={o.id} className="rounded border-l-4 border-emerald-400 bg-emerald-50/40 p-3">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
Bước {o.stepOrder} - {o.stepName} · Cấp {o.levelOrder}
</span>
<span>{formatDateTime(o.signedAt)}</span>
</div>
<div className="mt-1 font-medium">{o.signedByFullName}</div>
<div className="mt-1 whitespace-pre-wrap text-sm">{o.comment ?? '(duyệt — không ý kiến)'}</div>
</div>
))}
</div>
)}
</div>
{/* Action confirm dialog */}
<Dialog
open={!!actionDialog}
onClose={() => {
setActionDialog(null)
setComment('')
}}
title={actionDialog ? `${ACTION_LABEL[actionDialog].text} đề xuất` : ''}
>
<div className="space-y-3">
<Label htmlFor="action-comment">Ý kiến (tuỳ chọn)</Label>
<Textarea
id="action-comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={4}
placeholder="Để trống nếu không có ý kiến..."
maxLength={2000}
/>
<div className="text-xs text-muted-foreground">{comment.length}/2000</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setActionDialog(null)}>
Huỷ
</Button>
<Button
onClick={() => actionDialog && action.mutate(actionDialog)}
disabled={action.isPending}
className={actionDialog ? ACTION_LABEL[actionDialog].tone : ''}
>
{action.isPending ? 'Đang xử lý...' : 'Xác nhận'}
</Button>
</div>
</div>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,209 @@
// Đề 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>
)
}

View File

@ -0,0 +1,95 @@
// Phase 10.3 G-O3 (S37 2026-05-28) — Đề xuất (Proposal) types mirror BE
// ProposalFeatures.cs DTO. File MIRROR SHA256 identical với fe-user counterpart.
export const ProposalStatus = {
Nhap: 1,
DaGuiDuyet: 2,
TraLai: 3,
TuChoi: 4,
DaDuyet: 5,
} as const
export type ProposalStatusValue = (typeof ProposalStatus)[keyof typeof ProposalStatus]
export const PROPOSAL_STATUS_LABELS: Record<ProposalStatusValue, string> = {
1: 'Nháp',
2: 'Đã gửi duyệt',
3: 'Trả lại',
4: 'Từ chối',
5: 'Đã duyệt',
}
export const PROPOSAL_STATUS_BADGE: Record<ProposalStatusValue, string> = {
1: 'bg-slate-100 text-slate-700 border-slate-300',
2: 'bg-amber-100 text-amber-800 border-amber-300',
3: 'bg-orange-100 text-orange-800 border-orange-300',
4: 'bg-red-100 text-red-800 border-red-300',
5: 'bg-emerald-100 text-emerald-800 border-emerald-300',
}
export interface ProposalListItemDto {
id: string
maDeXuat: string | null
title: string
amountEstimate: number | null
status: ProposalStatusValue
departmentId: string | null
departmentName: string | null
drafterUserId: string
drafterFullName: string | null
approvalWorkflowId: string | null
workflowCode: string | null
currentApprovalLevelOrder: number | null
createdAt: string
}
export interface ProposalAttachmentDto {
id: string
fileName: string
filePath: string
fileSize: number
mimeType: string | null
uploadedByUserId: string
uploadedByFullName: string
createdAt: string
}
export interface ProposalLevelOpinionDto {
id: string
approvalWorkflowLevelId: string
stepOrder: number | null
stepName: string | null
levelOrder: number | null
approverUserId: string | null
comment: string | null
signedAt: string
signedByUserId: string
signedByFullName: string
}
export interface ProposalDetailDto extends ProposalListItemDto {
description: string | null
workflowName: string | null
rejectedFromStatus: number | null
attachments: ProposalAttachmentDto[]
levelOpinions: ProposalLevelOpinionDto[]
}
export interface CreateProposalInput {
title: string
description?: string | null
amountEstimate?: number | null
departmentId?: string | null
approvalWorkflowId?: string | null
}
export interface UpdateProposalDraftInput extends CreateProposalInput {}
export interface PagedResult<T> {
items: T[]
total: number
page: number
pageSize: number
totalPages: number
hasNext: boolean
hasPrev: boolean
}