feat: 离职/解聘完整功能

1. 已有离职/解聘记录的员工不能再次解聘(后端校验+前端提示)
2. 员工主动离职功能:花名册列表增加「离职」操作按钮和弹窗
3. 离职也按离职日期动态判断在职/离职状态,支持提前办理
4. 员工档案「解聘记录」tab改为「离职/解聘记录」,区分类型显示
5. Schema: TerminationRecord 增加 type、resignationReason 字段
6. 后端新增 POST /termination/resignation 接口
7. 花名册列表返回 hasTermination 标记
This commit is contained in:
freedakgmail
2026-07-23 17:31:27 +08:00
parent e6b5ac5989
commit 29ba3a98bc
6 changed files with 234 additions and 19 deletions
+142 -17
View File
@@ -1,6 +1,6 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download } from 'lucide-react'
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -19,6 +19,8 @@ export default function Roster() {
const [selectedId, setSelectedId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [showAddModal, setShowAddModal] = useState(false)
const [showResignModal, setShowResignModal] = useState(false)
const [resignEmployee, setResignEmployee] = useState<any>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
@@ -39,6 +41,16 @@ export default function Roster() {
},
})
const resignMutation = useMutation({
mutationFn: (data: any) => api.post('/termination/resignation', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
setShowResignModal(false)
setResignEmployee(null)
},
})
const filtered = employees?.filter((e: any) =>
!search || e.name.includes(search) || e.department.includes(search)
) || []
@@ -87,6 +99,7 @@ export default function Roster() {
<th className="py-2 px-3 text-center"></th>
<th className="py-2 px-3 text-center"></th>
<th className="py-2 px-3 text-center"></th>
<th className="py-2 px-3 text-center"></th>
</tr>
</thead>
<tbody>
@@ -129,6 +142,23 @@ export default function Roster() {
<td className="py-2 px-3 text-center text-gray-500">{e.counts?.trainingRecords || 0}</td>
<td className="py-2 px-3 text-center text-gray-500">{e.counts?.performanceRecords || 0}</td>
<td className="py-2 px-3 text-center text-gray-500">{e.counts?.payslips || 0}</td>
<td className="py-2 px-3 text-center">
{e.status === 'ACTIVE' && !e.hasTermination && (
<button
className="text-xs text-gray-500 hover:text-danger flex items-center gap-0.5"
onClick={(ev) => {
ev.stopPropagation()
setResignEmployee(e)
setShowResignModal(true)
}}
>
<UserX className="w-3.5 h-3.5" />
</button>
)}
{e.hasTermination && e.status === 'ACTIVE' && (
<span className="text-xs text-gray-400"></span>
)}
</td>
</tr>
))}
</tbody>
@@ -145,6 +175,16 @@ export default function Roster() {
error={addMutation.error as any}
/>
)}
{showResignModal && resignEmployee && (
<ResignModal
employee={resignEmployee}
onClose={() => { setShowResignModal(false); setResignEmployee(null) }}
onSubmit={(data) => resignMutation.mutate(data)}
loading={resignMutation.isPending}
error={resignMutation.error as any}
/>
)}
</div>
)
}
@@ -170,7 +210,7 @@ function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: (
{ key: 'attendance', label: '考勤记录', icon: Calendar },
{ key: 'training', label: '培训签收', icon: GraduationCap },
{ key: 'performance', label: '绩效考核', icon: TrendingUp },
{ key: 'termination', label: '解聘记录', icon: FileText },
{ key: 'termination', label: '离职/解聘记录', icon: FileText },
{ key: 'attachment', label: '附件管理', icon: Paperclip },
{ key: 'evidence', label: '仲裁证据链', icon: Scale },
]
@@ -617,6 +657,74 @@ function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string;
)
}
function ResignModal({ employee, onClose, onSubmit, loading, error }: {
employee: any
onClose: () => void
onSubmit: (data: any) => void
loading: boolean
error: any
}) {
const [form, setForm] = useState({
terminationDate: new Date().toISOString().slice(0, 10),
resignationReason: '个人原因',
remark: '',
})
const reasons = ['个人原因', '职业发展', '薪资不满意', '家庭原因', '身体原因', '其他']
const handleSubmit = () => {
onSubmit({
employeeId: employee.id,
terminationDate: new Date(form.terminationDate).toISOString(),
resignationReason: form.resignationReason,
remark: form.remark || undefined,
})
}
return (
<Modal open onClose={onClose} title={`办理离职 - ${employee.name}`}>
<div className="space-y-3">
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md">
</div>
<div>
<Label></Label>
<div className="text-xs text-gray-600">{employee.name} - {employee.department}</div>
</div>
<div>
<Label></Label>
<Input
type="date"
value={form.terminationDate}
onChange={(e) => setForm({ ...form, terminationDate: e.target.value })}
/>
</div>
<div>
<Label></Label>
<Select value={form.resignationReason} onChange={(e) => setForm({ ...form, resignationReason: e.target.value })}>
{reasons.map((r) => <option key={r} value={r}>{r}</option>)}
</Select>
</div>
<div>
<Label></Label>
<Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} placeholder="补充说明" />
</div>
{error && (
<div className="text-xs text-danger">
{(error as any)?.response?.data?.error?.message || '操作失败,请重试'}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? '提交中...' : '确认离职'}
</Button>
</div>
</div>
</Modal>
)
}
function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
onClose: () => void
onSubmit: (data: any) => void
@@ -892,6 +1000,7 @@ function TerminationInfo({ employeeId, profile, records }: { employeeId: string;
const reasonMap: Record<string, string> = {
NEGOTIATED: '协商解除', FAULT: '员工过错', NONFAULT: '非过错解除',
LAYOFF: '经济性裁员', EXPIRED: '合同到期', ILLEGAL: '违法解除',
RESIGNATION: '员工主动离职',
}
const legalBasisMap: Record<string, string> = {
NEGOTIATED: '《劳动合同法》第36条', FAULT: '《劳动合同法》第39条',
@@ -908,7 +1017,7 @@ function TerminationInfo({ employeeId, profile, records }: { employeeId: string;
enabled: !!printRecord,
})
if (!records?.length) return <Card><div className="text-center py-8 text-gray-400"></div></Card>
if (!records?.length) return <Card><div className="text-center py-8 text-gray-400">/</div></Card>
if (printRecord) {
return (
@@ -1031,26 +1140,42 @@ function TerminationInfo({ employeeId, profile, records }: { employeeId: string;
<div className="space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-gray-700">{t.terminationDate?.toString().slice(0, 10)}</span>
<span className={`px-2 py-0.5 rounded text-xs ${t.type === 'RESIGNATION' ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>{t.type === 'RESIGNATION' ? '主动离职' : '公司解聘'}</span>
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs">{reasonMap[t.reason] || t.reason}</span>
<span className={`px-2 py-0.5 rounded text-xs ${t.riskLevel === 'SAFE' ? 'bg-green-50 text-safe' : t.riskLevel === 'WARNING' ? 'bg-amber-50 text-amber-600' : 'bg-red-50 text-danger'}`}>
{t.riskLevel === 'SAFE' ? '风险低' : t.riskLevel === 'WARNING' ? '注意' : '高风险'}
</span>
{t.type !== 'RESIGNATION' && (
<span className={`px-2 py-0.5 rounded text-xs ${t.riskLevel === 'SAFE' ? 'bg-green-50 text-safe' : t.riskLevel === 'WARNING' ? 'bg-amber-50 text-amber-600' : 'bg-red-50 text-danger'}`}>
{t.riskLevel === 'SAFE' ? '风险低' : t.riskLevel === 'WARNING' ? '注意' : '高风险'}
</span>
)}
</div>
<div className="grid md:grid-cols-2 gap-3 text-xs">
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-400"></span>
<span className="font-medium text-gray-700">¥{fmt(t.compensation)}</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-400"></span>
<span className="font-medium text-gray-700">{legalBasisMap[t.reason] || '-'}</span>
</div>
{t.type === 'RESIGNATION' ? (
<>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-400"></span>
<span className="font-medium text-gray-700">{t.resignationReason || '-'}</span>
</div>
</>
) : (
<>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-400"></span>
<span className="font-medium text-gray-700">¥{fmt(t.compensation)}</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-400"></span>
<span className="font-medium text-gray-700">{legalBasisMap[t.reason] || '-'}</span>
</div>
</>
)}
</div>
{t.remark && <div className="text-xs text-gray-500 bg-gray-50 px-2 py-1.5 rounded">{t.remark}</div>}
<div className="flex justify-end">
<Button variant="secondary" size="sm" onClick={() => setPrintRecord(t)}>
<Printer className="w-4 h-4 mr-1" />
</Button>
{t.type !== 'RESIGNATION' && (
<Button variant="secondary" size="sm" onClick={() => setPrintRecord(t)}>
<Printer className="w-4 h-4 mr-1" />
</Button>
)}
</div>
</div>
</Card>
+5 -1
View File
@@ -26,6 +26,7 @@ interface RosterEmployee {
name: string
department: string
status: string
hasTermination?: boolean
hireDate: string
monthlySalary: number
latestContract: any
@@ -276,7 +277,7 @@ export default function Termination() {
}, [selectedEmployee, terminationDate, socialAvgWage, reason])
const canProceed = () => {
if (step === 0) return !!employeeId
if (step === 0) return !!employeeId && !selectedEmployee?.hasTermination
if (step === 1) return !!reason && !!terminationDate && (!riskAssessment?.warnings.length || acknowledgeRisk)
if (step === 2) return true
if (step === 3) return true
@@ -440,6 +441,9 @@ export default function Termination() {
<div className="font-medium">{selectedEmployee.name}{selectedEmployee.department}</div>
<div>{selectedEmployee.hireDate?.toString().slice(0, 10)}</div>
<div>¥{fmt(selectedEmployee.monthlySalary)}</div>
{selectedEmployee.hasTermination && (
<div className="text-danger font-medium mt-1"> /</div>
)}
{selectedEmployee.latestContract ? (
<div>{selectedEmployee.latestContract.contractType === 'UNSIGNED' ? '未签订' : `签订于 ${selectedEmployee.latestContract.signDate?.slice(0, 10) || '未知'}`}</div>
) : (