feat: 已离职/解聘员工可重新入职

1. 后端新增 rehireEmployee 函数和 POST /employees/:id/rehire 接口
2. 校验:仅已离职员工可重新入职,新入职日期须晚于上次离职日期
3. 复用员工已有基本信息,只需填写新入职日期和劳动合同
4. 前端花名册已离职员工显示「重新入职」按钮+弹窗(RehireModal)
This commit is contained in:
freedakgmail
2026-07-23 18:11:01 +08:00
parent 2896ba77aa
commit 1eeca663a0
3 changed files with 252 additions and 1 deletions
+17
View File
@@ -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)
+66
View File
@@ -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) {
+169 -1
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, 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