[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
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:
423
fe-admin/src/pages/office/WorkflowAppDetailPage.tsx
Normal file
423
fe-admin/src/pages/office/WorkflowAppDetailPage.tsx
Normal 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 có ý 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user