[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
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:
@ -32,6 +32,9 @@ import { HrmConfigsPage } from '@/pages/hrm/HrmConfigsPage'
|
|||||||
import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage'
|
import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage'
|
||||||
import { MeetingCalendarPage } from '@/pages/office/MeetingCalendarPage'
|
import { MeetingCalendarPage } from '@/pages/office/MeetingCalendarPage'
|
||||||
import { MeetingRoomsPage } from '@/pages/office/MeetingRoomsPage'
|
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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -85,6 +88,9 @@ function App() {
|
|||||||
{/* Văn phòng số — Phòng họp Booking + Catalog (Phase 10.2 G-O2 — Mig 36 S36) */}
|
{/* 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-calendar" element={<MeetingCalendarPage />} />
|
||||||
<Route path="/meeting-rooms" element={<MeetingRoomsPage />} />
|
<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="/reports" element={<ReportsPage />} />
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@ -70,6 +70,9 @@ function resolvePath(key: string): string | null {
|
|||||||
Off_PhongHop_View: '/meeting-calendar',
|
Off_PhongHop_View: '/meeting-calendar',
|
||||||
Off_PhongHop_Book: '/meeting-calendar',
|
Off_PhongHop_Book: '/meeting-calendar',
|
||||||
Off_PhongHop_Manage: '/meeting-rooms',
|
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]
|
if (staticMap[key]) return staticMap[key]
|
||||||
|
|
||||||
|
|||||||
@ -48,6 +48,11 @@ export const MenuKeys = {
|
|||||||
OffPhongHopView: 'Off_PhongHop_View',
|
OffPhongHopView: 'Off_PhongHop_View',
|
||||||
OffPhongHopManage: 'Off_PhongHop_Manage',
|
OffPhongHopManage: 'Off_PhongHop_Manage',
|
||||||
OffPhongHopBook: 'Off_PhongHop_Book',
|
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
|
} as const
|
||||||
|
|
||||||
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]
|
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]
|
||||||
|
|||||||
191
fe-admin/src/pages/office/ProposalCreatePage.tsx
Normal file
191
fe-admin/src/pages/office/ProposalCreatePage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
310
fe-admin/src/pages/office/ProposalDetailPage.tsx
Normal file
310
fe-admin/src/pages/office/ProposalDetailPage.tsx
Normal 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 có 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
209
fe-admin/src/pages/office/ProposalsListPage.tsx
Normal file
209
fe-admin/src/pages/office/ProposalsListPage.tsx
Normal 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">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>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
fe-admin/src/types/proposal.ts
Normal file
95
fe-admin/src/types/proposal.ts
Normal 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
|
||||||
|
}
|
||||||
@ -25,6 +25,9 @@ import { HrmConfigsPage } from '@/pages/hrm/HrmConfigsPage'
|
|||||||
import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage'
|
import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage'
|
||||||
import { MeetingCalendarPage } from '@/pages/office/MeetingCalendarPage'
|
import { MeetingCalendarPage } from '@/pages/office/MeetingCalendarPage'
|
||||||
import { MeetingRoomsPage } from '@/pages/office/MeetingRoomsPage'
|
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() {
|
function App() {
|
||||||
return (
|
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) */}
|
{/* 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-calendar" element={<MeetingCalendarPage />} />
|
||||||
<Route path="/meeting-rooms" element={<MeetingRoomsPage />} />
|
<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="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
|
|||||||
@ -92,6 +92,9 @@ function resolvePath(key: string): string | null {
|
|||||||
Off_PhongHop_View: '/meeting-calendar',
|
Off_PhongHop_View: '/meeting-calendar',
|
||||||
Off_PhongHop_Book: '/meeting-calendar',
|
Off_PhongHop_Book: '/meeting-calendar',
|
||||||
Off_PhongHop_Manage: '/meeting-rooms',
|
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]
|
if (staticMap[key]) return staticMap[key]
|
||||||
|
|
||||||
|
|||||||
@ -48,6 +48,11 @@ export const MenuKeys = {
|
|||||||
OffPhongHopView: 'Off_PhongHop_View',
|
OffPhongHopView: 'Off_PhongHop_View',
|
||||||
OffPhongHopManage: 'Off_PhongHop_Manage',
|
OffPhongHopManage: 'Off_PhongHop_Manage',
|
||||||
OffPhongHopBook: 'Off_PhongHop_Book',
|
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
|
} as const
|
||||||
|
|
||||||
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]
|
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]
|
||||||
|
|||||||
191
fe-user/src/pages/office/ProposalCreatePage.tsx
Normal file
191
fe-user/src/pages/office/ProposalCreatePage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
310
fe-user/src/pages/office/ProposalDetailPage.tsx
Normal file
310
fe-user/src/pages/office/ProposalDetailPage.tsx
Normal 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 có 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
209
fe-user/src/pages/office/ProposalsListPage.tsx
Normal file
209
fe-user/src/pages/office/ProposalsListPage.tsx
Normal 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">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>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
fe-user/src/types/proposal.ts
Normal file
95
fe-user/src/types/proposal.ts
Normal 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
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SolutionErp.Application.Office;
|
||||||
|
|
||||||
|
namespace SolutionErp.Api.Controllers;
|
||||||
|
|
||||||
|
// Phase 10.3 G-O3 (S37 2026-05-28) — Đề xuất REST endpoint.
|
||||||
|
// Class-level [Authorize] — any authenticated user can list/create/view.
|
||||||
|
// Per-action ownership/role enforcement trong handler (Drafter OR Admin for write;
|
||||||
|
// Approver-of-current-Level OR Admin for approve/reject/return).
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/proposals")]
|
||||||
|
[Authorize]
|
||||||
|
public class ProposalsController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetList(
|
||||||
|
[FromQuery] int? status,
|
||||||
|
[FromQuery] Guid? departmentId,
|
||||||
|
[FromQuery] Guid? drafterUserId,
|
||||||
|
[FromQuery] bool inboxOnly = false,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 50,
|
||||||
|
[FromQuery] string? search = null)
|
||||||
|
=> Ok(await mediator.Send(new GetProposalsQuery(status, departmentId, drafterUserId, inboxOnly, page, pageSize, search)));
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var dto = await mediator.Send(new GetProposalByIdQuery(id));
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateProposalCommand cmd)
|
||||||
|
{
|
||||||
|
var id = await mediator.Send(cmd);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateProposalDraftBody body)
|
||||||
|
{
|
||||||
|
await mediator.Send(new UpdateProposalDraftCommand(id, body.Title, body.Description,
|
||||||
|
body.AmountEstimate, body.DepartmentId, body.ApprovalWorkflowId));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/submit")]
|
||||||
|
public async Task<IActionResult> Submit(Guid id)
|
||||||
|
{
|
||||||
|
await mediator.Send(new SubmitProposalCommand(id));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/approve")]
|
||||||
|
public async Task<IActionResult> Approve(Guid id, [FromBody] ApprovalActionBody body)
|
||||||
|
{
|
||||||
|
await mediator.Send(new ApproveProposalCommand(id, body.Comment));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/reject")]
|
||||||
|
public async Task<IActionResult> Reject(Guid id, [FromBody] ApprovalActionBody body)
|
||||||
|
{
|
||||||
|
await mediator.Send(new RejectProposalCommand(id, body.Comment));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/return")]
|
||||||
|
public async Task<IActionResult> Return(Guid id, [FromBody] ApprovalActionBody body)
|
||||||
|
{
|
||||||
|
await mediator.Send(new ReturnProposalCommand(id, body.Comment));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UpdateProposalDraftBody(
|
||||||
|
string Title,
|
||||||
|
string? Description,
|
||||||
|
decimal? AmountEstimate,
|
||||||
|
Guid? DepartmentId,
|
||||||
|
Guid? ApprovalWorkflowId);
|
||||||
|
|
||||||
|
public record ApprovalActionBody(string? Comment);
|
||||||
|
}
|
||||||
@ -111,5 +111,13 @@ public interface IApplicationDbContext
|
|||||||
DbSet<MeetingBooking> MeetingBookings { get; }
|
DbSet<MeetingBooking> MeetingBookings { get; }
|
||||||
DbSet<MeetingBookingAttendee> MeetingBookingAttendees { get; }
|
DbSet<MeetingBookingAttendee> MeetingBookingAttendees { get; }
|
||||||
|
|
||||||
|
// Phase 10.3 G-O3 (Mig 38 — S37) — Đề xuất (Proposal) cookie-cutter mirror PE.
|
||||||
|
// Workflow V2 dynamic ApplicableType=ProposalGeneral=4 (Mig 37 enum extend).
|
||||||
|
// CodeGen "DX/YYYY/NNN" atomic via ProposalCodeSequences.
|
||||||
|
DbSet<Proposal> Proposals { get; }
|
||||||
|
DbSet<ProposalAttachment> ProposalAttachments { get; }
|
||||||
|
DbSet<ProposalLevelOpinion> ProposalLevelOpinions { get; }
|
||||||
|
DbSet<ProposalCodeSequence> ProposalCodeSequences { get; }
|
||||||
|
|
||||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
556
src/Backend/SolutionErp.Application/Office/ProposalFeatures.cs
Normal file
556
src/Backend/SolutionErp.Application/Office/ProposalFeatures.cs
Normal file
@ -0,0 +1,556 @@
|
|||||||
|
using System.Data;
|
||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Application.Common.Models;
|
||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
|
using SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.Office;
|
||||||
|
|
||||||
|
// Phase 10.3 G-O3 (S37 2026-05-28) — Đề xuất (Proposal) CQRS + Workflow V2 ApproveV2.
|
||||||
|
// Cookie-cutter mirror PurchaseEvaluation Plan B Chunk D pattern (Mig 22-26 V2).
|
||||||
|
// ApplicableType=ProposalGeneral=4 (Mig 37 enum extend).
|
||||||
|
//
|
||||||
|
// 8 endpoint qua ProposalsController:
|
||||||
|
// GET /api/proposals — list paged (filter status/dept/drafter/inbox)
|
||||||
|
// GET /api/proposals/{id} — detail Include Attachments + LevelOpinions + Workflow
|
||||||
|
// POST /api/proposals — create Status=Nhap (MaDeXuat null tới Submit)
|
||||||
|
// PUT /api/proposals/{id} — update draft (Nhap or TraLai only)
|
||||||
|
// POST /api/proposals/{id}/submit — gen MaDeXuat atomic + Status=DaGuiDuyet
|
||||||
|
// POST /api/proposals/{id}/approve — ApproveV2: UPSERT LevelOpinion + advance level/terminal
|
||||||
|
// POST /api/proposals/{id}/reject — Status=TuChoi terminal (no opinion sync)
|
||||||
|
// POST /api/proposals/{id}/return — Status=TraLai (no opinion sync, Drafter resubmit từ Cấp 1)
|
||||||
|
|
||||||
|
// ===== DTOs =====
|
||||||
|
|
||||||
|
public record ProposalListItemDto(
|
||||||
|
Guid Id,
|
||||||
|
string? MaDeXuat,
|
||||||
|
string Title,
|
||||||
|
decimal? AmountEstimate,
|
||||||
|
int Status,
|
||||||
|
Guid? DepartmentId,
|
||||||
|
string? DepartmentName,
|
||||||
|
Guid DrafterUserId,
|
||||||
|
string? DrafterFullName,
|
||||||
|
Guid? ApprovalWorkflowId,
|
||||||
|
string? WorkflowCode,
|
||||||
|
int? CurrentApprovalLevelOrder,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record ProposalDetailDto(
|
||||||
|
Guid Id,
|
||||||
|
string? MaDeXuat,
|
||||||
|
string Title,
|
||||||
|
string? Description,
|
||||||
|
decimal? AmountEstimate,
|
||||||
|
int Status,
|
||||||
|
Guid? DepartmentId,
|
||||||
|
string? DepartmentName,
|
||||||
|
Guid DrafterUserId,
|
||||||
|
string? DrafterFullName,
|
||||||
|
Guid? ApprovalWorkflowId,
|
||||||
|
string? WorkflowCode,
|
||||||
|
string? WorkflowName,
|
||||||
|
int? CurrentApprovalLevelOrder,
|
||||||
|
int? RejectedFromStatus,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
List<ProposalAttachmentDto> Attachments,
|
||||||
|
List<ProposalLevelOpinionDto> LevelOpinions);
|
||||||
|
|
||||||
|
public record ProposalAttachmentDto(
|
||||||
|
Guid Id,
|
||||||
|
string FileName,
|
||||||
|
string FilePath,
|
||||||
|
long FileSize,
|
||||||
|
string? MimeType,
|
||||||
|
Guid UploadedByUserId,
|
||||||
|
string UploadedByFullName,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record ProposalLevelOpinionDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid ApprovalWorkflowLevelId,
|
||||||
|
int? StepOrder,
|
||||||
|
string? StepName,
|
||||||
|
int? LevelOrder,
|
||||||
|
Guid? ApproverUserId,
|
||||||
|
string? Comment,
|
||||||
|
DateTime SignedAt,
|
||||||
|
Guid SignedByUserId,
|
||||||
|
string SignedByFullName);
|
||||||
|
|
||||||
|
// ===== Region 1: Header CRUD =====
|
||||||
|
|
||||||
|
public record GetProposalsQuery(
|
||||||
|
int? Status,
|
||||||
|
Guid? DepartmentId,
|
||||||
|
Guid? DrafterUserId,
|
||||||
|
bool InboxOnly = false,
|
||||||
|
int Page = 1,
|
||||||
|
int PageSize = 50,
|
||||||
|
string? Search = null) : IRequest<PagedResult<ProposalListItemDto>>;
|
||||||
|
|
||||||
|
public class GetProposalsHandler(IApplicationDbContext db, ICurrentUser currentUser)
|
||||||
|
: IRequestHandler<GetProposalsQuery, PagedResult<ProposalListItemDto>>
|
||||||
|
{
|
||||||
|
public async Task<PagedResult<ProposalListItemDto>> Handle(GetProposalsQuery req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var page = req.Page < 1 ? 1 : req.Page;
|
||||||
|
var pageSize = req.PageSize switch { < 1 => 50, > 200 => 200, _ => req.PageSize };
|
||||||
|
|
||||||
|
var q = db.Proposals.AsNoTracking().Where(p => !p.IsDeleted);
|
||||||
|
|
||||||
|
if (req.Status.HasValue)
|
||||||
|
q = q.Where(p => (int)p.Status == req.Status.Value);
|
||||||
|
if (req.DepartmentId.HasValue)
|
||||||
|
q = q.Where(p => p.DepartmentId == req.DepartmentId.Value);
|
||||||
|
if (req.DrafterUserId.HasValue)
|
||||||
|
q = q.Where(p => p.DrafterUserId == req.DrafterUserId.Value);
|
||||||
|
|
||||||
|
// Inbox = DaGuiDuyet phiếu có Cấp hiện tại match current user trong Workflow.
|
||||||
|
// Lite version: filter Status=DaGuiDuyet client-side resolve approver via Workflow join below.
|
||||||
|
if (req.InboxOnly && currentUser.UserId.HasValue)
|
||||||
|
{
|
||||||
|
q = q.Where(p => p.Status == ProposalStatus.DaGuiDuyet);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||||
|
{
|
||||||
|
var s = req.Search.Trim();
|
||||||
|
q = q.Where(p => p.Title.Contains(s) || (p.MaDeXuat != null && p.MaDeXuat.Contains(s)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
|
||||||
|
var items = await q.OrderByDescending(p => p.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(p => new ProposalListItemDto(
|
||||||
|
p.Id,
|
||||||
|
p.MaDeXuat,
|
||||||
|
p.Title,
|
||||||
|
p.AmountEstimate,
|
||||||
|
(int)p.Status,
|
||||||
|
p.DepartmentId,
|
||||||
|
db.Departments.Where(d => d.Id == p.DepartmentId).Select(d => d.Name).FirstOrDefault(),
|
||||||
|
p.DrafterUserId,
|
||||||
|
db.Users.Where(u => u.Id == p.DrafterUserId).Select(u => u.FullName).FirstOrDefault(),
|
||||||
|
p.ApprovalWorkflowId,
|
||||||
|
db.ApprovalWorkflows.Where(w => w.Id == p.ApprovalWorkflowId).Select(w => w.Code).FirstOrDefault(),
|
||||||
|
p.CurrentApprovalLevelOrder,
|
||||||
|
p.CreatedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new PagedResult<ProposalListItemDto>(items, total, page, pageSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record GetProposalByIdQuery(Guid Id) : IRequest<ProposalDetailDto?>;
|
||||||
|
|
||||||
|
public class GetProposalByIdHandler(IApplicationDbContext db)
|
||||||
|
: IRequestHandler<GetProposalByIdQuery, ProposalDetailDto?>
|
||||||
|
{
|
||||||
|
public async Task<ProposalDetailDto?> Handle(GetProposalByIdQuery req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.Proposals.AsNoTracking()
|
||||||
|
.Include(x => x.Attachments)
|
||||||
|
.Include(x => x.LevelOpinions)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) return null;
|
||||||
|
|
||||||
|
var deptName = await db.Departments.AsNoTracking()
|
||||||
|
.Where(d => d.Id == p.DepartmentId).Select(d => d.Name).FirstOrDefaultAsync(ct);
|
||||||
|
var drafterName = await db.Users.AsNoTracking()
|
||||||
|
.Where(u => u.Id == p.DrafterUserId).Select(u => u.FullName).FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
string? wfCode = null;
|
||||||
|
string? wfName = null;
|
||||||
|
if (p.ApprovalWorkflowId.HasValue)
|
||||||
|
{
|
||||||
|
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||||
|
.Select(w => new { w.Code, w.Name })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
wfCode = wf?.Code;
|
||||||
|
wfName = wf?.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build LevelOpinions với Step+Level metadata via JOIN
|
||||||
|
var levelIds = p.LevelOpinions.Select(o => o.ApprovalWorkflowLevelId).Distinct().ToList();
|
||||||
|
var levels = await db.ApprovalWorkflowSteps.AsNoTracking()
|
||||||
|
.Where(s => s.Levels.Any(l => levelIds.Contains(l.Id)))
|
||||||
|
.Select(s => new
|
||||||
|
{
|
||||||
|
s.Id,
|
||||||
|
s.Order,
|
||||||
|
s.Name,
|
||||||
|
Levels = s.Levels.Where(l => levelIds.Contains(l.Id))
|
||||||
|
.Select(l => new { l.Id, l.Order, l.ApproverUserId })
|
||||||
|
.ToList(),
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var levelLookup = levels.SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l }))
|
||||||
|
.ToDictionary(x => x.Level.Id);
|
||||||
|
|
||||||
|
var opinions = p.LevelOpinions
|
||||||
|
.Select(o =>
|
||||||
|
{
|
||||||
|
levelLookup.TryGetValue(o.ApprovalWorkflowLevelId, out var lvl);
|
||||||
|
return new ProposalLevelOpinionDto(
|
||||||
|
o.Id,
|
||||||
|
o.ApprovalWorkflowLevelId,
|
||||||
|
lvl?.Step.Order,
|
||||||
|
lvl?.Step.Name,
|
||||||
|
lvl?.Level.Order,
|
||||||
|
lvl?.Level.ApproverUserId,
|
||||||
|
o.Comment,
|
||||||
|
o.SignedAt,
|
||||||
|
o.SignedByUserId,
|
||||||
|
o.SignedByFullName);
|
||||||
|
})
|
||||||
|
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var attachments = p.Attachments.Select(a => new ProposalAttachmentDto(
|
||||||
|
a.Id, a.FileName, a.FilePath, a.FileSize, a.MimeType,
|
||||||
|
a.UploadedByUserId, a.UploadedByFullName, a.CreatedAt)).ToList();
|
||||||
|
|
||||||
|
return new ProposalDetailDto(
|
||||||
|
p.Id, p.MaDeXuat, p.Title, p.Description, p.AmountEstimate, (int)p.Status,
|
||||||
|
p.DepartmentId, deptName, p.DrafterUserId, drafterName,
|
||||||
|
p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
|
||||||
|
p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null,
|
||||||
|
p.CreatedAt, attachments, opinions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateProposalCommand(
|
||||||
|
string Title,
|
||||||
|
string? Description,
|
||||||
|
decimal? AmountEstimate,
|
||||||
|
Guid? DepartmentId,
|
||||||
|
Guid? ApprovalWorkflowId) : IRequest<Guid>;
|
||||||
|
|
||||||
|
public class CreateProposalValidator : AbstractValidator<CreateProposalCommand>
|
||||||
|
{
|
||||||
|
public CreateProposalValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Title).NotEmpty().MaximumLength(300);
|
||||||
|
RuleFor(x => x.Description).MaximumLength(5000);
|
||||||
|
RuleFor(x => x.AmountEstimate).GreaterThanOrEqualTo(0).When(x => x.AmountEstimate.HasValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateProposalHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||||
|
: IRequestHandler<CreateProposalCommand, Guid>
|
||||||
|
{
|
||||||
|
public async Task<Guid> Handle(CreateProposalCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is null)
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
|
||||||
|
// Verify workflow ApplicableType=ProposalGeneral=4 nếu pin
|
||||||
|
if (req.ApprovalWorkflowId.HasValue)
|
||||||
|
{
|
||||||
|
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Where(w => w.Id == req.ApprovalWorkflowId.Value)
|
||||||
|
.Select(w => (int?)w.ApplicableType)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (wfType is null)
|
||||||
|
throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId.Value);
|
||||||
|
if (wfType.Value != (int)ApprovalWorkflowApplicableType.ProposalGeneral)
|
||||||
|
throw new ConflictException("Quy trình duyệt không thuộc loại Đề xuất.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var entity = new Proposal
|
||||||
|
{
|
||||||
|
Title = req.Title.Trim(),
|
||||||
|
Description = req.Description?.Trim(),
|
||||||
|
AmountEstimate = req.AmountEstimate,
|
||||||
|
DepartmentId = req.DepartmentId,
|
||||||
|
DrafterUserId = currentUser.UserId.Value,
|
||||||
|
ApprovalWorkflowId = req.ApprovalWorkflowId,
|
||||||
|
Status = ProposalStatus.Nhap,
|
||||||
|
CreatedAt = clock.UtcNow,
|
||||||
|
CreatedBy = currentUser.UserId,
|
||||||
|
};
|
||||||
|
db.Proposals.Add(entity);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return entity.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UpdateProposalDraftCommand(
|
||||||
|
Guid Id,
|
||||||
|
string Title,
|
||||||
|
string? Description,
|
||||||
|
decimal? AmountEstimate,
|
||||||
|
Guid? DepartmentId,
|
||||||
|
Guid? ApprovalWorkflowId) : IRequest;
|
||||||
|
|
||||||
|
public class UpdateProposalDraftValidator : AbstractValidator<UpdateProposalDraftCommand>
|
||||||
|
{
|
||||||
|
public UpdateProposalDraftValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Title).NotEmpty().MaximumLength(300);
|
||||||
|
RuleFor(x => x.Description).MaximumLength(5000);
|
||||||
|
RuleFor(x => x.AmountEstimate).GreaterThanOrEqualTo(0).When(x => x.AmountEstimate.HasValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateProposalDraftHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||||
|
: IRequestHandler<UpdateProposalDraftCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(UpdateProposalDraftCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.Proposals.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("Proposal", req.Id);
|
||||||
|
|
||||||
|
// Only Drafter (or admin) can edit, only when Nhap/TraLai
|
||||||
|
var isOwner = p.DrafterUserId == currentUser.UserId;
|
||||||
|
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||||
|
if (!isOwner && !isAdmin)
|
||||||
|
throw new ForbiddenException("Chỉ người soạn hoặc Admin được sửa đề xuất.");
|
||||||
|
if (p.Status != ProposalStatus.Nhap && p.Status != ProposalStatus.TraLai)
|
||||||
|
throw new ConflictException("Chỉ sửa được khi trạng thái Nháp hoặc Trả lại.");
|
||||||
|
|
||||||
|
// Verify workflow if changed
|
||||||
|
if (req.ApprovalWorkflowId.HasValue && req.ApprovalWorkflowId != p.ApprovalWorkflowId)
|
||||||
|
{
|
||||||
|
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Where(w => w.Id == req.ApprovalWorkflowId.Value)
|
||||||
|
.Select(w => (int?)w.ApplicableType)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (wfType is null)
|
||||||
|
throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId.Value);
|
||||||
|
if (wfType.Value != (int)ApprovalWorkflowApplicableType.ProposalGeneral)
|
||||||
|
throw new ConflictException("Quy trình duyệt không thuộc loại Đề xuất.");
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Title = req.Title.Trim();
|
||||||
|
p.Description = req.Description?.Trim();
|
||||||
|
p.AmountEstimate = req.AmountEstimate;
|
||||||
|
p.DepartmentId = req.DepartmentId;
|
||||||
|
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = currentUser.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Region 2: Workflow Actions =====
|
||||||
|
|
||||||
|
public record SubmitProposalCommand(Guid Id) : IRequest;
|
||||||
|
|
||||||
|
public class SubmitProposalHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||||
|
: IRequestHandler<SubmitProposalCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(SubmitProposalCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.Proposals.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("Proposal", req.Id);
|
||||||
|
|
||||||
|
var isOwner = p.DrafterUserId == currentUser.UserId;
|
||||||
|
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||||
|
if (!isOwner && !isAdmin)
|
||||||
|
throw new ForbiddenException("Chỉ người soạn hoặc Admin được gửi duyệt.");
|
||||||
|
if (p.Status != ProposalStatus.Nhap && p.Status != ProposalStatus.TraLai)
|
||||||
|
throw new ConflictException("Chỉ gửi duyệt được khi trạng thái Nháp hoặc Trả lại.");
|
||||||
|
if (!p.ApprovalWorkflowId.HasValue)
|
||||||
|
throw new ConflictException("Chưa chọn quy trình duyệt.");
|
||||||
|
|
||||||
|
// Gen MaDeXuat nếu null (lần Submit đầu tiên)
|
||||||
|
if (string.IsNullOrEmpty(p.MaDeXuat))
|
||||||
|
{
|
||||||
|
p.MaDeXuat = await GenerateMaDeXuatAsync(db, clock.Now.Year, clock, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Status = ProposalStatus.DaGuiDuyet;
|
||||||
|
p.CurrentApprovalLevelOrder = 1;
|
||||||
|
p.RejectedFromStatus = null;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = currentUser.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static async Task<string> GenerateMaDeXuatAsync(
|
||||||
|
IApplicationDbContext db, int year, IDateTime clock, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var prefix = $"DX/{year}";
|
||||||
|
var dbContext = (DbContext)db;
|
||||||
|
await using var tx = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
|
||||||
|
var seq = await db.ProposalCodeSequences.FirstOrDefaultAsync(s => s.Prefix == prefix, ct);
|
||||||
|
if (seq is null)
|
||||||
|
{
|
||||||
|
seq = new ProposalCodeSequence { Prefix = prefix, LastSeq = 0, UpdatedAt = clock.UtcNow };
|
||||||
|
db.ProposalCodeSequences.Add(seq);
|
||||||
|
}
|
||||||
|
seq.LastSeq++;
|
||||||
|
seq.UpdatedAt = clock.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
await tx.CommitAsync(ct);
|
||||||
|
return $"{prefix}/{seq.LastSeq:D3}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ApproveProposalCommand(Guid Id, string? Comment) : IRequest;
|
||||||
|
|
||||||
|
public class ApproveProposalHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||||
|
: IRequestHandler<ApproveProposalCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(ApproveProposalCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is null) throw new UnauthorizedException();
|
||||||
|
|
||||||
|
var p = await db.Proposals
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("Proposal", req.Id);
|
||||||
|
if (p.Status != ProposalStatus.DaGuiDuyet)
|
||||||
|
throw new ConflictException("Chỉ duyệt được khi trạng thái Đã gửi duyệt.");
|
||||||
|
if (!p.ApprovalWorkflowId.HasValue || !p.CurrentApprovalLevelOrder.HasValue)
|
||||||
|
throw new ConflictException("Quy trình duyệt chưa pin hoặc thiếu cấp hiện tại.");
|
||||||
|
|
||||||
|
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||||
|
if (wf is null) throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||||
|
|
||||||
|
// Tìm Step hiện tại = chứa Level Order == CurrentApprovalLevelOrder
|
||||||
|
// Multi-step workflow: traverse step-by-step. Lite version: assume 1 step per workflow
|
||||||
|
// hoặc CurrentApprovalLevelOrder reset mỗi step (mirror PE design).
|
||||||
|
// Đây dùng global level order trong workflow (sum tất cả level cross steps).
|
||||||
|
var allLevels = wf.Steps.OrderBy(s => s.Order)
|
||||||
|
.SelectMany(s => s.Levels.OrderBy(l => l.Order).Select(l => new { Step = s, Level = l }))
|
||||||
|
.ToList();
|
||||||
|
if (allLevels.Count == 0)
|
||||||
|
throw new ConflictException("Quy trình duyệt không có cấp duyệt.");
|
||||||
|
|
||||||
|
var currentSlot = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||||
|
if (currentSlot is null)
|
||||||
|
throw new ConflictException($"Cấp duyệt {p.CurrentApprovalLevelOrder.Value} không tồn tại trong quy trình.");
|
||||||
|
|
||||||
|
// Verify actor match — must be ApproverUserId hoặc Admin override
|
||||||
|
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||||
|
if (!isAdmin && currentSlot.Level.ApproverUserId != currentUser.UserId.Value)
|
||||||
|
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||||
|
|
||||||
|
// UPSERT LevelOpinion
|
||||||
|
var existing = await db.ProposalLevelOpinions
|
||||||
|
.FirstOrDefaultAsync(o => o.ProposalId == p.Id && o.ApprovalWorkflowLevelId == currentSlot.Level.Id, ct);
|
||||||
|
var commentFinal = string.IsNullOrWhiteSpace(req.Comment)
|
||||||
|
? "(duyệt — không ý kiến)"
|
||||||
|
: req.Comment.Trim();
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
db.ProposalLevelOpinions.Add(new ProposalLevelOpinion
|
||||||
|
{
|
||||||
|
ProposalId = p.Id,
|
||||||
|
ApprovalWorkflowLevelId = currentSlot.Level.Id,
|
||||||
|
Comment = commentFinal,
|
||||||
|
SignedAt = clock.UtcNow,
|
||||||
|
SignedByUserId = currentUser.UserId.Value,
|
||||||
|
SignedByFullName = currentUser.FullName ?? "(unknown)",
|
||||||
|
CreatedAt = clock.UtcNow,
|
||||||
|
CreatedBy = currentUser.UserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.Comment = commentFinal;
|
||||||
|
existing.SignedAt = clock.UtcNow;
|
||||||
|
existing.SignedByUserId = currentUser.UserId.Value;
|
||||||
|
existing.SignedByFullName = currentUser.FullName ?? "(unknown)";
|
||||||
|
existing.UpdatedAt = clock.UtcNow;
|
||||||
|
existing.UpdatedBy = currentUser.UserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance level OR terminal
|
||||||
|
if (p.CurrentApprovalLevelOrder.Value < allLevels.Count)
|
||||||
|
{
|
||||||
|
p.CurrentApprovalLevelOrder = p.CurrentApprovalLevelOrder.Value + 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
p.Status = ProposalStatus.DaDuyet;
|
||||||
|
p.CurrentApprovalLevelOrder = null;
|
||||||
|
}
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = currentUser.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RejectProposalCommand(Guid Id, string? Comment) : IRequest;
|
||||||
|
|
||||||
|
public class RejectProposalHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||||
|
: IRequestHandler<RejectProposalCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(RejectProposalCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.Proposals.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("Proposal", req.Id);
|
||||||
|
if (p.Status != ProposalStatus.DaGuiDuyet)
|
||||||
|
throw new ConflictException("Chỉ từ chối được khi đang trong workflow duyệt.");
|
||||||
|
|
||||||
|
// Verify actor (lite — only check if has Admin role or matches any level approver)
|
||||||
|
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||||
|
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||||
|
{
|
||||||
|
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||||
|
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||||
|
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||||
|
.ToList() ?? new();
|
||||||
|
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||||
|
if (currentLevel?.ApproverUserId != currentUser.UserId)
|
||||||
|
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Status = ProposalStatus.TuChoi;
|
||||||
|
p.CurrentApprovalLevelOrder = null;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = currentUser.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ReturnProposalCommand(Guid Id, string? Comment) : IRequest;
|
||||||
|
|
||||||
|
public class ReturnProposalHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||||
|
: IRequestHandler<ReturnProposalCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(ReturnProposalCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.Proposals.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("Proposal", req.Id);
|
||||||
|
if (p.Status != ProposalStatus.DaGuiDuyet)
|
||||||
|
throw new ConflictException("Chỉ trả lại được khi đang trong workflow duyệt.");
|
||||||
|
|
||||||
|
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||||
|
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||||
|
{
|
||||||
|
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||||
|
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||||
|
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||||
|
.ToList() ?? new();
|
||||||
|
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||||
|
if (currentLevel?.ApproverUserId != currentUser.UserId)
|
||||||
|
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Status = ProposalStatus.TraLai;
|
||||||
|
p.RejectedFromStatus = ProposalStatus.DaGuiDuyet;
|
||||||
|
p.CurrentApprovalLevelOrder = null;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = currentUser.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -47,6 +47,14 @@ public enum ApprovalWorkflowApplicableType
|
|||||||
DuyetNcc = 1, // PE module — Duyệt NCC (default test target)
|
DuyetNcc = 1, // PE module — Duyệt NCC (default test target)
|
||||||
DuyetNccPhuongAn = 2, // PE — Duyệt NCC + Giải pháp
|
DuyetNccPhuongAn = 2, // PE — Duyệt NCC + Giải pháp
|
||||||
Contract = 3, // HĐ general (any ContractType)
|
Contract = 3, // HĐ general (any ContractType)
|
||||||
|
|
||||||
|
// Phase 10.3 Workflow Apps (S37 — Mig 37 enum extend). Cookie-cutter Mig 22 V2 pattern.
|
||||||
|
// Mỗi module có 1 workflow per ApplicableType, admin Designer config + pin per draft.
|
||||||
|
ProposalGeneral = 4, // G-O3 — Đề xuất (Proposal)
|
||||||
|
LeaveRequest = 5, // G-O4 — Đơn nghỉ phép
|
||||||
|
OtRequest = 6, // G-O4 — Đơn OT
|
||||||
|
VehicleBooking = 7, // G-O5 — Đặt xe công
|
||||||
|
ItTicket = 8, // G-O6 — Ticket CNTT
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bước = Phòng. 1 quy trình có nhiều bước theo Order.
|
// Bước = Phòng. 1 quy trình có nhiều bước theo Order.
|
||||||
|
|||||||
@ -105,6 +105,12 @@ public static class MenuKeys
|
|||||||
public const string OffPhongHopManage = "Off_PhongHop_Manage"; // Quản lý phòng (Admin CRUD MeetingRoom)
|
public const string OffPhongHopManage = "Off_PhongHop_Manage"; // Quản lý phòng (Admin CRUD MeetingRoom)
|
||||||
public const string OffPhongHopBook = "Off_PhongHop_Book"; // Đặt phòng (Create/Update/Cancel Booking)
|
public const string OffPhongHopBook = "Off_PhongHop_Book"; // Đặt phòng (Create/Update/Cancel Booking)
|
||||||
|
|
||||||
|
// Phase 10.3 G-O3 (Mig 38 — S37 2026-05-28) — Đề xuất (Proposal) workflow V2.
|
||||||
|
public const string OffDeXuat = "Off_DeXuat"; // sub-group Đề xuất
|
||||||
|
public const string OffDeXuatList = "Off_DeXuat_List"; // Danh sách đề xuất
|
||||||
|
public const string OffDeXuatCreate = "Off_DeXuat_Create"; // Tạo đề xuất mới
|
||||||
|
public const string OffDeXuatInbox = "Off_DeXuat_Inbox"; // Inbox phê duyệt
|
||||||
|
|
||||||
public static readonly string[] PurchaseEvaluationTypeCodes =
|
public static readonly string[] PurchaseEvaluationTypeCodes =
|
||||||
["DuyetNcc", "DuyetNccPhuongAn"];
|
["DuyetNcc", "DuyetNccPhuongAn"];
|
||||||
|
|
||||||
@ -133,6 +139,7 @@ public static class MenuKeys
|
|||||||
HrmConfig, HrmConfigLeaveTypes, HrmConfigHolidays, HrmConfigShifts, HrmConfigOtPolicies, // Mig 35 — Phase 10.2 G-H2
|
HrmConfig, HrmConfigLeaveTypes, HrmConfigHolidays, HrmConfigShifts, HrmConfigOtPolicies, // Mig 35 — Phase 10.2 G-H2
|
||||||
Off, OffDanhBa, // Phase 10.2 G-O1 — Văn phòng số
|
Off, OffDanhBa, // Phase 10.2 G-O1 — Văn phòng số
|
||||||
OffPhongHop, OffPhongHopView, OffPhongHopManage, OffPhongHopBook, // Phase 10.2 G-O2 — Phòng họp
|
OffPhongHop, OffPhongHopView, OffPhongHopManage, OffPhongHopBook, // Phase 10.2 G-O2 — Phòng họp
|
||||||
|
OffDeXuat, OffDeXuatList, OffDeXuatCreate, OffDeXuatInbox, // Phase 10.3 G-O3 — Đề xuất
|
||||||
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
|
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
|
||||||
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
|
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
|
||||||
];
|
];
|
||||||
|
|||||||
@ -10,3 +10,15 @@ public enum MeetingBookingStatus
|
|||||||
Cancelled = 2, // Đã huỷ
|
Cancelled = 2, // Đã huỷ
|
||||||
Completed = 3, // Đã kết thúc (auto-set khi EndAt < Now via job/manual)
|
Completed = 3, // Đã kết thúc (auto-set khi EndAt < Now via job/manual)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 10.3 G-O3 (Mig 38 — S37 2026-05-28) — Trạng thái Đề xuất (Proposal).
|
||||||
|
// 5-state mirror PE/Contract pattern (Nháp / Đã gửi duyệt / Trả lại / Từ chối / Đã duyệt).
|
||||||
|
// Workflow V2 dynamic theo ApprovalWorkflow.ApplicableType=ProposalGeneral=4.
|
||||||
|
public enum ProposalStatus
|
||||||
|
{
|
||||||
|
Nhap = 1, // Drafter đang soạn, chưa gửi duyệt
|
||||||
|
DaGuiDuyet = 2, // Trong workflow approve flow (CurrentApprovalLevelOrder)
|
||||||
|
TraLai = 3, // Approver trả về Drafter sửa → resubmit lại từ Cấp 1
|
||||||
|
TuChoi = 4, // Terminal — không thể edit/resubmit
|
||||||
|
DaDuyet = 5, // Terminal — workflow complete tất cả Cấp
|
||||||
|
}
|
||||||
|
|||||||
38
src/Backend/SolutionErp.Domain/Office/Proposal.cs
Normal file
38
src/Backend/SolutionErp.Domain/Office/Proposal.cs
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
using SolutionErp.Domain.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
// Phase 10.3 G-O3 (Mig 38 — S37 2026-05-28) — Đề xuất (Proposal) aggregate root.
|
||||||
|
// Mirror PE Plan B Mig 22-26 pattern cookie-cutter: Workflow V2 dynamic (ApplicableType=4) +
|
||||||
|
// LevelOpinions UPSERT auto via ApproveV2Async + atomic CodeGen DX/YYYY/NNN.
|
||||||
|
// Reference: PurchaseEvaluation.cs (PE module flagship V2 mirror source).
|
||||||
|
public class Proposal : AuditableEntity
|
||||||
|
{
|
||||||
|
public string? MaDeXuat { get; set; } // Auto-gen "DX/YYYY/NNN" qua ProposalCodeSequence
|
||||||
|
|
||||||
|
public string Title { get; set; } = string.Empty; // "Đề xuất mua máy tính", "Đề xuất tăng ngân sách"
|
||||||
|
public string? Description { get; set; } // Nội dung chi tiết (max 5000)
|
||||||
|
|
||||||
|
// Tổng số tiền dự kiến (đ). Optional — có đề xuất không số tiền (vd policy).
|
||||||
|
public decimal? AmountEstimate { get; set; }
|
||||||
|
|
||||||
|
public ProposalStatus Status { get; set; } = ProposalStatus.Nhap;
|
||||||
|
|
||||||
|
public Guid? DepartmentId { get; set; } // Phòng ban đề xuất (org filter)
|
||||||
|
public Guid DrafterUserId { get; set; } // User tạo đề xuất
|
||||||
|
|
||||||
|
// Workflow V2 pin (cookie-cutter PE Mig 22-23-24).
|
||||||
|
// ApplicableType phải = ProposalGeneral=4 (Mig 37 enum extend).
|
||||||
|
public Guid? ApprovalWorkflowId { get; set; }
|
||||||
|
public int? CurrentApprovalLevelOrder { get; set; } // Cấp đang chờ (1-based) khi DaGuiDuyet
|
||||||
|
|
||||||
|
// SLA (mirror PE)
|
||||||
|
public DateTime? SlaDeadline { get; set; }
|
||||||
|
public bool SlaWarningSent { get; set; }
|
||||||
|
|
||||||
|
// Smart reject (mirror PE Mig 16)
|
||||||
|
public ProposalStatus? RejectedFromStatus { get; set; }
|
||||||
|
|
||||||
|
public List<ProposalAttachment> Attachments { get; set; } = new();
|
||||||
|
public List<ProposalLevelOpinion> LevelOpinions { get; set; } = new();
|
||||||
|
}
|
||||||
20
src/Backend/SolutionErp.Domain/Office/ProposalAttachment.cs
Normal file
20
src/Backend/SolutionErp.Domain/Office/ProposalAttachment.cs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
using SolutionErp.Domain.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
// Phase 10.3 G-O3 (Mig 38 — S37) — Attachment cho Đề xuất.
|
||||||
|
// FK Cascade Proposal (xoá Đề xuất → wipe Attachments).
|
||||||
|
// Mirror PurchaseEvaluationAttachment pattern.
|
||||||
|
public class ProposalAttachment : AuditableEntity
|
||||||
|
{
|
||||||
|
public Guid ProposalId { get; set; }
|
||||||
|
public Proposal Proposal { get; set; } = null!;
|
||||||
|
|
||||||
|
public string FileName { get; set; } = string.Empty; // Original file name display
|
||||||
|
public string FilePath { get; set; } = string.Empty; // Server file path / blob URL
|
||||||
|
public long FileSize { get; set; } // Bytes
|
||||||
|
public string? MimeType { get; set; } // "application/pdf", "image/png"
|
||||||
|
|
||||||
|
public Guid UploadedByUserId { get; set; } // Drafter or approver tải lên
|
||||||
|
public string UploadedByFullName { get; set; } = string.Empty; // denorm
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
namespace SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
// Phase 10.3 G-O3 (Mig 38 — S37) — Sequence generator cho mã Đề xuất (MaDeXuat).
|
||||||
|
// Mirror PurchaseEvaluationCodeSequence pattern (Prefix string PK + LastSeq atomic).
|
||||||
|
// Format: "DX/YYYY/NNN" — vd "DX/2026/001" → "DX/2026/002" → ...
|
||||||
|
// Prefix = "DX/{YYYY}" cho từng năm. LastSeq reset đầu năm tự nhiên (key mới).
|
||||||
|
// Update atomic qua SERIALIZABLE transaction trong CodeGen service.
|
||||||
|
public class ProposalCodeSequence
|
||||||
|
{
|
||||||
|
public string Prefix { get; set; } = string.Empty; // PK — "DX/2026"
|
||||||
|
public int LastSeq { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
|
using SolutionErp.Domain.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
// Phase 10.3 G-O3 (Mig 38 — S37) — Ý kiến cấp duyệt V2 dynamic cho Proposal.
|
||||||
|
// Cookie-cutter mirror PurchaseEvaluationLevelOpinion (Mig 26).
|
||||||
|
//
|
||||||
|
// Mỗi row = 1 (Proposal × ApprovalWorkflowLevel). Service ApproveV2Async sau
|
||||||
|
// khi approve thành công Cấp hiện tại sẽ UPSERT row này (latest-write-wins).
|
||||||
|
// Reject (TraLai/TuChoi) KHÔNG sync.
|
||||||
|
//
|
||||||
|
// UNIQUE composite (ProposalId, LevelId) — 1 row / level / proposal.
|
||||||
|
// FK Cascade Proposal (wipe khi xoá) + Restrict Level (admin xoá Level chặn).
|
||||||
|
// SignedByUserId track actor thật (có thể Admin override) + denorm FullName.
|
||||||
|
public class ProposalLevelOpinion : AuditableEntity
|
||||||
|
{
|
||||||
|
public Guid ProposalId { get; set; }
|
||||||
|
public Guid ApprovalWorkflowLevelId { get; set; }
|
||||||
|
|
||||||
|
public string? Comment { get; set; } // max 2000 hoặc placeholder
|
||||||
|
public DateTime SignedAt { get; set; }
|
||||||
|
public Guid SignedByUserId { get; set; } // người ký thực sự (có thể Admin thay)
|
||||||
|
public string SignedByFullName { get; set; } = string.Empty; // snapshot denorm
|
||||||
|
|
||||||
|
public Proposal? Proposal { get; set; }
|
||||||
|
public ApprovalWorkflowLevel? Level { get; set; }
|
||||||
|
}
|
||||||
@ -102,6 +102,12 @@ public class ApplicationDbContext
|
|||||||
public DbSet<MeetingBooking> MeetingBookings => Set<MeetingBooking>();
|
public DbSet<MeetingBooking> MeetingBookings => Set<MeetingBooking>();
|
||||||
public DbSet<MeetingBookingAttendee> MeetingBookingAttendees => Set<MeetingBookingAttendee>();
|
public DbSet<MeetingBookingAttendee> MeetingBookingAttendees => Set<MeetingBookingAttendee>();
|
||||||
|
|
||||||
|
// Phase 10.3 G-O3 (Mig 38 — S37) — Đề xuất + Attachment + LevelOpinion + CodeSequence.
|
||||||
|
public DbSet<Proposal> Proposals => Set<Proposal>();
|
||||||
|
public DbSet<ProposalAttachment> ProposalAttachments => Set<ProposalAttachment>();
|
||||||
|
public DbSet<ProposalLevelOpinion> ProposalLevelOpinions => Set<ProposalLevelOpinion>();
|
||||||
|
public DbSet<ProposalCodeSequence> ProposalCodeSequences => Set<ProposalCodeSequence>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(builder);
|
base.OnModelCreating(builder);
|
||||||
|
|||||||
@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
// EF Mig 38 G-O3 (S37) — Attachment cho Đề xuất.
|
||||||
|
// FK Cascade Proposal (wipe khi xoá Đề xuất).
|
||||||
|
public class ProposalAttachmentConfiguration : IEntityTypeConfiguration<ProposalAttachment>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ProposalAttachment> e)
|
||||||
|
{
|
||||||
|
e.ToTable("ProposalAttachments");
|
||||||
|
|
||||||
|
e.Property(x => x.FileName).HasMaxLength(500).IsRequired();
|
||||||
|
e.Property(x => x.FilePath).HasMaxLength(1000).IsRequired();
|
||||||
|
e.Property(x => x.MimeType).HasMaxLength(200);
|
||||||
|
e.Property(x => x.UploadedByFullName).HasMaxLength(200).IsRequired();
|
||||||
|
|
||||||
|
e.HasOne(x => x.Proposal)
|
||||||
|
.WithMany(p => p.Attachments)
|
||||||
|
.HasForeignKey(x => x.ProposalId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
e.HasIndex(x => x.ProposalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
// EF Mig 38 G-O3 (S37) — Sequence generator cho mã Đề xuất.
|
||||||
|
// Mirror PurchaseEvaluationCodeSequenceConfiguration pattern.
|
||||||
|
// PK = Prefix (string) — "DX/2026". LastSeq tăng atomic.
|
||||||
|
public class ProposalCodeSequenceConfiguration : IEntityTypeConfiguration<ProposalCodeSequence>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ProposalCodeSequence> e)
|
||||||
|
{
|
||||||
|
e.ToTable("ProposalCodeSequences");
|
||||||
|
|
||||||
|
e.HasKey(x => x.Prefix);
|
||||||
|
e.Property(x => x.Prefix).HasMaxLength(20); // "DX/2026" max 7 chars OK
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
// EF Mig 38 G-O3 (S37) — Đề xuất aggregate root.
|
||||||
|
// Cookie-cutter mirror PurchaseEvaluationConfiguration pattern.
|
||||||
|
public class ProposalConfiguration : IEntityTypeConfiguration<Proposal>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Proposal> e)
|
||||||
|
{
|
||||||
|
e.ToTable("Proposals");
|
||||||
|
|
||||||
|
e.Property(x => x.MaDeXuat).HasMaxLength(50); // "DX/2026/001"
|
||||||
|
e.Property(x => x.Title).HasMaxLength(300).IsRequired();
|
||||||
|
e.Property(x => x.Description).HasMaxLength(5000);
|
||||||
|
e.Property(x => x.AmountEstimate).HasColumnType("decimal(18,2)");
|
||||||
|
e.Property(x => x.Status).HasConversion<int>();
|
||||||
|
e.Property(x => x.RejectedFromStatus).HasConversion<int>();
|
||||||
|
|
||||||
|
// MaDeXuat optional UNIQUE (gen sau Submit lần đầu)
|
||||||
|
e.HasIndex(x => x.MaDeXuat).IsUnique().HasFilter("[MaDeXuat] IS NOT NULL");
|
||||||
|
|
||||||
|
// Index Status + Drafter cho list filter/inbox
|
||||||
|
e.HasIndex(x => x.Status);
|
||||||
|
e.HasIndex(x => x.DrafterUserId);
|
||||||
|
e.HasIndex(x => x.DepartmentId);
|
||||||
|
e.HasIndex(x => x.ApprovalWorkflowId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
// EF Mig 38 G-O3 (S37) — Ý kiến cấp duyệt V2 dynamic. UPSERT auto từ ApproveV2Async.
|
||||||
|
// Cookie-cutter mirror PurchaseEvaluationLevelOpinionConfiguration (Mig 26).
|
||||||
|
public class ProposalLevelOpinionConfiguration : IEntityTypeConfiguration<ProposalLevelOpinion>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ProposalLevelOpinion> e)
|
||||||
|
{
|
||||||
|
e.ToTable("ProposalLevelOpinions");
|
||||||
|
|
||||||
|
e.Property(x => x.Comment).HasMaxLength(2000);
|
||||||
|
e.Property(x => x.SignedByFullName).HasMaxLength(200).IsRequired();
|
||||||
|
|
||||||
|
e.HasOne(x => x.Proposal)
|
||||||
|
.WithMany(p => p.LevelOpinions)
|
||||||
|
.HasForeignKey(x => x.ProposalId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Level)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.ApprovalWorkflowLevelId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.ProposalId, x.ApprovalWorkflowLevelId }).IsUnique();
|
||||||
|
e.HasIndex(x => x.ApprovalWorkflowLevelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -132,6 +132,11 @@ public static class DbInitializer
|
|||||||
// Idempotent: skip nếu workflow QT-HD-V2-001 đã tồn tại.
|
// Idempotent: skip nếu workflow QT-HD-V2-001 đã tồn tại.
|
||||||
await SeedSampleContractWorkflowV2Async(db, userManager, logger);
|
await SeedSampleContractWorkflowV2Async(db, userManager, logger);
|
||||||
|
|
||||||
|
// Phase 10.3 G-O3 (Mig 38 — S37 2026-05-28). Infrastructure seed sample workflow
|
||||||
|
// ApplicableType=ProposalGeneral=4 cho Drafter Workspace dropdown.
|
||||||
|
// NOT gated DemoSeed per gotcha #51 INFRASTRUCTURE seed.
|
||||||
|
await SeedSampleProposalWorkflowV2Async(db, userManager, logger);
|
||||||
|
|
||||||
await WarnDefaultAdminPasswordAsync(userManager, logger);
|
await WarnDefaultAdminPasswordAsync(userManager, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -239,6 +244,56 @@ public static class DbInitializer
|
|||||||
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for Contract: QT-HD-V2-001 v01");
|
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for Contract: QT-HD-V2-001 v01");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 10.3 G-O3 (Mig 38 — S37) — Sample workflow Proposal cho UAT.
|
||||||
|
// Mirror SeedSampleContractWorkflowV2Async pattern. INFRASTRUCTURE seed NOT gated.
|
||||||
|
private static async Task SeedSampleProposalWorkflowV2Async(
|
||||||
|
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
|
||||||
|
{
|
||||||
|
var hasAnyProposal = await db.ApprovalWorkflows
|
||||||
|
.AnyAsync(w => w.ApplicableType == ApprovalWorkflowApplicableType.ProposalGeneral);
|
||||||
|
if (hasAnyProposal) return;
|
||||||
|
|
||||||
|
var approver = await userManager.FindByEmailAsync("binh.le@solutions.com.vn");
|
||||||
|
if (approver is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("SeedSampleProposalWorkflowV2Async: skip — approver binh.le@solutions.com.vn not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ccmDept = await db.Departments.FirstOrDefaultAsync(d => d.Code == "CCM");
|
||||||
|
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Code = "QT-DX-V2-001",
|
||||||
|
Version = 1,
|
||||||
|
ApplicableType = ApprovalWorkflowApplicableType.ProposalGeneral,
|
||||||
|
Name = "Quy trình duyệt Đề xuất mẫu V2",
|
||||||
|
Description = "Sample seed UAT — 1 Bước Phòng CCM × 1 Cấp (Lê Văn Bình). Admin có thể clone tạo version mới qua Designer.",
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true,
|
||||||
|
ActivatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
var step = new ApprovalWorkflowStep
|
||||||
|
{
|
||||||
|
ApprovalWorkflow = wf,
|
||||||
|
Order = 1,
|
||||||
|
Name = "Bước 1 - Phòng CCM",
|
||||||
|
DepartmentId = ccmDept?.Id,
|
||||||
|
};
|
||||||
|
var level = new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Step = step,
|
||||||
|
Order = 1,
|
||||||
|
Name = "Cấp 1",
|
||||||
|
ApproverUserId = approver.Id,
|
||||||
|
};
|
||||||
|
wf.Steps.Add(step);
|
||||||
|
step.Levels.Add(level);
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for Proposal: QT-DX-V2-001 v01");
|
||||||
|
}
|
||||||
|
|
||||||
// Seed 4 master catalogs với defaults cho user nhập liệu Details. Idempotent:
|
// Seed 4 master catalogs với defaults cho user nhập liệu Details. Idempotent:
|
||||||
// skip per-table nếu đã có row (admin có thể đã thêm/sửa — không clobber).
|
// skip per-table nếu đã có row (admin có thể đã thêm/sửa — không clobber).
|
||||||
private static async Task SeedCatalogsAsync(ApplicationDbContext db, ILogger logger)
|
private static async Task SeedCatalogsAsync(ApplicationDbContext db, ILogger logger)
|
||||||
@ -1507,6 +1562,11 @@ public static class DbInitializer
|
|||||||
(MenuKeys.OffPhongHopView, "Xem lịch", MenuKeys.OffPhongHop, 1, "CalendarDays"),
|
(MenuKeys.OffPhongHopView, "Xem lịch", MenuKeys.OffPhongHop, 1, "CalendarDays"),
|
||||||
(MenuKeys.OffPhongHopManage, "Quản lý phòng", MenuKeys.OffPhongHop, 2, "Building2"),
|
(MenuKeys.OffPhongHopManage, "Quản lý phòng", MenuKeys.OffPhongHop, 2, "Building2"),
|
||||||
(MenuKeys.OffPhongHopBook, "Đặt phòng", MenuKeys.OffPhongHop, 3, "CalendarPlus"),
|
(MenuKeys.OffPhongHopBook, "Đặt phòng", MenuKeys.OffPhongHop, 3, "CalendarPlus"),
|
||||||
|
// Phase 10.3 G-O3 (Mig 38 — S37 2026-05-28). Sub-group "Đề xuất" + 3 leaf.
|
||||||
|
(MenuKeys.OffDeXuat, "Đề xuất", MenuKeys.Off, 3, "FileSignature"),
|
||||||
|
(MenuKeys.OffDeXuatList, "Danh sách", MenuKeys.OffDeXuat, 1, "List"),
|
||||||
|
(MenuKeys.OffDeXuatCreate, "Tạo mới", MenuKeys.OffDeXuat, 2, "Plus"),
|
||||||
|
(MenuKeys.OffDeXuatInbox, "Inbox duyệt", MenuKeys.OffDeXuat, 3, "Inbox"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Per-type sub-menu under Contracts: 1 group + 3 leaves each
|
// Per-type sub-menu under Contracts: 1 group + 3 leaves each
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ExtendApplicableTypeForWorkflowApps : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5428
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260528082726_AddProposals.Designer.cs
generated
Normal file
5428
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260528082726_AddProposals.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,184 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddProposals : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ProposalCodeSequences",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Prefix = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||||
|
LastSeq = table.Column<int>(type: "int", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ProposalCodeSequences", x => x.Prefix);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Proposals",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
MaDeXuat = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||||
|
Title = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
|
||||||
|
Description = table.Column<string>(type: "nvarchar(max)", maxLength: 5000, nullable: true),
|
||||||
|
AmountEstimate = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
DepartmentId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
DrafterUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
ApprovalWorkflowId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
CurrentApprovalLevelOrder = table.Column<int>(type: "int", nullable: true),
|
||||||
|
SlaDeadline = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
SlaWarningSent = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
RejectedFromStatus = table.Column<int>(type: "int", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Proposals", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ProposalAttachments",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
ProposalId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
FileName = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
|
||||||
|
FilePath = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
|
||||||
|
FileSize = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
MimeType = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||||
|
UploadedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
UploadedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ProposalAttachments", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ProposalAttachments_Proposals_ProposalId",
|
||||||
|
column: x => x.ProposalId,
|
||||||
|
principalTable: "Proposals",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ProposalLevelOpinions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
ProposalId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
ApprovalWorkflowLevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
Comment = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||||
|
SignedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
SignedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
SignedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ProposalLevelOpinions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ProposalLevelOpinions_ApprovalWorkflowLevels_ApprovalWorkflowLevelId",
|
||||||
|
column: x => x.ApprovalWorkflowLevelId,
|
||||||
|
principalTable: "ApprovalWorkflowLevels",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ProposalLevelOpinions_Proposals_ProposalId",
|
||||||
|
column: x => x.ProposalId,
|
||||||
|
principalTable: "Proposals",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ProposalAttachments_ProposalId",
|
||||||
|
table: "ProposalAttachments",
|
||||||
|
column: "ProposalId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ProposalLevelOpinions_ApprovalWorkflowLevelId",
|
||||||
|
table: "ProposalLevelOpinions",
|
||||||
|
column: "ApprovalWorkflowLevelId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ProposalLevelOpinions_ProposalId_ApprovalWorkflowLevelId",
|
||||||
|
table: "ProposalLevelOpinions",
|
||||||
|
columns: new[] { "ProposalId", "ApprovalWorkflowLevelId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Proposals_ApprovalWorkflowId",
|
||||||
|
table: "Proposals",
|
||||||
|
column: "ApprovalWorkflowId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Proposals_DepartmentId",
|
||||||
|
table: "Proposals",
|
||||||
|
column: "DepartmentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Proposals_DrafterUserId",
|
||||||
|
table: "Proposals",
|
||||||
|
column: "DrafterUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Proposals_MaDeXuat",
|
||||||
|
table: "Proposals",
|
||||||
|
column: "MaDeXuat",
|
||||||
|
unique: true,
|
||||||
|
filter: "[MaDeXuat] IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Proposals_Status",
|
||||||
|
table: "Proposals",
|
||||||
|
column: "Status");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ProposalAttachments");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ProposalCodeSequences");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ProposalLevelOpinions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Proposals");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3623,6 +3623,227 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("MeetingRooms", (string)null);
|
b.ToTable("MeetingRooms", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Office.Proposal", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<decimal?>("AmountEstimate")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ApprovalWorkflowId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int?>("CurrentApprovalLevelOrder")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeletedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DepartmentId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(5000)
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<Guid>("DrafterUserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("MaDeXuat")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<int?>("RejectedFromStatus")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("SlaDeadline")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<bool>("SlaWarningSent")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("nvarchar(300)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ApprovalWorkflowId");
|
||||||
|
|
||||||
|
b.HasIndex("DepartmentId");
|
||||||
|
|
||||||
|
b.HasIndex("DrafterUserId");
|
||||||
|
|
||||||
|
b.HasIndex("MaDeXuat")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("[MaDeXuat] IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
|
b.ToTable("Proposals", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Office.ProposalAttachment", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeletedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<string>("FilePath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<long>("FileSize")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("MimeType")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProposalId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("UploadedByFullName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UploadedByUserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ProposalId");
|
||||||
|
|
||||||
|
b.ToTable("ProposalAttachments", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Office.ProposalCodeSequence", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Prefix")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("nvarchar(20)");
|
||||||
|
|
||||||
|
b.Property<int>("LastSeq")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.HasKey("Prefix");
|
||||||
|
|
||||||
|
b.ToTable("ProposalCodeSequences", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Office.ProposalLevelOpinion", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid>("ApprovalWorkflowLevelId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Comment")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeletedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProposalId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime>("SignedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("SignedByFullName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<Guid>("SignedByUserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ApprovalWorkflowLevelId");
|
||||||
|
|
||||||
|
b.HasIndex("ProposalId", "ApprovalWorkflowLevelId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("ProposalLevelOpinions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
|
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@ -4887,6 +5108,36 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("Booking");
|
b.Navigation("Booking");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Office.ProposalAttachment", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.Office.Proposal", "Proposal")
|
||||||
|
.WithMany("Attachments")
|
||||||
|
.HasForeignKey("ProposalId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Proposal");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Office.ProposalLevelOpinion", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ApprovalWorkflowLevelId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("SolutionErp.Domain.Office.Proposal", "Proposal")
|
||||||
|
.WithMany("LevelOpinions")
|
||||||
|
.HasForeignKey("ProposalId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Level");
|
||||||
|
|
||||||
|
b.Navigation("Proposal");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
|
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", null)
|
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", null)
|
||||||
@ -5126,6 +5377,13 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("Attendees");
|
b.Navigation("Attendees");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Office.Proposal", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Attachments");
|
||||||
|
|
||||||
|
b.Navigation("LevelOpinions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
|
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Approvals");
|
b.Navigation("Approvals");
|
||||||
|
|||||||
Reference in New Issue
Block a user