86e5526a83
- 问题1/3: 绩效考核/培训记录员工姓名可点击跳转员工详情页 - 问题2: 离职证明模板支持自定义+员工端下载 - 问题4(P0): 修复工资填写后数据归零问题 - 问题5: 社保添加员工参保信息列表 - 问题6(P0): 商业保险支持为员工参保 - 问题7(P0): 员工福利支持为员工添加福利 - 问题8: 规章制度支持导入Word文档 - 问题9: 文本模板下载Word增加HTML格式 - 问题10: 模板下载变量替换修复(排除token参数) - 问题11(P0): 电子签署发起时员工下拉框有选项 - 问题12: 新增绩效记录添加考评人选项 - 问题13: 违纪记录添加处罚执行细节 - 问题14: 特殊员工列表添加查看详情按钮和姓名链接 - 问题15: 员工福利汇总正确显示参保人员 - 问题16(P0): 证据链验证修复(递归排序key+自动修复历史哈希)
271 lines
11 KiB
TypeScript
271 lines
11 KiB
TypeScript
/**
|
||
* 员工离职申请入口 — 员工端提交离职申请、查看申请状态、撤回申请
|
||
*/
|
||
import { useState, useRef } from 'react'
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import { toast } from 'sonner'
|
||
import { UserX, Clock, FileText, Camera, X, Image as ImageIcon, Download } from 'lucide-react'
|
||
import { portalApi } 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 { InlineAlert } from '../../components/ui/InlineAlert'
|
||
|
||
/** 离职原因选项 */
|
||
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 [attachments, setAttachments] = useState<string[]>([])
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
|
||
/** 查询离职申请状态 */
|
||
const { data: records = [], isLoading } = useQuery<any[]>({
|
||
queryKey: ['portal-resignation-status'],
|
||
queryFn: async () => {
|
||
return await portalApi.resignationStatus()
|
||
},
|
||
})
|
||
|
||
/** 提交离职申请 */
|
||
const submitMutation = useMutation({
|
||
mutationFn: async (data: { reason: string; expectedDate: string; remark: string; attachments?: string[] }) => {
|
||
return await portalApi.resignationSubmit(data)
|
||
},
|
||
onSuccess: () => {
|
||
toast.success('离职申请已提交,请等待HR审批')
|
||
queryClient.invalidateQueries({ queryKey: ['portal-resignation-status'] })
|
||
setForm({ reason: '', expectedDate: '', remark: '' })
|
||
setAttachments([])
|
||
},
|
||
onError: (err: any) => {
|
||
toast.error(err?.response?.data?.error?.message || '提交失败')
|
||
},
|
||
})
|
||
|
||
/** 撤回离职申请 */
|
||
const withdrawMutation = useMutation({
|
||
mutationFn: async (id: string) => {
|
||
return await portalApi.resignationWithdraw(id)
|
||
},
|
||
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, attachments })
|
||
}
|
||
|
||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const files = e.target.files
|
||
if (!files) return
|
||
Array.from(files).forEach(file => {
|
||
if (file.size > 5 * 1024 * 1024) {
|
||
toast.error(`${file.name} 超过5MB限制`)
|
||
return
|
||
}
|
||
const reader = new FileReader()
|
||
reader.onload = () => {
|
||
setAttachments(prev => [...prev, reader.result as string])
|
||
}
|
||
reader.readAsDataURL(file)
|
||
})
|
||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||
}
|
||
|
||
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>
|
||
<div>
|
||
<Label>辞职信照片(选填,最多5张)</Label>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
className="hidden"
|
||
onChange={handleFileUpload}
|
||
/>
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<button
|
||
type="button"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="flex items-center gap-1 px-3 py-2 rounded-md border border-dashed border-gray-300 text-sm text-gray-500 hover:border-primary hover:text-primary transition-colors"
|
||
>
|
||
<Camera className="w-4 h-4" />
|
||
上传照片
|
||
</button>
|
||
{attachments.map((img, i) => (
|
||
<div key={i} className="relative w-16 h-16 rounded-md overflow-hidden border">
|
||
<img src={img} alt={`附件${i + 1}`} className="w-full h-full object-cover" />
|
||
<button
|
||
type="button"
|
||
onClick={() => setAttachments(prev => prev.filter((_, idx) => idx !== i))}
|
||
className="absolute top-0 right-0 bg-black/50 text-white rounded-bl p-0.5"
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="text-xs text-gray-400 mt-1">支持上传辞职信照片,HR审批时可查看</div>
|
||
</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'
|
||
const canDownload = r.status === 'COMPLETED'
|
||
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>
|
||
{canDownload && (
|
||
<div className="mt-2 pt-2 border-t border-gray-50">
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={async () => {
|
||
try {
|
||
const blob = await portalApi.downloadCertificate(r.id)
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `离职证明.doc`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
} catch {
|
||
toast.error('下载失败')
|
||
}
|
||
}}
|
||
>
|
||
<Download className="w-4 h-4 mr-1" />下载离职证明
|
||
</Button>
|
||
</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>
|
||
)
|
||
}
|