Files
TurboHR/frontend/src/pages/LeaveApproval.tsx
T

261 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { CalendarClock, Check, X, Trash2, Clock, CheckCircle2, XCircle, RotateCcw, FileText, Smartphone } from 'lucide-react'
import { leaveApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
const LEAVE_TYPE_MAP: Record<string, string> = {
SICK: '病假',
PERSONAL: '事假',
ANNUAL: '年假',
MATERNITY: '产假',
OTHER: '其他',
}
const STATUS_MAP: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
PENDING: { label: '待审批', color: 'bg-amber-50 text-warning', icon: <Clock className="w-3 h-3" /> },
APPROVED: { label: '已批准', color: 'bg-green-50 text-safe', icon: <CheckCircle2 className="w-3 h-3" /> },
REJECTED: { label: '已驳回', color: 'bg-red-50 text-danger', icon: <XCircle className="w-3 h-3" /> },
CANCELLED: { label: '已撤回', color: 'bg-gray-100 text-gray-500', icon: <RotateCcw className="w-3 h-3" /> },
}
const fmtDate = (d: string) => new Date(d).toLocaleDateString('zh-CN')
export default function LeaveApproval() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [filterStatus, setFilterStatus] = useState('')
const [filterType, setFilterType] = useState('')
const [approveModal, setApproveModal] = useState<{ id: string; action: string; name: string } | null>(null)
const [approveRemark, setApproveRemark] = useState('')
const { data: stats } = useQuery<any>({
queryKey: ['leave-stats'],
queryFn: () => leaveApi.stats(),
})
const { data: result, isLoading } = useQuery<any>({
queryKey: ['leave-requests', filterStatus, filterType, page, pageSize],
queryFn: () => leaveApi.list({ status: filterStatus || undefined, leaveType: filterType || undefined, page, pageSize }),
})
const list = result?.list || []
const total = result?.total || 0
const approveMutation = useMutation({
mutationFn: ({ id, action, remark }: { id: string; action: string; remark?: string }) =>
leaveApi.approve(id, action, remark),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['leave-requests'] })
queryClient.invalidateQueries({ queryKey: ['leave-stats'] })
toast.success('审批完成')
setApproveModal(null)
setApproveRemark('')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '审批失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => leaveApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['leave-requests'] })
queryClient.invalidateQueries({ queryKey: ['leave-stats'] })
toast.success('已删除')
},
})
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<CalendarClock className="w-5 h-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500"> </p>
</div>
</div>
{/* 流程说明 */}
<div className="bg-blue-50 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1">
<Smartphone className="w-4 h-4 text-blue-600 flex-shrink-0" />
<span className="text-xs font-medium text-blue-700"></span>
</div>
<div className="text-xs text-blue-600 leading-relaxed">
<br/>
<br/>
</div>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<Card className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-blue-50 flex items-center justify-center"><FileText className="w-4 h-4 text-blue-600" /></div>
<div><div className="text-sm font-bold">{stats?.total || 0}</div><div className="text-xs text-gray-500"></div></div>
</Card>
<Card className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-amber-50 flex items-center justify-center"><Clock className="w-4 h-4 text-amber-600" /></div>
<div><div className="text-sm font-bold text-warning">{stats?.pending || 0}</div><div className="text-xs text-gray-500"></div></div>
</Card>
<Card className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-green-50 flex items-center justify-center"><CheckCircle2 className="w-4 h-4 text-green-600" /></div>
<div><div className="text-sm font-bold text-safe">{stats?.approved || 0}</div><div className="text-xs text-gray-500"></div></div>
</Card>
<Card className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-red-50 flex items-center justify-center"><XCircle className="w-4 h-4 text-red-600" /></div>
<div><div className="text-sm font-bold text-danger">{stats?.rejected || 0}</div><div className="text-xs text-gray-500"></div></div>
</Card>
<Card className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-gray-100 flex items-center justify-center"><RotateCcw className="w-4 h-4 text-gray-500" /></div>
<div><div className="text-sm font-bold text-gray-500">{stats?.cancelled || 0}</div><div className="text-xs text-gray-500"></div></div>
</Card>
</div>
{/* 筛选 + 操作 */}
<div className="flex items-center gap-2 flex-wrap">
<Select value={filterStatus} onChange={(e) => { setFilterStatus(e.target.value); setPage(1) }} className="!w-28">
<option value=""></option>
<option value="PENDING"></option>
<option value="APPROVED"></option>
<option value="REJECTED"></option>
<option value="CANCELLED"></option>
</Select>
<Select value={filterType} onChange={(e) => { setFilterType(e.target.value); setPage(1) }} className="!w-28">
<option value=""></option>
{Object.entries(LEAVE_TYPE_MAP).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
{total > 0 && <span className="text-xs text-gray-500">{total} </span>}
<div className="flex-1" />
</div>
{/* 列表 */}
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : list.length === 0 ? (
<EmptyState title="暂无休假申请" description="员工在手机端提交的休假申请将显示在此处" />
) : (
<>
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-500">
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3 text-right"></th>
</tr>
</thead>
<tbody>
{list.map((item: any) => {
const st = STATUS_MAP[item.status] || STATUS_MAP.PENDING
return (
<tr key={item.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2.5 px-3">
<div className="text-xs font-medium">{item.employee?.name}</div>
<div className="text-xs text-gray-500">{item.employee?.department}</div>
</td>
<td className="py-2.5 px-3 text-xs">{LEAVE_TYPE_MAP[item.leaveType] || item.leaveType}</td>
<td className="py-2.5 px-3 text-xs text-gray-600">
{fmtDate(item.startDate)} ~ {fmtDate(item.endDate)}
</td>
<td className="py-2.5 px-3 text-right text-sm font-medium">{item.days}</td>
<td className="py-2.5 px-3 text-xs text-gray-600 max-w-32 truncate" title={item.reason || ''}>{item.reason || '—'}</td>
<td className="py-2.5 px-3">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${st.color}`}>
{st.icon}{st.label}
</span>
</td>
<td className="py-2.5 px-3 text-xs text-gray-500 max-w-32 truncate" title={item.approveRemark || ''}>{item.approveRemark || '—'}</td>
<td className="py-2.5 px-3">
<div className="flex items-center justify-end gap-1">
{item.status === 'PENDING' && (
<>
<button
onClick={() => setApproveModal({ id: item.id, action: 'APPROVED', name: item.employee?.name })}
className="p-1 rounded hover:bg-green-50 text-gray-500 hover:text-safe transition-colors"
title="批准"
>
<Check className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setApproveModal({ id: item.id, action: 'REJECTED', name: item.employee?.name })}
className="p-1 rounded hover:bg-red-50 text-gray-500 hover:text-danger transition-colors"
title="驳回"
>
<X className="w-3.5 h-3.5" />
</button>
<button
onClick={async () => {
if (await confirm({ title: '删除申请', message: '确认删除该休假申请?此操作不可撤销。' })) {
deleteMutation.mutate(item.id)
}
}}
className="p-1 rounded hover:bg-red-50 text-gray-500 hover:text-danger transition-colors"
title="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
{/* 审批弹窗 */}
{approveModal && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setApproveModal(null)}>
<Card className="max-w-md w-full" >
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">
{approveModal.action === 'APPROVED' ? '批准休假申请' : '驳回休假申请'} {approveModal.name}
</h3>
<button onClick={() => setApproveModal(null)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
</div>
<div className="space-y-3">
<div>
<Label></Label>
<Input value={approveRemark} onChange={(e) => setApproveRemark(e.target.value)} placeholder="请输入审批意见(可选)" />
</div>
<div className="flex gap-2">
<Button
variant={approveModal.action === 'APPROVED' ? 'primary' : 'danger'}
onClick={() => approveMutation.mutate({ id: approveModal.id, action: approveModal.action, remark: approveRemark })}
disabled={approveMutation.isPending}
>
{approveMutation.isPending ? '处理中...' : approveModal.action === 'APPROVED' ? '确认批准' : '确认驳回'}
</Button>
<Button variant="secondary" onClick={() => setApproveModal(null)}></Button>
</div>
</div>
</div>
</Card>
</div>
)}
</div>
)
}