feat: 已离职/解聘员工可重新入职
1. 后端新增 rehireEmployee 函数和 POST /employees/:id/rehire 接口 2. 校验:仅已离职员工可重新入职,新入职日期须晚于上次离职日期 3. 复用员工已有基本信息,只需填写新入职日期和劳动合同 4. 前端花名册已离职员工显示「重新入职」按钮+弹窗(RehireModal)
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
|||||||
getEmployees,
|
getEmployees,
|
||||||
getEmployeeDetail,
|
getEmployeeDetail,
|
||||||
createEmployee,
|
createEmployee,
|
||||||
|
rehireEmployee,
|
||||||
updateEmployee,
|
updateEmployee,
|
||||||
deleteEmployee,
|
deleteEmployee,
|
||||||
batchRenew,
|
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) => {
|
router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||||
try {
|
try {
|
||||||
const result = await deleteEmployee(req.user!.orgId, req.params.id)
|
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 }
|
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) {
|
export async function updateEmployee(orgId: string, id: string, data: any) {
|
||||||
const employee = await prisma.employee.findFirst({ where: { id, orgId } })
|
const employee = await prisma.employee.findFirst({ where: { id, orgId } })
|
||||||
if (!employee) {
|
if (!employee) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
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 api from '../lib/api'
|
||||||
import Card from '../components/ui/Card'
|
import Card from '../components/ui/Card'
|
||||||
import Button from '../components/ui/Button'
|
import Button from '../components/ui/Button'
|
||||||
@@ -21,6 +21,8 @@ export default function Roster() {
|
|||||||
const [showAddModal, setShowAddModal] = useState(false)
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
const [showResignModal, setShowResignModal] = useState(false)
|
const [showResignModal, setShowResignModal] = useState(false)
|
||||||
const [resignEmployee, setResignEmployee] = useState<any>(null)
|
const [resignEmployee, setResignEmployee] = useState<any>(null)
|
||||||
|
const [showRehireModal, setShowRehireModal] = useState(false)
|
||||||
|
const [rehireEmployee, setRehireEmployee] = useState<any>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(10)
|
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) =>
|
const filtered = employees?.filter((e: any) =>
|
||||||
!search || e.name.includes(search) || e.department.includes(search)
|
!search || e.name.includes(search) || e.department.includes(search)
|
||||||
) || []
|
) || []
|
||||||
@@ -181,6 +193,18 @@ export default function Roster() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -208,6 +232,16 @@ export default function Roster() {
|
|||||||
error={resignMutation.error as any}
|
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>
|
</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 }: {
|
function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSubmit: (data: any) => void
|
onSubmit: (data: any) => void
|
||||||
|
|||||||
Reference in New Issue
Block a user