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:
selfrelease
2026-08-15 12:37:27 +08:00
parent 8cfdd566af
commit e1b5ae9aab
46 changed files with 3455 additions and 206 deletions
+34 -6
View File
@@ -3,6 +3,7 @@ import { useState, useRef } from "react"
import { toast } from "sonner"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { attachmentApi, employeeApi, socialInsuranceApi } from '../../lib/api-services'
import { useConfirm } from '../../hooks/useConfirm'
import { copyToClipboard } from '../../lib/clipboard'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
@@ -12,6 +13,7 @@ import { fmt } from "./shared"
export default function BasicInfo({ profile, employeeId, attachments }: { profile: any; employeeId: string; attachments: any[] }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [editing, setEditing] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'CERTIFICATE' | 'CONTRACT' | 'PHOTO' | 'OTHER'>('ID_CARD')
@@ -109,11 +111,36 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
enabled: !editing && !!profile.socialInsBase && !!profile.city,
})
const handleSave = () => {
const handleSave = async () => {
if (form.city !== (profile.city || '') && !form.cityChangeReason.trim()) {
toast.error('参保城市变更必须填写变更原因')
return
}
// 入职日期变更联动状态确认
const originalHireDate = profile.hireDate?.toString().slice(0, 10) || ''
let statusUpdate: string | undefined
if (form.hireDate && form.hireDate !== originalHireDate) {
const today = new Date()
today.setHours(0, 0, 0, 0)
const newHireDate = new Date(form.hireDate)
const currentStatus = profile.status || 'ACTIVE'
let newStatus = currentStatus
if (newHireDate > today) {
newStatus = 'PENDING_ONBOARD'
} else {
newStatus = 'ACTIVE'
}
if (newStatus !== currentStatus) {
const statusLabel = newStatus === 'PENDING_ONBOARD' ? '待入职' : '在职'
const currentLabel = currentStatus === 'PENDING_ONBOARD' ? '待入职' : currentStatus === 'ACTIVE' ? '在职' : currentStatus
const confirmed = await confirm({
title: '入职日期变更确认',
message: `入职日期从 ${originalHireDate} 变更为 ${form.hireDate},员工状态将从"${currentLabel}"变更为"${statusLabel}",是否继续?`,
})
if (!confirmed) return
statusUpdate = newStatus
}
}
const data: any = {
department: form.department,
gender: form.gender,
@@ -137,6 +164,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
position: form.position || undefined,
cityChangeReason: form.city !== profile.city ? form.cityChangeReason || undefined : undefined,
}
if (statusUpdate) data.status = statusUpdate
updateMutation.mutate(data)
}
@@ -147,7 +175,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
...(profile.gender === '女'
? [{ label: '女性岗位', value: profile.femaleWorkerType === 'CADRE' ? '干部/管理岗' : profile.femaleWorkerType === 'WORKER' ? '工人/操作岗' : '未填写' }]
: []),
{ label: '身份证号', value: profile.idCardNumber || '未填写' },
{ label: '证件号码', value: profile.idCardNumber || '未填写' },
{ label: '手机号', value: profile.phone || '未填写' },
{ label: '学历', value: profile.education || '未填写' },
{ label: '职务/岗位', value: profile.position || '未填写' },
@@ -221,13 +249,13 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
<span className="text-gray-500 shrink-0">{f.label}</span>
<span className="font-medium text-right truncate ml-2 flex items-center gap-1">
{f.value}
{f.label === '身份证号' && profile.idCardNumber && (
{f.label === '证件号码' && profile.idCardNumber && (
<button
type="button"
className="text-gray-400 hover:text-primary transition-colors shrink-0"
title="复制身份证号"
title="复制证件号码"
onClick={() => {
copyToClipboard(profile.idCardNumber, '已复制身份证号')
copyToClipboard(profile.idCardNumber, '已复制证件号码')
}}
>
<Copy className="w-3 h-3" />
@@ -317,7 +345,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
) : (
<div className="grid md:grid-cols-2 gap-3">
<div><Label></Label><Input value={profile.name} disabled /></div>
<div><Label></Label><Input value={profile.idCardNumber || ''} disabled /></div>
<div><Label></Label><Input value={profile.idCardNumber || ''} disabled /></div>
<div><Label></Label><Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} /></div>
<div><Label></Label><Select value={form.gender} onChange={(e) => setForm({ ...form, gender: e.target.value as '男' | '女' })}><option value="男"></option><option value="女"></option></Select></div>
{form.gender === '女' && (
+187 -46
View File
@@ -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