feat: 已离职/解聘员工可重新入职
1. 后端新增 rehireEmployee 函数和 POST /employees/:id/rehire 接口 2. 校验:仅已离职员工可重新入职,新入职日期须晚于上次离职日期 3. 复用员工已有基本信息,只需填写新入职日期和劳动合同 4. 前端花名册已离职员工显示「重新入职」按钮+弹窗(RehireModal)
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
getEmployees,
|
||||
getEmployeeDetail,
|
||||
createEmployee,
|
||||
rehireEmployee,
|
||||
updateEmployee,
|
||||
deleteEmployee,
|
||||
batchRenew,
|
||||
@@ -64,6 +65,22 @@ router.put('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:id/rehire', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const result = await rehireEmployee(req.user!.orgId, req.user!.id, req.params.id, req.body)
|
||||
await auditLog(req, 'REHIRE', 'EMPLOYEE', req.params.id, { hireDate: req.body.hireDate })
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'CONFLICT') {
|
||||
return res.status(409).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
}
|
||||
if (err?.code === 'VALIDATION_ERROR') {
|
||||
return res.status(400).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const result = await deleteEmployee(req.user!.orgId, req.params.id)
|
||||
|
||||
@@ -203,6 +203,72 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
return { id: employee.id }
|
||||
}
|
||||
|
||||
// 重新入职:复用已有员工基本信息,更新入职日期和状态,可选创建新合同
|
||||
export async function rehireEmployee(orgId: string, userId: string, id: string, data: any) {
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id, orgId },
|
||||
include: { terminations: { orderBy: { terminationDate: 'desc' }, take: 1 } },
|
||||
})
|
||||
if (!employee) {
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const isResigned = employee.terminations.some((t) => t.terminationDate <= today)
|
||||
if (!isResigned) {
|
||||
throw { code: 'CONFLICT', message: '该员工当前在职,无需重新入职' }
|
||||
}
|
||||
|
||||
const newHireDate = new Date(data.hireDate)
|
||||
const latestTerm = employee.terminations[0]
|
||||
if (latestTerm && newHireDate <= latestTerm.terminationDate) {
|
||||
throw { code: 'VALIDATION_ERROR', message: '新入职日期必须晚于上次离职/解聘日期' }
|
||||
}
|
||||
|
||||
await prisma.employee.update({
|
||||
where: { id },
|
||||
data: {
|
||||
hireDate: newHireDate,
|
||||
status: 'ACTIVE',
|
||||
isPregnant: data.isPregnant || false,
|
||||
isInMedicalPeriod: data.isInMedicalPeriod || false,
|
||||
isWorkInjured: data.isWorkInjured || false,
|
||||
},
|
||||
})
|
||||
|
||||
if (data.contract && data.contract.contractType !== 'UNSIGNED') {
|
||||
const contractMonths = data.contract.endDate
|
||||
? Math.ceil(daysBetween(new Date(data.contract.endDate), new Date(data.contract.startDate)) / 30.44)
|
||||
: data.contract.contractYears * 12
|
||||
|
||||
const probationCheck = validateProbation(contractMonths, data.contract.probationMonths)
|
||||
if (!probationCheck.valid) {
|
||||
throw { code: 'VALIDATION_ERROR', message: probationCheck.message }
|
||||
}
|
||||
|
||||
await prisma.laborContract.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: id,
|
||||
signDate: data.contract.signDate ? new Date(data.contract.signDate) : null,
|
||||
startDate: new Date(data.contract.startDate),
|
||||
endDate: data.contract.endDate ? new Date(data.contract.endDate) : null,
|
||||
contractType: data.contract.contractType,
|
||||
signMethod: data.contract.signMethod || 'PAPER',
|
||||
contractYears: data.contract.contractYears || 3,
|
||||
probationMonths: data.contract.probationMonths || 0,
|
||||
probationSalary: data.contract.probationSalary || 0,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await runRiskDetection(orgId)
|
||||
|
||||
return { id }
|
||||
}
|
||||
|
||||
export async function updateEmployee(orgId: string, id: string, data: any) {
|
||||
const employee = await prisma.employee.findFirst({ where: { id, orgId } })
|
||||
if (!employee) {
|
||||
|
||||
@@ -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, UserX } from 'lucide-react'
|
||||
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
@@ -21,6 +21,8 @@ export default function Roster() {
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [showResignModal, setShowResignModal] = useState(false)
|
||||
const [resignEmployee, setResignEmployee] = useState<any>(null)
|
||||
const [showRehireModal, setShowRehireModal] = useState(false)
|
||||
const [rehireEmployee, setRehireEmployee] = useState<any>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
@@ -59,6 +61,16 @@ export default function Roster() {
|
||||
},
|
||||
})
|
||||
|
||||
const rehireMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post(`/employees/${rehireEmployee?.id}/rehire`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
setShowRehireModal(false)
|
||||
setRehireEmployee(null)
|
||||
},
|
||||
})
|
||||
|
||||
const filtered = employees?.filter((e: any) =>
|
||||
!search || e.name.includes(search) || e.department.includes(search)
|
||||
) || []
|
||||
@@ -181,6 +193,18 @@ export default function Roster() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{e.status === 'RESIGNED' && (
|
||||
<button
|
||||
className="text-xs text-primary hover:text-primary/80 flex items-center gap-0.5"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
setRehireEmployee(e)
|
||||
setShowRehireModal(true)
|
||||
}}
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />重新入职
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -208,6 +232,16 @@ export default function Roster() {
|
||||
error={resignMutation.error as any}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showRehireModal && rehireEmployee && (
|
||||
<RehireModal
|
||||
employee={rehireEmployee}
|
||||
onClose={() => { setShowRehireModal(false); setRehireEmployee(null) }}
|
||||
onSubmit={(data) => rehireMutation.mutate(data)}
|
||||
loading={rehireMutation.isPending}
|
||||
error={rehireMutation.error as any}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -748,6 +782,140 @@ function ResignModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
)
|
||||
}
|
||||
|
||||
function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
employee: any
|
||||
onClose: () => void
|
||||
onSubmit: (data: any) => void
|
||||
loading: boolean
|
||||
error: any
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
hireDate: new Date().toISOString().slice(0, 10),
|
||||
isPregnant: false,
|
||||
isInMedicalPeriod: false,
|
||||
isWorkInjured: false,
|
||||
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
|
||||
signDate: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
contractYears: 3,
|
||||
probationMonths: 0,
|
||||
probationSalary: 0,
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
const data: any = {
|
||||
hireDate: new Date(form.hireDate).toISOString(),
|
||||
isPregnant: form.isPregnant,
|
||||
isInMedicalPeriod: form.isInMedicalPeriod,
|
||||
isWorkInjured: form.isWorkInjured,
|
||||
}
|
||||
if (form.contractType !== 'UNSIGNED' && form.startDate) {
|
||||
data.contract = {
|
||||
signDate: form.signDate ? new Date(form.signDate).toISOString() : null,
|
||||
startDate: new Date(form.startDate).toISOString(),
|
||||
endDate: form.endDate ? new Date(form.endDate).toISOString() : null,
|
||||
contractType: form.contractType,
|
||||
contractYears: form.contractYears,
|
||||
probationMonths: form.probationMonths,
|
||||
probationSalary: form.probationSalary,
|
||||
}
|
||||
}
|
||||
onSubmit(data)
|
||||
}
|
||||
|
||||
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.hireDate}
|
||||
onChange={(e) => setForm({ ...form, hireDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs">
|
||||
<label className="flex items-center gap-1">
|
||||
<input type="checkbox" checked={form.isPregnant} onChange={(e) => setForm({ ...form, isPregnant: e.target.checked })} />
|
||||
孕期
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<input type="checkbox" checked={form.isInMedicalPeriod} onChange={(e) => setForm({ ...form, isInMedicalPeriod: e.target.checked })} />
|
||||
医疗期
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<input type="checkbox" checked={form.isWorkInjured} onChange={(e) => setForm({ ...form, isWorkInjured: e.target.checked })} />
|
||||
工伤
|
||||
</label>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<Label>劳动合同</Label>
|
||||
<Select value={form.contractType} onChange={(e) => setForm({ ...form, contractType: e.target.value as any })}>
|
||||
<option value="FIXED">固定期限</option>
|
||||
<option value="UNFIXED">无固定期限</option>
|
||||
<option value="UNSIGNED">暂不签订</option>
|
||||
</Select>
|
||||
</div>
|
||||
{form.contractType !== 'UNSIGNED' && (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label>签订日期</Label>
|
||||
<Input type="date" value={form.signDate} onChange={(e) => setForm({ ...form, signDate: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>合同起始日</Label>
|
||||
<Input type="date" value={form.startDate} onChange={(e) => setForm({ ...form, startDate: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
{form.contractType === 'FIXED' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label>合同到期日</Label>
|
||||
<Input type="date" value={form.endDate} onChange={(e) => setForm({ ...form, endDate: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>合同年限</Label>
|
||||
<Input type="number" value={form.contractYears} onChange={(e) => setForm({ ...form, contractYears: parseInt(e.target.value) || 3 })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label>试用期(月)</Label>
|
||||
<Input type="number" value={form.probationMonths} onChange={(e) => setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>试用期工资</Label>
|
||||
<Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseFloat(e.target.value) || 0 })} />
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
|
||||
Reference in New Issue
Block a user