feat: 20260815 系统优化 - 全部31项问题修复(P0×6+P1×14+P2×9+P3×2)
P0紧急修复(6项): - 草稿保存完整恢复所有字段(含socialAvgWage) - 补偿金批次从compensationBreakdown读取 - 违法解除风险确认UI - 合同结束日期前后校验(前后端双保险) P1高优先级(14项): - 离职日期联动社保/公积金截止月(15号规则) - 合规检查+工作交接改为软阻断(生成待办) - 补偿月数(N/N+1/2N/自定义)+计算基数(近12月/合同/自定义) - 解聘并入花名册操作栏(类型选择跳转向导) - 合同续签开始日期自动推导(原合同结束日+1天) - 年龄合规筛查(童工阻断/未成年工/退休警告) - 编辑入职日期后状态联动(待入职↔在职) - 转正移植到花名册操作栏+薪资回写 - 男职工无法选择三期 P2体验优化(9项): - "劳动合同"调整为"用工关系" - 费用结算新增剩余年假折算(300%日工资) - 身份证号全域改为"证件号码"(前后端18个文件) - 手机号查重 - 开具证明+合同续签移植到花名册操作栏 - 批量转正+批量开具证明 - 去掉用工办理模块 P3规划(2项): - 组织架构+审批流(Department/Position/ApprovalFlow/ApprovalInstance) - 客服工作台(Ticket/ChatSession+SUPPORT角色) 新增模型: Department/Position/ApprovalFlow/ApprovalInstance/Ticket/TicketMessage/ChatSession/ChatMessage 新增字段: Employee.departmentId/supervisorId 新增角色: SUPPORT Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { rosterApi, socialInsuranceApi, employeeApi } from '../../lib/api-services'
|
||||
import Button from "../../components/ui/Button"
|
||||
import { Input, Label, Select } from "../../components/ui/Input"
|
||||
@@ -152,69 +153,159 @@ export function ResignModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
socialInsEndMonth: '',
|
||||
housingFundEndMonth: '',
|
||||
})
|
||||
/** 离职/解聘类型:主动离职走快速流程,其他类型跳转解聘向导 */
|
||||
const [termType, setTermType] = useState<'RESIGNATION' | 'NEGOTIATED' | 'FAULT' | 'NONFAULT' | 'LAYOFF' | 'EXPIRED' | 'ILLEGAL'>('RESIGNATION')
|
||||
|
||||
const terminationMonth = form.terminationDate ? form.terminationDate.slice(0, 7) : ''
|
||||
|
||||
const reasons = ['个人原因', '职业发展', '薪资不满意', '家庭原因', '身体原因', '其他']
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (termType === 'RESIGNATION') {
|
||||
onSubmit({
|
||||
employeeId: employee.id,
|
||||
terminationDate: new Date(form.terminationDate).toISOString(),
|
||||
resignationReason: form.resignationReason,
|
||||
remark: form.remark || undefined,
|
||||
socialInsEndMonth: form.socialInsEndMonth || terminationMonth,
|
||||
housingFundEndMonth: form.housingFundEndMonth || terminationMonth,
|
||||
})
|
||||
} else {
|
||||
// 非主动离职:跳转 Termination 向导,通过 URL 参数传递
|
||||
const params = new URLSearchParams({
|
||||
employeeId: employee.id,
|
||||
reason: termType,
|
||||
})
|
||||
window.location.href = `/termination?${params.toString()}`
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={`办理离职/解聘 - ${employee.name}`}>
|
||||
<div className="space-y-3">
|
||||
{/* 解聘类型选择 */}
|
||||
<div>
|
||||
<Label>离职/解聘类型</Label>
|
||||
<Select value={termType} onChange={(e) => setTermType(e.target.value as any)}>
|
||||
<option value="RESIGNATION">主动离职(快速办理)</option>
|
||||
<option value="NEGOTIATED">协商解除</option>
|
||||
<option value="FAULT">过错解除</option>
|
||||
<option value="NONFAULT">非过错解除</option>
|
||||
<option value="LAYOFF">裁员</option>
|
||||
<option value="EXPIRED">到期不续签</option>
|
||||
<option value="ILLEGAL">违法解除</option>
|
||||
</Select>
|
||||
</div>
|
||||
{termType === 'RESIGNATION' ? (
|
||||
<>
|
||||
<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.terminationDate}
|
||||
onChange={(e) => setForm({ ...form, terminationDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>离职原因</Label>
|
||||
<Select value={form.resignationReason} onChange={(e) => setForm({ ...form, resignationReason: e.target.value })}>
|
||||
{reasons.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<Label>社保公积金截止缴费年月</Label>
|
||||
<div className="text-xs text-gray-400 mb-2">默认与离职日期同月,可手动修改</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>社保截止年月</Label>
|
||||
<Input type="month" value={form.socialInsEndMonth || terminationMonth} onChange={(e) => setForm({ ...form, socialInsEndMonth: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>公积金截止年月</Label>
|
||||
<Input type="month" value={form.housingFundEndMonth || terminationMonth} onChange={(e) => setForm({ ...form, housingFundEndMonth: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
{((form.socialInsEndMonth && form.socialInsEndMonth !== terminationMonth) || (form.housingFundEndMonth && form.housingFundEndMonth !== terminationMonth)) && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-amber-50 text-warning text-xs mt-2">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
截止缴费年月与离职日期不在同月,请确认是否为多缴/少缴月份。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>备注(选填)</Label>
|
||||
<Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} placeholder="补充说明" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="bg-amber-50 text-amber-700 text-xs px-3 py-2 rounded-md space-y-1">
|
||||
<div className="font-medium">解聘流程</div>
|
||||
<p>选择"{termType === 'NEGOTIATED' ? '协商解除' : termType === 'FAULT' ? '过错解除' : termType === 'NONFAULT' ? '非过错解除' : termType === 'LAYOFF' ? '裁员' : termType === 'EXPIRED' ? '到期不续签' : '违法解除'}"将进入解聘向导,支持补偿金计算、合规检查、工作交接等完整流程。</p>
|
||||
<p>点击"进入解聘向导"跳转到解聘补偿页面。</p>
|
||||
</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}>
|
||||
{termType === 'RESIGNATION' ? (loading ? '提交中...' : '确认离职') : '进入解聘向导'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/** 转正确认弹窗 */
|
||||
export function ConfirmModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
employee: any
|
||||
onClose: () => void
|
||||
onSubmit: (data: any) => void
|
||||
loading: boolean
|
||||
error: any
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
confirmDate: new Date().toISOString().slice(0, 10),
|
||||
regularSalary: '',
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit({
|
||||
employeeId: employee.id,
|
||||
terminationDate: new Date(form.terminationDate).toISOString(),
|
||||
resignationReason: form.resignationReason,
|
||||
remark: form.remark || undefined,
|
||||
socialInsEndMonth: form.socialInsEndMonth || terminationMonth,
|
||||
housingFundEndMonth: form.housingFundEndMonth || terminationMonth,
|
||||
confirmDate: new Date(form.confirmDate).toISOString(),
|
||||
regularSalary: form.regularSalary ? Number(form.regularSalary) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={`办理离职 - ${employee.name}`}>
|
||||
<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.terminationDate}
|
||||
onChange={(e) => setForm({ ...form, terminationDate: e.target.value })}
|
||||
/>
|
||||
<Label>转正日期 *</Label>
|
||||
<Input type="date" value={form.confirmDate} onChange={(e) => setForm({ ...form, confirmDate: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>离职原因</Label>
|
||||
<Select value={form.resignationReason} onChange={(e) => setForm({ ...form, resignationReason: e.target.value })}>
|
||||
{reasons.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<Label>社保公积金截止缴费年月</Label>
|
||||
<div className="text-xs text-gray-400 mb-2">默认与离职日期同月,可手动修改</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>社保截止年月</Label>
|
||||
<Input type="month" value={form.socialInsEndMonth || terminationMonth} onChange={(e) => setForm({ ...form, socialInsEndMonth: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>公积金截止年月</Label>
|
||||
<Input type="month" value={form.housingFundEndMonth || terminationMonth} onChange={(e) => setForm({ ...form, housingFundEndMonth: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
{((form.socialInsEndMonth && form.socialInsEndMonth !== terminationMonth) || (form.housingFundEndMonth && form.housingFundEndMonth !== terminationMonth)) && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-amber-50 text-warning text-xs mt-2">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
截止缴费年月与离职日期不在同月,请确认是否为多缴/少缴月份。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>备注(选填)</Label>
|
||||
<Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} placeholder="补充说明" />
|
||||
<Label>转正薪资(选填)</Label>
|
||||
<Input type="number" value={form.regularSalary} onChange={(e) => setForm({ ...form, regularSalary: e.target.value })} placeholder="不填则保持原薪资" />
|
||||
<div className="text-xs text-gray-400 mt-1">填写后将更新员工月工资并记录薪资变更</div>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-danger">
|
||||
@@ -224,7 +315,7 @@ export function ResignModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading}>
|
||||
{loading ? '提交中...' : '确认离职'}
|
||||
{loading ? '提交中...' : '确认转正'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -554,8 +645,10 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
}
|
||||
}
|
||||
|
||||
// 根据身份证号自动计算性别(第17位:奇数=男,偶数=女)+ 查重
|
||||
// 根据证件号码自动计算性别(第17位:奇数=男,偶数=女)+ 查重 + 年龄合规筛查
|
||||
const [idCardDuplicate, setIdCardDuplicate] = useState<{ exists: boolean; employee?: any } | null>(null)
|
||||
const [ageWarning, setAgeWarning] = useState<{ type: 'BLOCK' | 'WARN'; message: string } | null>(null)
|
||||
const [phoneDuplicate, setPhoneDuplicate] = useState<{ exists: boolean; employee?: any } | null>(null)
|
||||
const handleIdCardChange = (idCard: string) => {
|
||||
let gender = form.gender
|
||||
if (idCard.length >= 17) {
|
||||
@@ -564,10 +657,32 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
}
|
||||
setForm({ ...form, idCardNumber: idCard, gender })
|
||||
setIdCardDuplicate(null)
|
||||
setAgeWarning(null)
|
||||
if (idCard.length === 18) {
|
||||
employeeApi.checkIdCard(idCard).then((data: { exists: boolean; employee?: any }) => {
|
||||
setIdCardDuplicate(data)
|
||||
}).catch(() => {})
|
||||
// 年龄合规筛查:从证件号码提取出生日期计算年龄
|
||||
const birthYear = parseInt(idCard.substring(6, 10))
|
||||
const birthMonth = parseInt(idCard.substring(10, 12))
|
||||
const birthDay = parseInt(idCard.substring(12, 14))
|
||||
if (!isNaN(birthYear) && !isNaN(birthMonth) && !isNaN(birthDay)) {
|
||||
const today = new Date()
|
||||
let age = today.getFullYear() - birthYear
|
||||
const monthDiff = today.getMonth() - (birthMonth - 1)
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDay)) {
|
||||
age--
|
||||
}
|
||||
if (age < 16) {
|
||||
setAgeWarning({ type: 'BLOCK', message: `该员工年龄 ${age} 岁,未满16周岁,禁止招用童工(《劳动法》第15条)` })
|
||||
} else if (age < 18) {
|
||||
setAgeWarning({ type: 'WARN', message: `该员工年龄 ${age} 岁,未满18周岁,属于未成年工,需遵守特殊保护规定(《劳动法》第58条)` })
|
||||
} else if (gender === '男' && age >= 60) {
|
||||
setAgeWarning({ type: 'WARN', message: `该员工年龄 ${age} 岁,已达法定退休年龄(男60岁),建议确认是否按退休处理` })
|
||||
} else if (gender === '女' && age >= 50) {
|
||||
setAgeWarning({ type: 'WARN', message: `该员工年龄 ${age} 岁,已达或接近法定退休年龄(女工人50岁/干部55岁),建议确认是否按退休处理` })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,6 +748,11 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
// 童工阻断:未满16周岁禁止录入
|
||||
if (ageWarning?.type === 'BLOCK') {
|
||||
toast.error(ageWarning.message)
|
||||
return
|
||||
}
|
||||
const data: any = {
|
||||
name: form.name, department: form.department,
|
||||
position: form.position || undefined,
|
||||
@@ -684,11 +804,17 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
<div><Label>姓名 *</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="员工姓名" /></div>
|
||||
<div><Label>部门 *</Label><Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} placeholder="如:技术部" /></div>
|
||||
<div><Label>职务/岗位</Label><Input value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" /></div>
|
||||
<div><Label>身份证号 *</Label><Input value={form.idCardNumber} onChange={(e) => handleIdCardChange(e.target.value)} placeholder="18位" maxLength={18} /></div>
|
||||
<div><Label>证件号码 *</Label><Input value={form.idCardNumber} onChange={(e) => handleIdCardChange(e.target.value)} placeholder="18位" maxLength={18} /></div>
|
||||
{idCardDuplicate?.exists && (
|
||||
<div className="col-span-4 px-3 py-2 rounded-md bg-amber-50 text-amber-700 text-xs flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>该身份证号已存在:{idCardDuplicate.employee?.name}({idCardDuplicate.employee?.department}),请确认是否重复录入</span>
|
||||
<span>该证件号码已存在:{idCardDuplicate.employee?.name}({idCardDuplicate.employee?.department}),请确认是否重复录入</span>
|
||||
</div>
|
||||
)}
|
||||
{ageWarning && (
|
||||
<div className={`col-span-4 px-3 py-2 rounded-md text-xs flex items-center gap-2 ${ageWarning.type === 'BLOCK' ? 'bg-red-50 text-danger' : 'bg-amber-50 text-amber-700'}`}>
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>{ageWarning.message}</span>
|
||||
</div>
|
||||
)}
|
||||
<div><Label>性别</Label><div className="text-sm text-gray-600 py-2">{form.idCardNumber.length >= 17 ? form.gender : '自动识别'}</div></div>
|
||||
@@ -699,8 +825,23 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div><Label>入职日期 *</Label><Input type="date" value={form.hireDate} onChange={(e) => handleHireDateChange(e.target.value)} /></div>
|
||||
<div><Label>月工资 *</Label><Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" /></div>
|
||||
<div><Label>手机号</Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
|
||||
<div><Label>手机号</Label><Input value={form.phone} onChange={(e) => {
|
||||
const phone = e.target.value.replace(/\D/g, '').slice(0, 11)
|
||||
setForm({ ...form, phone })
|
||||
setPhoneDuplicate(null)
|
||||
if (phone.length === 11) {
|
||||
employeeApi.checkPhone(phone).then((data: { exists: boolean; employee?: any }) => {
|
||||
setPhoneDuplicate(data)
|
||||
}).catch(() => {})
|
||||
}
|
||||
}} placeholder="选填" maxLength={11} /></div>
|
||||
</div>
|
||||
{phoneDuplicate?.exists && (
|
||||
<div className="px-3 py-2 rounded-md bg-amber-50 text-amber-700 text-xs flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>该手机号已存在:{phoneDuplicate.employee?.name}({phoneDuplicate.employee?.department}),请确认是否重复录入</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div><Label>参保城市</Label><Select value={form.city} onChange={async (e) => {
|
||||
const city = e.target.value
|
||||
|
||||
Reference in New Issue
Block a user