Sprint 4-5: 员工自助+考勤+合规+AI+搜索

This commit is contained in:
selfrelease
2026-08-01 06:45:59 +08:00
parent 9946197d20
commit a88c96299c
16 changed files with 1919 additions and 35 deletions
@@ -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>
)
}