All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m5s
Double-check chất lượng P11-A ở Max (agents trước chạy High + truncate 3×) → phát hiện 2 bug THẬT trong workflow-picker FE của WorkflowAppDetailPage (core approve/reject/return ĐÚNG, chỉ sub-flow chọn quy trình hỏng): Bug #1 (HIGH) — pinWorkflow PUT /{id} chỉ gửi {approvalWorkflowId} → UpdateDraft validator (Reason NotEmpty, NumDays>0...) fail → 400. Nút "Lưu quy trình" vỡ. Bug #2 (HIGH) — fetch workflow expect flat array nhưng endpoint trả AwAdminOverviewDto {types:[...]} → picker rỗng/crash. FE copy nhầm pattern hỏng của ProposalCreatePage thay vì PE/Contract proven. Fix: - BE: thêm endpoint chuyên dụng PUT /{id}/workflow + Set{Module}WorkflowCommand/Handler cho 4 module — chỉ set ApprovalWorkflowId trên draft Nhap/TraLai (verify ApplicableType per module), KHÔNG validate field khác. Single-responsibility, bulletproof. - FE: sửa fetch mirror PE/Contract (data.types.find(t=>t.applicableType===X)?.history .filter(isUserSelectable)) + pin gọi endpoint mới. fe-admin+fe-user SHA256 identical. - Test: +3 SetWorkflow (happy no-status-change / wrong ApplicableType Conflict / submitted guard) → 141→144 PASS. Verify: BE build 0 error · 144 test PASS · FE build ×2 · SHA256 identical. Bonus phát hiện: ProposalCreatePage (S37) có bug #2 có sẵn (latent, chưa exercise UAT) → flag spawn task riêng, KHÔNG fix trong commit này. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
433 lines
16 KiB
TypeScript
433 lines
16 KiB
TypeScript
// 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; 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<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.
|
|
// 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 <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>
|
|
)
|
|
}
|