feat: 待签合同支持登记线下签署日期

- 线下手签合同:展开后可点击「登记签署日期」选择日期并保存
  保存后合同从待签列表移除(signDate 已填写)
- 电子签合同:签署日期由电签系统自动回写,不可手动修改
- 后端新增 POST /esign/sign-date 接口
  电子签合同拒绝手动修改签署日期

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-16 12:13:07 +08:00
parent 79145b47b9
commit 040c5a3ab9
8 changed files with 344 additions and 50 deletions
+3
View File
@@ -650,6 +650,9 @@ export const esignApi = {
/** 催办(生成自动登录链接) */
remind: (employeeId: string) =>
post('/esign/remind', { employeeId }).then(unwrap<any>()),
/** 登记线下合同签署日期 */
signDate: (contractId: string, signDate: string) =>
post('/esign/sign-date', { contractId, signDate }).then(unwrap<any>()),
create: (data: { contractId?: string; employeeId: string; documentTitle: string; documentContent?: string; remark?: string; scene?: string; templateId?: string; templateVars?: Record<string, string> }) =>
post('/esign/create', data),
detail: (id: string) =>
+59 -1
View File
@@ -788,9 +788,30 @@ function PendingTab({ pendingList, loading, onRemind, remindLoading, remindData,
remindData: any
onDetail: (id: string) => void
}) {
const queryClient = useQueryClient()
const [expandedEmp, setExpandedEmp] = useState<string | null>(null)
const [remindEmpId, setRemindEmpId] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [signDateEditing, setSignDateEditing] = useState<string | null>(null)
const [signDateValue, setSignDateValue] = useState('')
const [signDateSaving, setSignDateSaving] = useState(false)
/** 保存签署日期 */
const handleSaveSignDate = async (contractId: string) => {
if (!signDateValue) { toast.error('请选择签署日期'); return }
setSignDateSaving(true)
try {
await esignApi.signDate(contractId, signDateValue)
toast.success('签署日期已登记')
setSignDateEditing(null)
setSignDateValue('')
queryClient.invalidateQueries({ queryKey: ['esign-pending'] })
} catch (err: any) {
toast.error(err?.response?.data?.error?.message || '登记失败')
} finally {
setSignDateSaving(false)
}
}
const handleCopy = (url: string) => {
navigator.clipboard?.writeText(url)
@@ -908,7 +929,44 @@ function PendingTab({ pendingList, loading, onRemind, remindLoading, remindData,
</button>
)}
{item.type === 'contract' && (
<span className="text-gray-400 ml-auto"></span>
<div className="ml-auto flex items-center gap-2">
{signDateEditing === item.contractId ? (
<>
<input
type="date"
value={signDateValue}
onChange={(e) => setSignDateValue(e.target.value)}
className="h-7 px-2 text-xs border rounded"
/>
<button
className="text-xs text-primary hover:underline"
disabled={signDateSaving}
onClick={() => handleSaveSignDate(item.contractId)}
>
{signDateSaving ? '保存中...' : '保存'}
</button>
<button
className="text-xs text-gray-400 hover:text-gray-600"
onClick={() => { setSignDateEditing(null); setSignDateValue('') }}
>
</button>
</>
) : (
<>
<span className="text-gray-400"></span>
<button
className="text-xs text-primary hover:underline"
onClick={() => {
setSignDateEditing(item.contractId)
setSignDateValue(new Date().toISOString().slice(0, 10))
}}
>
</button>
</>
)}
</div>
)}
</div>
))}
+109 -29
View File
@@ -748,10 +748,15 @@ 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)
// 身份证校验位算法(GB 11643-1999
const ID_WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
const ID_CHECK_CODES = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
const handleIdCardChange = (idCard: string) => {
let gender = form.gender
if (idCard.length >= 17) {
@@ -762,33 +767,84 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
setIdCardDuplicate(null)
setAgeWarning(null)
if (idCard.length === 18) {
employeeApi.checkIdCard(idCard).then((data: { exists: boolean; employee?: any }) => {
setIdCardDuplicate(data)
}).catch(() => {})
// 年龄合规筛查:从证件号码提取出生日期计算年龄
// 1. 基本格式校验:前17位必须为数字
if (!/^\d{17}[\dXx]$/.test(idCard)) {
setAgeWarning({ type: 'BLOCK', message: '证件号码格式错误:前17位必须为数字,第18位为数字或X' })
return
}
// 2. 校验位验证
const sum = idCard.substring(0, 17).split('').reduce((s, c, i) => s + parseInt(c) * ID_WEIGHTS[i], 0)
const expectedCheck = ID_CHECK_CODES[sum % 11]
if (idCard[17].toUpperCase() !== expectedCheck) {
setAgeWarning({ type: 'BLOCK', message: '证件号码校验位错误,请检查输入是否正确' })
return
}
// 3. 出生日期合法性验证
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岁),建议确认是否按退休处理` })
}
if (birthMonth < 1 || birthMonth > 12 || birthDay < 1 || birthDay > 31) {
setAgeWarning({ type: 'BLOCK', message: '证件号码出生日期非法(月份或日期超出范围)' })
return
}
const birthDate = new Date(birthYear, birthMonth - 1, birthDay)
if (isNaN(birthDate.getTime()) || birthDate.getFullYear() !== birthYear || birthDate.getMonth() !== birthMonth - 1 || birthDate.getDate() !== birthDay) {
setAgeWarning({ type: 'BLOCK', message: '证件号码出生日期不存在(如2月30日)' })
return
}
if (birthDate > new Date()) {
setAgeWarning({ type: 'BLOCK', message: '证件号码出生日期晚于今天,不可录入' })
return
}
employeeApi.checkIdCard(idCard).then((data: { exists: boolean; employee?: any }) => {
setIdCardDuplicate(data)
}).catch(() => {})
// 4. 年龄合规筛查
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岁),不可签订劳动合同,请选择劳务协议` })
}
}
}
// 派生:是否超龄(用于限制合同类型)
const isOverage = (() => {
if (form.idCardNumber.length !== 18) return false
const birthYear = parseInt(form.idCardNumber.substring(6, 10))
const birthMonth = parseInt(form.idCardNumber.substring(10, 12))
const birthDay = parseInt(form.idCardNumber.substring(12, 14))
if (isNaN(birthYear) || isNaN(birthMonth) || isNaN(birthDay)) return false
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 (form.gender === '男') return age >= 60
// 女:干部55,工人50,未选类型按50
return age >= (form.femaleWorkerType === 'CADRE' ? 55 : 50)
})()
// 劳务协议/实习协议:不缴纳社保公积金
const isNoSocialContract = form.contractType === 'LABOR' || form.contractType === 'INTERNSHIP'
// 超龄时若当前合同类型非法(草稿恢复场景),自动切到 UNSIGNED
useEffect(() => {
if (isOverage && !['LABOR', 'INTERNSHIP', 'UNSIGNED'].includes(form.contractType)) {
setForm((prev: any) => ({ ...prev, contractType: 'UNSIGNED' }))
}
}, [isOverage, form.contractType])
// 计算合同月数
const contractMonths = (() => {
if (form.contractType !== 'FIXED' || !form.startDate) return 0
@@ -884,8 +940,11 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
const canSubmit = form.name && form.department && form.hireDate && form.monthlySalary
&& form.idCardNumber.length >= 18
&& ageWarning?.type !== 'BLOCK'
&& (form.contractType === 'UNSIGNED' || form.startDate)
&& !probationError && !probationSalaryError
// 超龄人员不得签订劳动合同
&& (!isOverage || ['LABOR', 'INTERNSHIP', 'UNSIGNED'].includes(form.contractType))
const isDirty = !!(form.name || form.department || form.idCardNumber || form.monthlySalary || form.phone)
useUnsavedChanges(isDirty)
@@ -975,24 +1034,28 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
<div className="flex items-center gap-2 mb-3">
<Briefcase className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700"></span>
<span className="text-xs text-gray-500"></span>
{isNoSocialContract ? (
<span className="text-xs text-amber-600">/</span>
) : (
<span className="text-xs text-gray-500"></span>
)}
</div>
<div className="grid grid-cols-4 gap-4">
<div className={`grid grid-cols-4 gap-4 ${isNoSocialContract ? 'opacity-50' : ''}`}>
<div>
<Label></Label>
<Input type="number" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={form.monthlySalary || '默认为月工资'} />
<Input type="number" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={form.monthlySalary || '默认为月工资'} disabled={isNoSocialContract} />
</div>
<div>
<Label></Label>
<Input type="month" value={form.socialInsStartMonth || hireMonth} onChange={(e) => setForm({ ...form, socialInsStartMonth: e.target.value })} />
<Input type="month" value={form.socialInsStartMonth || hireMonth} onChange={(e) => setForm({ ...form, socialInsStartMonth: e.target.value })} disabled={isNoSocialContract} />
</div>
<div>
<Label></Label>
<Input type="number" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={form.monthlySalary || '默认为月工资'} />
<Input type="number" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={form.monthlySalary || '默认为月工资'} disabled={isNoSocialContract} />
</div>
<div>
<Label></Label>
<Input type="month" value={form.housingFundStartMonth || hireMonth} onChange={(e) => setForm({ ...form, housingFundStartMonth: e.target.value })} />
<Input type="month" value={form.housingFundStartMonth || hireMonth} onChange={(e) => setForm({ ...form, housingFundStartMonth: e.target.value })} disabled={isNoSocialContract} />
</div>
</div>
</div>
@@ -1007,10 +1070,27 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
<Label></Label>
<Select value={form.contractType} onChange={(e) => {
const ct = contractTypes.find(t => t.value === e.target.value)
setForm({ ...form, contractType: e.target.value as any, endDate: ct && !ct.hasEndDate ? '' : form.endDate })
const newType = e.target.value as any
// 选劳务协议/实习协议 → 社保公积金置 0 并清空起始月
if (newType === 'LABOR' || newType === 'INTERNSHIP') {
setForm({
...form,
contractType: newType,
endDate: ct && !ct.hasEndDate ? '' : form.endDate,
socialInsBase: '0', socialInsStartMonth: '',
housingFundBase: '0', housingFundStartMonth: '',
})
} else {
setForm({ ...form, contractType: newType, endDate: ct && !ct.hasEndDate ? '' : form.endDate })
}
}}>
{contractTypes.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
{contractTypes
.filter(t => !isOverage || ['LABOR', 'INTERNSHIP', 'UNSIGNED'].includes(t.value))
.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</Select>
{isOverage && (
<div className="text-xs text-amber-600 mt-1">//</div>
)}
</div>
</div>
</div>