261 lines
13 KiB
TypeScript
261 lines
13 KiB
TypeScript
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>
|
||
)
|
||
}
|