[CLAUDE] Workflow: wire ApproveV2 + LevelOpinions cho 4 WorkflowApps module (Phase 11 P11-A)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m6s

Wire full approval workflow V2 cho Leave/OT/Travel/Vehicle — cookie-cutter
mirror Proposal (Mig 38). Trước đây skeleton Phase 1 (Create+List), giờ
ApproveV2 advance-level + UPSERT LevelOpinion + atomic codegen.

Schema (Mig 41 WireWorkflowAppsApprovalV2 — 84→89 tables, pure additive):
- 4 bảng {Leave,Ot,Travel,Vehicle}LevelOpinions (UNIQUE composite + Cascade
  parent + Restrict Level — mirror ProposalLevelOpinion)
- 1 bảng WorkflowAppCodeSequences (shared atomic MaDonTu, Prefix-keyed)
- 4 cột RejectedFromStatus (smart return tracking)
- enum ApprovalWorkflowApplicableType.TravelRequest = 9

Application (LeaveOt + TravelVehicle ApprovalFeatures.cs — 30 handler):
- GetById detail (Include LevelOpinions + JOIN Step/Level) · UpdateDraft
- Submit (gen MaDonTu + DaGuiDuyet + level=1, verify ApplicableType per module)
- Approve (verify actor==ApproverUserId OR Admin, UPSERT opinion latest-write-wins,
  advance level OR terminal DaDuyet, empty comment → placeholder)
- Reject (→TuChoi) · Return (→TraLai + RejectedFromStatus)

Api: 4 controller +6 route mỗi cái (GET/{id}, PUT/{id}, submit/approve/reject/return)
Infra: DbInitializer seed 4 workflow V2 mẫu (QT-NP/OT/CT/XE-V2-001) → UAT test ngay
FE: WorkflowAppDetailPage.tsx declarative 4-kind (fe-admin+fe-user SHA256 identical)
  — workflow status + opinion timeline + action buttons; gỡ banner skeleton + row nav

Tests: +11 WorkflowAppApproveV2Tests (130→141 PASS) — state machine + UPSERT
  invariant + guards + codegen + forbidden + placeholder (Leave full + Ot smoke)

Verify: build 0 error · 141 test PASS · FE build ×2 · reviewer checklist
  (ApplicableType per-module + cross-module DbSet + [Authorize] — no copy-paste bug)
Known-minor (unreachable): Reject/Return actor-check skip nếu CurrentApprovalLevelOrder
  null — nhưng DaGuiDuyet luôn có set (defer hardening).
ItTicket KHÔNG đụng (kanban, no workflow V2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-30 09:44:00 +07:00
parent ad1dea9349
commit e7b66cd52b
39 changed files with 10604 additions and 22 deletions

View File

@ -0,0 +1,423 @@
// Generic Workflow App Detail page — Phase 11 P11-A Wave 3a (S42 2026-05-30).
// Declarative KIND_CONFIG Record<Kind> mirror WorkflowAppsListPage — 4 module
// leave / ot / travel / vehicle. Workflow status + Ý kiến cấp duyệt timeline +
// Submit/Approve/Reject/Return actions (mirror ProposalDetailPage cấu trúc).
// 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, CalendarOff, Car, CheckCircle2, Clock, Plane, 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 {
WORKFLOW_APP_STATUS_BADGE, WORKFLOW_APP_STATUS_LABELS,
WorkflowAppStatus, type WorkflowAppDetail,
} from '@/types/workflowApps'
type Kind = 'leave' | 'ot' | 'travel' | 'vehicle'
type ActionKind = 'approve' | 'reject' | 'return'
interface WorkflowOption { id: string; code: string; name: string }
function formatDate(iso?: string): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('vi-VN')
}
function formatDateTime(iso?: string): string {
if (!iso) return '—'
return new Date(iso).toLocaleString('vi-VN')
}
function formatVnd(n: number | null | undefined): string {
if (n === null || n === undefined) return '—'
return n.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' },
}
const KIND_CONFIG: Record<Kind, {
title: string
endpoint: string
applicableType: number
icon: any
detailFields: Array<{ label: string; render: (x: WorkflowAppDetail) => React.ReactNode }>
}> = {
leave: {
title: 'Đơn xin nghỉ phép',
endpoint: '/leave-requests',
applicableType: 5,
icon: CalendarOff,
detailFields: [
{ label: 'Người xin', render: (x) => x.requesterFullName },
{ label: 'Từ ngày', render: (x) => formatDate(x.startDate) },
{ label: 'Đến ngày', render: (x) => formatDate(x.endDate) },
{ label: 'Số ngày', render: (x) => x.numDays ?? '—' },
{ label: 'Lý do', render: (x) => x.reason || '—' },
],
},
ot: {
title: 'Đơn đăng ký OT',
endpoint: '/ot-requests',
applicableType: 6,
icon: Clock,
detailFields: [
{ label: 'Người xin', render: (x) => x.requesterFullName },
{ label: 'Ngày OT', render: (x) => formatDate(x.otDate) },
{ label: 'Giờ bắt đầu', render: (x) => x.startTime ?? '—' },
{ label: 'Giờ kết thúc', render: (x) => x.endTime ?? '—' },
{ label: 'Số giờ', render: (x) => x.hours ?? '—' },
{ label: 'Lý do', render: (x) => x.reason || '—' },
],
},
travel: {
title: 'Đơn đi công tác',
endpoint: '/travel-requests',
applicableType: 9,
icon: Plane,
detailFields: [
{ label: 'Người xin', render: (x) => x.requesterFullName },
{ label: 'Địa điểm', render: (x) => x.destination || '—' },
{ label: 'Từ ngày', render: (x) => formatDate(x.startDate) },
{ label: 'Đến ngày', render: (x) => formatDate(x.endDate) },
{ label: 'Số ngày', render: (x) => x.numDays ?? '—' },
{ label: 'Dự toán chi phí', render: (x) => formatVnd(x.estimatedCost) },
{ label: 'Mục đích', render: (x) => x.purpose || '—' },
],
},
vehicle: {
title: 'Đặt xe công',
endpoint: '/vehicle-bookings',
applicableType: 7,
icon: Car,
detailFields: [
{ label: 'Người đặt', render: (x) => x.requesterFullName },
{ label: 'Biển số', render: (x) => <span className="font-mono">{x.vehicleLicense ?? '—'}</span> },
{ label: 'Tên xe', render: (x) => x.vehicleName || '—' },
{ label: 'Bắt đầu', render: (x) => formatDateTime(x.startAt) },
{ label: 'Kết thúc', render: (x) => formatDateTime(x.endAt) },
{ label: 'Địa điểm đến', render: (x) => x.destination || '—' },
{ label: 'Tài xế', render: (x) => x.driverName || '—' },
{ label: 'Mục đích', render: (x) => x.purpose || '—' },
],
},
}
export function WorkflowAppDetailPage() {
const { kind = 'leave', id } = useParams<{ kind: Kind; id: string }>()
const navigate = useNavigate()
const qc = useQueryClient()
const config = KIND_CONFIG[kind as Kind]
const [actionDialog, setActionDialog] = useState<ActionKind | null>(null)
const [comment, setComment] = useState('')
const [pickedWorkflowId, setPickedWorkflowId] = useState<string>('')
const detail = useQuery({
queryKey: [config?.endpoint, id],
queryFn: async () =>
(await api.get<WorkflowAppDetail>(`${config.endpoint}/${id}`)).data,
enabled: !!config && !!id,
})
const d = detail.data
const status = d?.status
const isDraft = status === WorkflowAppStatus.Nhap || status === WorkflowAppStatus.TraLai
const isInWorkflow = status === WorkflowAppStatus.DaGuiDuyet
const hasWorkflow = !!d?.approvalWorkflowId
// Workflow picker — chỉ fetch khi draft chưa pin workflow.
const workflows = useQuery({
queryKey: ['approval-workflows-v2', { applicableType: config?.applicableType, isUserSelectable: true }],
queryFn: async () =>
(await api.get<WorkflowOption[]>('/approval-workflows-v2', {
params: { applicableType: config.applicableType, isUserSelectable: true },
})).data,
enabled: !!config && isDraft && !hasWorkflow,
})
const invalidate = () => {
qc.invalidateQueries({ queryKey: [config.endpoint, id] })
qc.invalidateQueries({ queryKey: [config.endpoint] })
}
const pinWorkflow = useMutation({
mutationFn: async (workflowId: string) => {
await api.put(`${config.endpoint}/${id}`, { approvalWorkflowId: workflowId })
},
onSuccess: () => {
toast.success('Đã chọn quy trình duyệt')
invalidate()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const submit = useMutation({
mutationFn: async () => {
await api.post(`${config.endpoint}/${id}/submit`, {})
},
onSuccess: () => {
toast.success('Đã gửi duyệt')
invalidate()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const action = useMutation({
mutationFn: async (k: ActionKind) => {
await api.post(`${config.endpoint}/${id}/${k}`, { comment: comment.trim() || null })
},
onSuccess: (_, k) => {
toast.success(`Đã ${ACTION_LABEL[k].text.toLowerCase()}`)
setActionDialog(null)
setComment('')
invalidate()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
if (!config) {
return <div className="text-red-600">Module không tồn tại: {kind}</div>
}
if (detail.isLoading) {
return (
<div className="space-y-4">
<PageHeader title="Đang tải..." />
</div>
)
}
if (detail.isError || !d) {
return (
<div className="space-y-4">
<PageHeader
title="Lỗi"
actions={
<Button variant="outline" onClick={() => navigate(`/workflow-apps/${kind}`)}>
<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 đơn từ.
</div>
</div>
)
}
const Icon = config.icon
return (
<div className="space-y-4">
<PageHeader
title={d.maDonTu ?? '(Chưa có mã)'}
description={config.title}
actions={
<Button variant="outline" onClick={() => navigate(`/workflow-apps/${kind}`)}>
<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',
WORKFLOW_APP_STATUS_BADGE[d.status],
)}
>
{WORKFLOW_APP_STATUS_LABELS[d.status]}
</span>
{d.currentApprovalLevelOrder != null && (
<span className="text-sm text-muted-foreground">
Cấp hiện tại: <span className="font-semibold">{d.currentApprovalLevelOrder}</span>
</span>
)}
{d.workflowCode && (
<span className="text-sm text-muted-foreground">
Quy trình: <span className="font-mono">{d.workflowCode}</span>
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{isDraft && !hasWorkflow && (
<>
<select
className="h-9 rounded-md border bg-background px-2 text-sm"
value={pickedWorkflowId}
onChange={(e) => setPickedWorkflowId(e.target.value)}
>
<option value=""> Chọn quy trình duyệt </option>
{(workflows.data ?? []).map((w) => (
<option key={w.id} value={w.id}>
{w.code} - {w.name}
</option>
))}
</select>
<Button
variant="outline"
disabled={!pickedWorkflowId || pinWorkflow.isPending}
onClick={() => pickedWorkflowId && pinWorkflow.mutate(pickedWorkflowId)}
>
Lưu quy trình
</Button>
</>
)}
{isDraft && (
<Button
onClick={() => submit.mutate()}
disabled={submit.isPending || !hasWorkflow}
title={!hasWorkflow ? 'Cần chọn quy trình duyệt trước khi gửi' : undefined}
>
<Send className="mr-2 h-4 w-4" />
{status === WorkflowAppStatus.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>
{isDraft && !hasWorkflow && (
<div className="rounded-lg border bg-amber-50/50 p-3 text-sm text-amber-900">
Đơn chưa gắn quy trình duyệt. Vui lòng chọn quy trình rồi bấm <strong>Lưu quy trình</strong> trước khi gửi duyệt.
</div>
)}
{/* Section 1: Thông tin */}
<div className="rounded-lg border bg-card p-6 space-y-3">
<h3 className="flex items-center gap-2 font-semibold text-base">
<Icon className="h-4 w-4 opacity-70" />
1. Thông tin
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
{config.detailFields.map((f) => (
<div key={f.label}>
<Label className="text-muted-foreground">{f.label}</Label>
<div className="mt-1 font-medium whitespace-pre-wrap">{f.render(d)}</div>
</div>
))}
<div>
<Label className="text-muted-foreground">Ngày tạo</Label>
<div className="mt-1 text-xs">{formatDateTime(d.createdAt)}</div>
</div>
</div>
</div>
{/* Section 2: Quy trình duyệt */}
<div className="rounded-lg border bg-card p-6 space-y-3">
<h3 className="font-semibold text-base">2. Quy trình duyệt</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div>
<Label className="text-muted-foreground">Quy trình</Label>
<div className="mt-1 text-xs">
{d.workflowCode ? (
<>
<span className="font-mono">{d.workflowCode}</span> - {d.workflowName}
</>
) : '— Chưa chọn —'}
</div>
</div>
<div>
<Label className="text-muted-foreground">Cấp hiện tại</Label>
<div className="mt-1 font-medium">{d.currentApprovalLevelOrder ?? '—'}</div>
</div>
</div>
</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>
{d.levelOpinions.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa ý kiến.</div>
) : (
<div className="space-y-3">
{[...d.levelOpinions]
.sort((a, b) =>
(a.stepOrder ?? 0) - (b.stepOrder ?? 0) ||
(a.levelOrder ?? 0) - (b.levelOrder ?? 0))
.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} đơn 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>
)
}