From 1eeca663a05a7bb509899a65c3f184731a4778d7 Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Thu, 23 Jul 2026 18:11:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B7=B2=E7=A6=BB=E8=81=8C/=E8=A7=A3?= =?UTF-8?q?=E8=81=98=E5=91=98=E5=B7=A5=E5=8F=AF=E9=87=8D=E6=96=B0=E5=85=A5?= =?UTF-8?q?=E8=81=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 后端新增 rehireEmployee 函数和 POST /employees/:id/rehire 接口 2. 校验:仅已离职员工可重新入职,新入职日期须晚于上次离职日期 3. 复用员工已有基本信息,只需填写新入职日期和劳动合同 4. 前端花名册已离职员工显示「重新入职」按钮+弹窗(RehireModal) --- backend/src/routes/employee.routes.ts | 17 +++ backend/src/services/contract.service.ts | 66 +++++++++ frontend/src/pages/Roster.tsx | 170 ++++++++++++++++++++++- 3 files changed, 252 insertions(+), 1 deletion(-) diff --git a/backend/src/routes/employee.routes.ts b/backend/src/routes/employee.routes.ts index ea7c17f..220fc49 100644 --- a/backend/src/routes/employee.routes.ts +++ b/backend/src/routes/employee.routes.ts @@ -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) diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index 3b2c496..5454b80 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -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) { diff --git a/frontend/src/pages/Roster.tsx b/frontend/src/pages/Roster.tsx index af88da9..5cdcbae 100644 --- a/frontend/src/pages/Roster.tsx +++ b/frontend/src/pages/Roster.tsx @@ -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(null) + const [showRehireModal, setShowRehireModal] = useState(false) + const [rehireEmployee, setRehireEmployee] = useState(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() { )} + {e.status === 'RESIGNED' && ( + + )} ))} @@ -208,6 +232,16 @@ export default function Roster() { error={resignMutation.error as any} /> )} + + {showRehireModal && rehireEmployee && ( + { setShowRehireModal(false); setRehireEmployee(null) }} + onSubmit={(data) => rehireMutation.mutate(data)} + loading={rehireMutation.isPending} + error={rehireMutation.error as any} + /> + )} ) } @@ -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 ( + +
+
+ 复用员工已有基本信息(姓名、部门、手机等),只需填写新入职日期和劳动合同。 +
+
+ +
{employee.name} - {employee.department}
+
+
+ + setForm({ ...form, hireDate: e.target.value })} + /> +
+
+ + + +
+
+ + +
+ {form.contractType !== 'UNSIGNED' && ( +
+
+
+ + setForm({ ...form, signDate: e.target.value })} /> +
+
+ + setForm({ ...form, startDate: e.target.value })} /> +
+
+ {form.contractType === 'FIXED' && ( +
+
+ + setForm({ ...form, endDate: e.target.value })} /> +
+
+ + setForm({ ...form, contractYears: parseInt(e.target.value) || 3 })} /> +
+
+ )} +
+
+ + setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} /> +
+
+ + setForm({ ...form, probationSalary: parseFloat(e.target.value) || 0 })} /> +
+
+
+ )} + {error && ( +
+ {(error as any)?.response?.data?.error?.message || '操作失败,请重试'} +
+ )} +
+ + +
+
+
+ ) +} + function AddEmployeeModal({ onClose, onSubmit, loading, error }: { onClose: () => void onSubmit: (data: any) => void