Sprint 4-5: 员工自助+考勤+合规+AI+搜索
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* 员工离职申请入口 — 员工端提交离职申请、查看申请状态、撤回申请
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { UserX, Clock, Check, X, FileText } from 'lucide-react'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
import { Input, Label, Select } from '../../components/ui/Input'
|
||||
import { InlineAlert } from '../../components/ui/InlineAlert'
|
||||
|
||||
/** 员工端 API 实例 */
|
||||
const portalApi = (await import('../../lib/api')).default.create({ baseURL: '/api/v1/portal' })
|
||||
portalApi.interceptors.request.use((config: any) => {
|
||||
const token = localStorage.getItem('portalToken')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
/** 离职原因选项 */
|
||||
const RESIGN_REASONS = [
|
||||
{ value: '个人发展', label: '个人发展' },
|
||||
{ value: '薪资待遇', label: '薪资待遇' },
|
||||
{ value: '家庭原因', label: '家庭原因' },
|
||||
{ value: '健康原因', label: '健康原因' },
|
||||
{ value: '工作环境', label: '工作环境' },
|
||||
{ value: '其他', label: '其他' },
|
||||
]
|
||||
|
||||
/** 状态映射 */
|
||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||
DRAFT: { label: '草稿', color: 'bg-gray-100 text-gray-600' },
|
||||
PENDING_APPROVAL: { label: '待审批', color: 'bg-amber-50 text-amber-700' },
|
||||
APPROVED: { label: '已审批', color: 'bg-blue-50 text-blue-700' },
|
||||
REJECTED: { label: '已驳回', color: 'bg-red-50 text-red-700' },
|
||||
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe' },
|
||||
CANCELLED: { label: '已撤回', color: 'bg-gray-100 text-gray-400' },
|
||||
}
|
||||
|
||||
export default function ResignationApply() {
|
||||
const queryClient = useQueryClient()
|
||||
const [form, setForm] = useState({
|
||||
reason: '',
|
||||
expectedDate: '',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
/** 查询离职申请状态 */
|
||||
const { data: records = [], isLoading } = useQuery<any[]>({
|
||||
queryKey: ['portal-resignation-status'],
|
||||
queryFn: async () => {
|
||||
const res = await portalApi.get('/resignation/status') as any
|
||||
return res.data || []
|
||||
},
|
||||
})
|
||||
|
||||
/** 提交离职申请 */
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: async (data: { reason: string; expectedDate: string; remark: string }) => {
|
||||
const res = await portalApi.post('/resignation/submit', data) as any
|
||||
return res.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('离职申请已提交,请等待HR审批')
|
||||
queryClient.invalidateQueries({ queryKey: ['portal-resignation-status'] })
|
||||
setForm({ reason: '', expectedDate: '', remark: '' })
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.error?.message || '提交失败')
|
||||
},
|
||||
})
|
||||
|
||||
/** 撤回离职申请 */
|
||||
const withdrawMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await portalApi.post(`/resignation/${id}/withdraw`) as any
|
||||
return res.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('离职申请已撤回')
|
||||
queryClient.invalidateQueries({ queryKey: ['portal-resignation-status'] })
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.error?.message || '撤回失败')
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!form.reason) { toast.error('请选择离职原因'); return }
|
||||
if (!form.expectedDate) { toast.error('请选择预计离职日期'); return }
|
||||
submitMutation.mutate(form)
|
||||
}
|
||||
|
||||
const hasPending = records.some((r: any) => r.status === 'DRAFT' || r.status === 'PENDING_APPROVAL')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserX className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">离职申请</h1>
|
||||
</div>
|
||||
|
||||
<InlineAlert type="info">
|
||||
提交离职申请后,HR将在3个工作日内审批。提前30天提交为法定要求,请合理选择离职日期。
|
||||
</InlineAlert>
|
||||
|
||||
{/* 申请表单 */}
|
||||
{!hasPending ? (
|
||||
<Card>
|
||||
<h2 className="text-sm font-medium mb-4">填写离职申请</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>离职原因 *</Label>
|
||||
<Select value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })}>
|
||||
<option value="">请选择</option>
|
||||
{RESIGN_REASONS.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>预计离职日期 *</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={form.expectedDate}
|
||||
min={new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)}
|
||||
onChange={(e) => setForm({ ...form, expectedDate: e.target.value })}
|
||||
/>
|
||||
<div className="text-xs text-gray-400 mt-1">法定要求提前30天通知</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>备注说明</Label>
|
||||
<Input
|
||||
value={form.remark}
|
||||
onChange={(e) => setForm({ ...form, remark: e.target.value })}
|
||||
placeholder="补充说明(选填)"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSubmit} disabled={submitMutation.isPending} className="w-full">
|
||||
{submitMutation.isPending ? '提交中...' : '提交离职申请'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<InlineAlert type="warning">
|
||||
您已有一个待处理的离职申请,请等待审批结果或撤回后重新提交。
|
||||
</InlineAlert>
|
||||
)}
|
||||
|
||||
{/* 申请记录 */}
|
||||
<Card>
|
||||
<h2 className="text-sm font-medium mb-3 flex items-center gap-1">
|
||||
<FileText className="w-4 h-4 text-gray-400" />
|
||||
申请记录
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-4 text-gray-400 text-sm">加载中...</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-400 text-sm">暂无离职申请记录</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{records.map((r: any) => {
|
||||
const statusCfg = STATUS_MAP[r.status] || STATUS_MAP.DRAFT
|
||||
const canWithdraw = r.status === 'DRAFT' || r.status === 'PENDING_APPROVAL'
|
||||
return (
|
||||
<div key={r.id} className="p-3 rounded-lg border border-gray-100">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${statusCfg.color}`}>{statusCfg.label}</span>
|
||||
<span className="text-xs text-gray-400 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(r.createdAt).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">预计离职日期</span>
|
||||
<span className="font-medium">{r.terminationDate ? new Date(r.terminationDate).toLocaleDateString('zh-CN') : '—'}</span>
|
||||
</div>
|
||||
{r.remark && (
|
||||
<div className="text-xs text-gray-500 mt-1">{r.remark}</div>
|
||||
)}
|
||||
</div>
|
||||
{canWithdraw && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-50">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => withdrawMutation.mutate(r.id)}
|
||||
disabled={withdrawMutation.isPending}
|
||||
>
|
||||
{withdrawMutation.isPending ? '撤回中...' : '撤回申请'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user