From 29ba3a98bcba1c13c9f33a45e19a7c3daee2f3c3 Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Thu, 23 Jul 2026 17:31:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A6=BB=E8=81=8C/=E8=A7=A3=E8=81=98?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 已有离职/解聘记录的员工不能再次解聘(后端校验+前端提示) 2. 员工主动离职功能:花名册列表增加「离职」操作按钮和弹窗 3. 离职也按离职日期动态判断在职/离职状态,支持提前办理 4. 员工档案「解聘记录」tab改为「离职/解聘记录」,区分类型显示 5. Schema: TerminationRecord 增加 type、resignationReason 字段 6. 后端新增 POST /termination/resignation 接口 7. 花名册列表返回 hasTermination 标记 --- backend/prisma/schema.prisma | 3 + backend/src/routes/roster.routes.ts | 2 + backend/src/routes/termination.routes.ts | 19 ++- backend/src/services/termination.service.ts | 64 ++++++++ frontend/src/pages/Roster.tsx | 159 +++++++++++++++++--- frontend/src/pages/Termination.tsx | 6 +- 6 files changed, 234 insertions(+), 19 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 8299caa..8e8136c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -83,6 +83,7 @@ enum TerminationReason { NONFAULT LAYOFF EXPIRED + RESIGNATION } enum RiskAssessment { @@ -249,8 +250,10 @@ model TerminationRecord { org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) employeeId String employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + type String @default("TERMINATION") // TERMINATION=公司解聘, RESIGNATION=员工主动离职 reason TerminationReason terminationDate DateTime + resignationReason String? // 主动离职原因(type=RESIGNATION时使用) compensation Float @default(0) riskLevel RiskAssessment @default(SAFE) checklist Json diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts index e13b5de..499fce0 100644 --- a/backend/src/routes/roster.routes.ts +++ b/backend/src/routes/roster.routes.ts @@ -63,6 +63,8 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => { name: e.name, department: e.department, status: e.terminations.some((t) => t.terminationDate <= today) ? 'RESIGNED' : 'ACTIVE', + hasTermination: e.terminations.length > 0, + latestTerminationDate: e.terminations[0]?.terminationDate || null, hireDate: e.hireDate, gender: e.gender, phone: e.phone, diff --git a/backend/src/routes/termination.routes.ts b/backend/src/routes/termination.routes.ts index ac405f5..c74d8b6 100644 --- a/backend/src/routes/termination.routes.ts +++ b/backend/src/routes/termination.routes.ts @@ -2,7 +2,7 @@ import { Router } from 'express' import { authMiddleware, AuthRequest } from '../middleware/auth' import { auditLog } from '../middleware/auditLog' import { terminationChecklistSchema } from '../schemas/termination.schema' -import { createTermination, getTerminations, getChecklistForReason, assessRisk, calculateCompensation } from '../services/termination.service' +import { createTermination, createResignation, getTerminations, getChecklistForReason, assessRisk, calculateCompensation } from '../services/termination.service' import prisma from '../lib/prisma' import { decrypt } from '../lib/crypto' @@ -70,4 +70,21 @@ router.post('/', authMiddleware, async (req: AuthRequest, res, next) => { } }) +router.post('/resignation', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const { employeeId, terminationDate, resignationReason, remark } = req.body + if (!employeeId || !terminationDate) { + return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '缺少必填字段' } }) + } + const result = await createResignation(req.user!.orgId, req.user!.id, { employeeId, terminationDate, resignationReason, remark }) + await auditLog(req, 'RESIGN', 'EMPLOYEE', employeeId, { resignationReason }) + 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 } }) + } + next(err) + } +}) + export default router diff --git a/backend/src/services/termination.service.ts b/backend/src/services/termination.service.ts index 9fda24f..61578ba 100644 --- a/backend/src/services/termination.service.ts +++ b/backend/src/services/termination.service.ts @@ -141,12 +141,22 @@ export async function createTermination(orgId: string, userId: string, data: any throw { code: 'NOT_FOUND', message: '员工不存在' } } + // 校验:已有离职/解聘记录且未重新雇佣则不允许再次解聘 + const latestTerm = await prisma.terminationRecord.findFirst({ + where: { employeeId: data.employeeId }, + orderBy: { terminationDate: 'desc' }, + }) + if (latestTerm && latestTerm.terminationDate >= employee.hireDate) { + throw { code: 'CONFLICT', message: '该员工已有离职/解聘记录,如需再次解聘请先办理重新雇佣' } + } + const { level } = assessRisk(employee, data.reason) const record = await prisma.terminationRecord.create({ data: { orgId, employeeId: data.employeeId, + type: 'TERMINATION', reason: data.reason, terminationDate: new Date(data.terminationDate), compensation: data.compensation || 0, @@ -176,6 +186,57 @@ export async function createTermination(orgId: string, userId: string, data: any return { id: record.id } } +// 员工主动离职 +export async function createResignation(orgId: string, userId: string, data: any) { + const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } }) + if (!employee) { + throw { code: 'NOT_FOUND', message: '员工不存在' } + } + + // 校验:已有离职/解聘记录且未重新雇佣则不允许再次离职 + const latestTerm = await prisma.terminationRecord.findFirst({ + where: { employeeId: data.employeeId }, + orderBy: { terminationDate: 'desc' }, + }) + if (latestTerm && latestTerm.terminationDate >= employee.hireDate) { + throw { code: 'CONFLICT', message: '该员工已有离职/解聘记录,如需再次办理请先重新雇佣' } + } + + const record = await prisma.terminationRecord.create({ + data: { + orgId, + employeeId: data.employeeId, + type: 'RESIGNATION', + reason: 'RESIGNATION', + terminationDate: new Date(data.terminationDate), + resignationReason: data.resignationReason || null, + compensation: 0, + riskLevel: 'SAFE', + checklist: {}, + remark: data.remark || null, + createdBy: userId, + }, + }) + + // 根据离职日期判断在职/离职状态 + const termDate = new Date(data.terminationDate) + const today = new Date() + today.setHours(0, 0, 0, 0) + const isResigned = termDate <= today + + await prisma.employee.update({ + where: { id: data.employeeId }, + data: { status: isResigned ? 'RESIGNED' : 'ACTIVE' }, + }) + + await prisma.riskItem.updateMany({ + where: { employeeId: data.employeeId, status: 'PENDING' }, + data: { status: 'RESOLVED', resolvedAt: new Date() }, + }) + + return { id: record.id } +} + export async function getTerminations(orgId: string, page: number, pageSize: number) { const skip = (page - 1) * pageSize @@ -195,10 +256,13 @@ export async function getTerminations(orgId: string, page: number, pageSize: num id: r.id, employeeName: r.employee.name, department: r.employee.department, + type: r.type, reason: r.reason, + resignationReason: r.resignationReason, terminationDate: r.terminationDate.toISOString().slice(0, 10), compensation: r.compensation, riskLevel: r.riskLevel, + remark: r.remark, createdAt: r.createdAt.toISOString().slice(0, 10), })), total, diff --git a/frontend/src/pages/Roster.tsx b/frontend/src/pages/Roster.tsx index 475c81e..7b70377 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 } 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(null) const [search, setSearch] = useState('') const [showAddModal, setShowAddModal] = useState(false) + const [showResignModal, setShowResignModal] = useState(false) + const [resignEmployee, setResignEmployee] = useState(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() { 培训 绩效 工资条 + 操作 @@ -129,6 +142,23 @@ export default function Roster() { {e.counts?.trainingRecords || 0} {e.counts?.performanceRecords || 0} {e.counts?.payslips || 0} + + {e.status === 'ACTIVE' && !e.hasTermination && ( + + )} + {e.hasTermination && e.status === 'ACTIVE' && ( + 待离职 + )} + ))} @@ -145,6 +175,16 @@ export default function Roster() { error={addMutation.error as any} /> )} + + {showResignModal && resignEmployee && ( + { setShowResignModal(false); setResignEmployee(null) }} + onSubmit={(data) => resignMutation.mutate(data)} + loading={resignMutation.isPending} + error={resignMutation.error as any} + /> + )} ) } @@ -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 ( + +
+
+ 员工主动离职,不涉及经济补偿金。离职日期可在未来(提前办理),到日期后状态自动变为离职。 +
+
+ +
{employee.name} - {employee.department}
+
+
+ + setForm({ ...form, terminationDate: e.target.value })} + /> +
+
+ + +
+
+ + setForm({ ...form, remark: e.target.value })} placeholder="补充说明" /> +
+ {error && ( +
+ {(error as any)?.response?.data?.error?.message || '操作失败,请重试'} +
+ )} +
+ + +
+
+
+ ) +} + 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 = { NEGOTIATED: '协商解除', FAULT: '员工过错', NONFAULT: '非过错解除', LAYOFF: '经济性裁员', EXPIRED: '合同到期', ILLEGAL: '违法解除', + RESIGNATION: '员工主动离职', } const legalBasisMap: Record = { NEGOTIATED: '《劳动合同法》第36条', FAULT: '《劳动合同法》第39条', @@ -908,7 +1017,7 @@ function TerminationInfo({ employeeId, profile, records }: { employeeId: string; enabled: !!printRecord, }) - if (!records?.length) return
暂无解聘记录
+ if (!records?.length) return
暂无离职/解聘记录
if (printRecord) { return ( @@ -1031,26 +1140,42 @@ function TerminationInfo({ employeeId, profile, records }: { employeeId: string;
{t.terminationDate?.toString().slice(0, 10)} + {t.type === 'RESIGNATION' ? '主动离职' : '公司解聘'} {reasonMap[t.reason] || t.reason} - - {t.riskLevel === 'SAFE' ? '风险低' : t.riskLevel === 'WARNING' ? '注意' : '高风险'} - + {t.type !== 'RESIGNATION' && ( + + {t.riskLevel === 'SAFE' ? '风险低' : t.riskLevel === 'WARNING' ? '注意' : '高风险'} + + )}
-
- 经济补偿金 - ¥{fmt(t.compensation)} -
-
- 法律依据 - {legalBasisMap[t.reason] || '-'} -
+ {t.type === 'RESIGNATION' ? ( + <> +
+ 离职原因 + {t.resignationReason || '-'} +
+ + ) : ( + <> +
+ 经济补偿金 + ¥{fmt(t.compensation)} +
+
+ 法律依据 + {legalBasisMap[t.reason] || '-'} +
+ + )}
{t.remark &&
{t.remark}
}
- + {t.type !== 'RESIGNATION' && ( + + )}
diff --git a/frontend/src/pages/Termination.tsx b/frontend/src/pages/Termination.tsx index 52ef3ac..9ee1b19 100644 --- a/frontend/src/pages/Termination.tsx +++ b/frontend/src/pages/Termination.tsx @@ -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() {
{selectedEmployee.name}({selectedEmployee.department})
入职日期:{selectedEmployee.hireDate?.toString().slice(0, 10)}
月工资:¥{fmt(selectedEmployee.monthlySalary)}
+ {selectedEmployee.hasTermination && ( +
⚠️ 该员工已有离职/解聘记录,如需再次解聘请先办理重新雇佣
+ )} {selectedEmployee.latestContract ? (
合同状态:{selectedEmployee.latestContract.contractType === 'UNSIGNED' ? '未签订' : `签订于 ${selectedEmployee.latestContract.signDate?.slice(0, 10) || '未知'}`}
) : (