// Generic Workflow App Detail page — Phase 11 P11-A Wave 3a (S42 2026-05-30). // Declarative KIND_CONFIG Record 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; isActive: boolean; isUserSelectable: boolean } 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 = { 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 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) => {x.vehicleLicense ?? '—'} }, { 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(null) const [comment, setComment] = useState('') const [pickedWorkflowId, setPickedWorkflowId] = useState('') const detail = useQuery({ queryKey: [config?.endpoint, id], queryFn: async () => (await api.get(`${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. // Endpoint trả AwAdminOverviewDto { types: [{ applicableType, history: [...] }] } — // KHÔNG phải flat array. Mirror pattern ContractCreatePage/PeWorkspace: extract bucket // theo applicableType rồi filter isUserSelectable (admin ghim cho user pick). const workflows = useQuery({ queryKey: ['approval-workflows-v2', config?.applicableType], queryFn: async () => { const res = await api.get<{ types: { applicableType: number; history: WorkflowOption[] }[] }>( '/approval-workflows-v2', { params: { applicableType: config.applicableType } }, ) const bucket = res.data.types.find((t) => t.applicableType === config.applicableType) return (bucket?.history ?? []).filter((w) => w.isUserSelectable) }, enabled: !!config && isDraft && !hasWorkflow, }) const invalidate = () => { qc.invalidateQueries({ queryKey: [config.endpoint, id] }) qc.invalidateQueries({ queryKey: [config.endpoint] }) } const pinWorkflow = useMutation({ mutationFn: async (workflowId: string) => { // Endpoint chuyên dụng /workflow — chỉ set ApprovalWorkflowId trên draft. // KHÔNG dùng PUT /{id} (UpdateDraft) vì nó validate Reason/NumDays... → 400. await api.put(`${config.endpoint}/${id}/workflow`, { 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
Module không tồn tại: {kind}
} if (detail.isLoading) { return (
) } if (detail.isError || !d) { return (
navigate(`/workflow-apps/${kind}`)}> Quay lại } />
Không tải được dữ liệu đơn từ.
) } const Icon = config.icon return (
navigate(`/workflow-apps/${kind}`)}> Danh sách } /> {/* Status row + action buttons */}
{WORKFLOW_APP_STATUS_LABELS[d.status]} {d.currentApprovalLevelOrder != null && ( Cấp hiện tại: {d.currentApprovalLevelOrder} )} {d.workflowCode && ( Quy trình: {d.workflowCode} )}
{isDraft && !hasWorkflow && ( <> )} {isDraft && ( )} {isInWorkflow && ( <> )}
{isDraft && !hasWorkflow && (
Đơn chưa gắn quy trình duyệt. Vui lòng chọn quy trình rồi bấm Lưu quy trình trước khi gửi duyệt.
)} {/* Section 1: Thông tin */}

1. Thông tin

{config.detailFields.map((f) => (
{f.render(d)}
))}
{formatDateTime(d.createdAt)}
{/* Section 2: Quy trình duyệt */}

2. Quy trình duyệt

{d.workflowCode ? ( <> {d.workflowCode} - {d.workflowName} ) : '— Chưa chọn —'}
{d.currentApprovalLevelOrder ?? '—'}
{/* Section 3: Ý kiến cấp duyệt V2 dynamic */}

3. Ý kiến cấp duyệt

{d.levelOpinions.length === 0 ? (
Chưa có ý kiến.
) : (
{[...d.levelOpinions] .sort((a, b) => (a.stepOrder ?? 0) - (b.stepOrder ?? 0) || (a.levelOrder ?? 0) - (b.levelOrder ?? 0)) .map((o) => (
Bước {o.stepOrder} {o.stepName} · Cấp {o.levelOrder} {formatDateTime(o.signedAt)}
{o.signedByFullName}
{o.comment ?? '(duyệt — không ý kiến)'}
))}
)}
{/* Action confirm dialog */} { setActionDialog(null) setComment('') }} title={actionDialog ? `${ACTION_LABEL[actionDialog].text} đơn từ` : ''} >