Files
TurboHR/frontend/src/pages/portal/ResignationApply.tsx
T
freedakgmail 86e5526a83 fix: 优化文档16项问题修复
- 问题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+自动修复历史哈希)
2026-08-11 22:02:19 +08:00

271 lines
11 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, 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>
)
}