014c94e482
1. 账户管理:新建/编辑时可勾选关联 level=0 根部门(公司/分公司/子公司) 2. 后端新增 API: - PUT /social/accounts/:id/departments 批量关联根部门 - GET /social/accounts/:id/departments 查询已关联部门 - GET /social/department-account/:departmentId 按部门带出适用账户+标准 3. 部门更新 API 支持 socialAccountId/housingAccountId 字段 4. 员工新增表单:选定部门后自动带出社保公积金账户,可手动调整 5. 参保记录创建时写入 accountId Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1218 lines
57 KiB
TypeScript
1218 lines
57 KiB
TypeScript
import { useState, useEffect } from "react"
|
||
import { useQuery } from "@tanstack/react-query"
|
||
import { toast } from "sonner"
|
||
import { rosterApi, socialInsuranceApi, employeeApi, socialAccountApi } from '../../lib/api-services'
|
||
import api from '../../lib/api'
|
||
import Button from "../../components/ui/Button"
|
||
import { Input, Label, Select } from "../../components/ui/Input"
|
||
import Modal from "../../components/ui/Modal"
|
||
import { AlertTriangle, Briefcase, FileSignature } from "lucide-react"
|
||
import { useUnsavedChanges } from "../../hooks/useUnsavedChanges"
|
||
import { fmt } from "./shared"
|
||
|
||
export function SalaryChangeModal({ employee, onClose, onSubmit, loading, error }: {
|
||
employee: any
|
||
onClose: () => void
|
||
onSubmit: (data: any) => void
|
||
loading: boolean
|
||
error: any
|
||
}) {
|
||
const todayStr = new Date().toISOString().slice(0, 10)
|
||
const [form, setForm] = useState({
|
||
newSalary: '',
|
||
effectiveDate: todayStr,
|
||
reason: '',
|
||
})
|
||
|
||
const handleSubmit = () => {
|
||
onSubmit({
|
||
newSalary: parseFloat(form.newSalary),
|
||
effectiveDate: new Date(form.effectiveDate).toISOString(),
|
||
reason: form.reason || undefined,
|
||
})
|
||
}
|
||
|
||
const canSubmit = form.newSalary && parseFloat(form.newSalary) > 0 && form.effectiveDate
|
||
|
||
return (
|
||
<Modal open onClose={onClose} title={`调薪 - ${employee.name}`}>
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label>员工</Label>
|
||
<div className="text-xs text-gray-600 py-1.5">{employee.name} - {employee.department}</div>
|
||
</div>
|
||
<div>
|
||
<Label>当前月薪</Label>
|
||
<div className="text-xs text-gray-600 py-1.5">¥{fmt(employee.monthlySalary)}</div>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label>新月薪 *</Label>
|
||
<Input type="number" value={form.newSalary} onChange={(e) => setForm({ ...form, newSalary: e.target.value })} placeholder="元" />
|
||
</div>
|
||
<div>
|
||
<Label>生效日期 *</Label>
|
||
<Input type="date" value={form.effectiveDate} onChange={(e) => setForm({ ...form, effectiveDate: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<Label>调薪原因(选填)</Label>
|
||
<Input value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} placeholder="如:年度调薪、晋升加薪" />
|
||
</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 || !canSubmit}>{loading ? '保存中...' : '确认调薪'}</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
export function DeptChangeModal({ employee, onClose, onSubmit, loading, error }: {
|
||
employee: any
|
||
onClose: () => void
|
||
onSubmit: (data: any) => void
|
||
loading: boolean
|
||
error: any
|
||
}) {
|
||
const todayStr = new Date().toISOString().slice(0, 10)
|
||
const [form, setForm] = useState({
|
||
departmentId: employee.departmentId || '',
|
||
newPosition: employee.position || '',
|
||
effectiveDate: todayStr,
|
||
reason: '',
|
||
})
|
||
|
||
// 拉取组织架构部门和职务列表
|
||
const { data: departments = [] } = useQuery({
|
||
queryKey: ['departments'],
|
||
queryFn: () => api.get('/departments').then(r => r.data),
|
||
})
|
||
const { data: positions = [] } = useQuery({
|
||
queryKey: ['positions'],
|
||
queryFn: () => api.get('/positions').then(r => r.data),
|
||
})
|
||
|
||
// 构建部门树形下拉选项(带层级缩进)
|
||
const deptOptions: { id: string; label: string; level: number }[] = []
|
||
const buildDeptOptions = (items: any[], parentId: string | null, level: number) => {
|
||
items.filter(d => d.parentId === parentId).sort((a, b) => a.sortOrder - b.sortOrder).forEach(d => {
|
||
deptOptions.push({ id: d.id, label: d.name, level })
|
||
buildDeptOptions(items, d.id, level + 1)
|
||
})
|
||
}
|
||
buildDeptOptions(departments, null, 0)
|
||
|
||
// 选中新部门后,过滤该部门下的职务:
|
||
// 1. 通用职务(departmentId 为空)所有部门可用
|
||
// 2. 该部门专属职务
|
||
// 3. 父部门专属职务(子部门继承父部门职务,如铁矿继承项目运营中心的工人/施工员等)
|
||
const getDeptAncestorIds = (deptId: string): string[] => {
|
||
const ids: string[] = []
|
||
let cur: any = departments.find((d: any) => d.id === deptId)
|
||
while (cur) {
|
||
ids.push(cur.id)
|
||
cur = cur.parentId ? departments.find((d: any) => d.id === cur.parentId) : null
|
||
}
|
||
return ids
|
||
}
|
||
const ancestorIds = form.departmentId ? getDeptAncestorIds(form.departmentId) : []
|
||
const filteredPositions = form.departmentId
|
||
? positions.filter((p: any) => !p.departmentId || ancestorIds.includes(p.departmentId))
|
||
: positions
|
||
|
||
const selectedDept = departments.find((d: any) => d.id === form.departmentId)
|
||
const hasChange = (form.departmentId && form.departmentId !== employee.departmentId) ||
|
||
(form.newPosition && form.newPosition !== employee.position)
|
||
|
||
const handleSubmit = () => {
|
||
onSubmit({
|
||
departmentId: form.departmentId || undefined,
|
||
newDepartment: selectedDept?.name || employee.department,
|
||
newPosition: form.newPosition || undefined,
|
||
effectiveMonth: form.effectiveDate.slice(0, 7),
|
||
reason: form.reason || undefined,
|
||
})
|
||
}
|
||
|
||
const canSubmit = form.effectiveDate && hasChange
|
||
|
||
return (
|
||
<Modal open onClose={onClose} title={`调动 - ${employee.name}`}>
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label>员工</Label>
|
||
<div className="text-xs text-gray-600 py-1.5">{employee.name}</div>
|
||
</div>
|
||
<div>
|
||
<Label>当前部门 / 职务</Label>
|
||
<div className="text-xs text-gray-600 py-1.5">{employee.department} / {employee.position || '—'}</div>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label>目标部门 *</Label>
|
||
<Select value={form.departmentId} onChange={(e) => setForm({ ...form, departmentId: e.target.value })}>
|
||
<option value="">请选择部门</option>
|
||
{deptOptions.map(d => (
|
||
<option key={d.id} value={d.id}>{' '.repeat(d.level)}{d.label}</option>
|
||
))}
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<Label>新职务</Label>
|
||
<Select value={form.newPosition} onChange={(e) => setForm({ ...form, newPosition: e.target.value })}>
|
||
<option value="">保持不变</option>
|
||
{filteredPositions.map((p: any) => (
|
||
<option key={p.id} value={p.name}>{p.name}{p.level ? `(${p.level})` : ''}</option>
|
||
))}
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<Label>生效日期 *</Label>
|
||
<Input type="date" value={form.effectiveDate} onChange={(e) => setForm({ ...form, effectiveDate: e.target.value })} />
|
||
</div>
|
||
<div>
|
||
<Label>调动原因(选填)</Label>
|
||
<Input value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} placeholder="如:组织架构调整、岗位轮换" />
|
||
</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 || !canSubmit}>{loading ? '保存中...' : '确认调动'}</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
export 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: '',
|
||
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 originalSalary = employee.monthlySalary ? Number(employee.monthlySalary) : null
|
||
const [form, setForm] = useState({
|
||
confirmDate: new Date().toISOString().slice(0, 10),
|
||
regularSalary: originalSalary ? String(originalSalary) : '',
|
||
})
|
||
|
||
/** 转正薪资与原薪资是否不同 */
|
||
const salaryChanged = form.regularSalary !== '' && Number(form.regularSalary) !== originalSalary
|
||
|
||
const handleSubmit = () => {
|
||
onSubmit({
|
||
employeeId: employee.id,
|
||
confirmDate: new Date(form.confirmDate).toISOString(),
|
||
regularSalary: form.regularSalary ? Number(form.regularSalary) : undefined,
|
||
})
|
||
}
|
||
|
||
return (
|
||
<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.confirmDate} onChange={(e) => setForm({ ...form, confirmDate: e.target.value })} />
|
||
</div>
|
||
<div>
|
||
<Label>转正薪资{originalSalary ? `(原薪资 ¥${originalSalary})` : ''}</Label>
|
||
<Input type="number" value={form.regularSalary} onChange={(e) => setForm({ ...form, regularSalary: e.target.value })} placeholder={originalSalary ? `不填则保持原薪资 ¥${originalSalary}` : '请输入转正薪资'} />
|
||
<div className="text-xs text-gray-400 mt-1">
|
||
{originalSalary
|
||
? (salaryChanged
|
||
? <span className="text-amber-600">⚠ 转正薪资与原薪资不同,提交后将发起薪酬调整确认书签署</span>
|
||
: '填写后将更新员工月工资并记录薪资变更')
|
||
: '填写后将更新员工月工资并记录薪资变更'}
|
||
</div>
|
||
</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}>
|
||
{loading ? '提交中...' : '确认转正'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
||
employee: any
|
||
onClose: () => void
|
||
onSubmit: (data: any) => void
|
||
loading: boolean
|
||
error: any
|
||
}) {
|
||
const todayStr = new Date().toISOString().slice(0, 10)
|
||
const { data: contractTypes = [] } = useQuery<Array<{ value: string; label: string; hasEndDate: boolean }>>({
|
||
queryKey: ['contract-types'],
|
||
queryFn: async () => {
|
||
return await rosterApi.contractTypes()
|
||
},
|
||
staleTime: Infinity,
|
||
})
|
||
// 拉取组织架构部门列表,用于部门下拉选择
|
||
const { data: departments = [] } = useQuery({
|
||
queryKey: ['departments'],
|
||
queryFn: () => api.get('/departments').then(r => r.data),
|
||
})
|
||
// 构建部门树形下拉选项(带层级缩进)
|
||
const deptOptions: { id: string; label: string; level: number }[] = []
|
||
const buildDeptOptions = (items: any[], parentId: string | null, level: number) => {
|
||
items.filter(d => d.parentId === parentId).sort((a, b) => a.sortOrder - b.sortOrder).forEach(d => {
|
||
deptOptions.push({ id: d.id, label: d.name, level })
|
||
buildDeptOptions(items, d.id, level + 1)
|
||
})
|
||
}
|
||
buildDeptOptions(departments, null, 0)
|
||
const defaultEndDate = (() => {
|
||
const d = new Date()
|
||
d.setFullYear(d.getFullYear() + 3)
|
||
d.setDate(d.getDate() - 1)
|
||
return d.toISOString().slice(0, 10)
|
||
})()
|
||
const [form, setForm] = useState({
|
||
hireDate: todayStr,
|
||
department: employee.department || '',
|
||
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
|
||
signDate: '',
|
||
startDate: todayStr,
|
||
endDate: defaultEndDate,
|
||
contractYears: 3,
|
||
probationMonths: 0,
|
||
probationSalary: 0,
|
||
socialInsBase: '', socialInsStartMonth: '',
|
||
housingFundBase: '', housingFundStartMonth: '',
|
||
})
|
||
const hireMonth = form.hireDate ? form.hireDate.slice(0, 7) : ''
|
||
|
||
// 计算合同月数
|
||
const contractMonths = (() => {
|
||
if (form.contractType !== 'FIXED' || !form.startDate) return 0
|
||
if (form.endDate) {
|
||
const start = new Date(form.startDate)
|
||
const end = new Date(form.endDate)
|
||
return Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
|
||
}
|
||
return form.contractYears * 12
|
||
})()
|
||
|
||
// 试用期上限(劳动合同法第19条)
|
||
const probationMax = (() => {
|
||
if (contractMonths >= 36) return 6
|
||
if (contractMonths >= 12) return 2
|
||
if (contractMonths >= 3) return 1
|
||
return 0
|
||
})()
|
||
|
||
const probationError = (() => {
|
||
if (form.probationMonths <= 0) return ''
|
||
if (contractMonths > 0 && contractMonths < 3) return '合同不足3个月,不得约定试用期'
|
||
if (form.probationMonths > probationMax) return `合同${contractMonths}个月,试用期最多${probationMax}个月`
|
||
return ''
|
||
})()
|
||
|
||
const monthlySalaryNum = employee?.monthlySalary || 0
|
||
const probationSalaryError = (() => {
|
||
if (form.probationMonths <= 0) return ''
|
||
if (form.probationSalary <= 0) return '有试用期时试用期工资必填'
|
||
if (monthlySalaryNum > 0 && form.probationSalary < monthlySalaryNum * 0.8) {
|
||
return `试用期工资不得低于转正工资的80%(最低¥${(monthlySalaryNum * 0.8).toFixed(0)})`
|
||
}
|
||
return ''
|
||
})()
|
||
|
||
// 合同结束日期自动计算
|
||
const handleContractYearsChange = (years: number) => {
|
||
if (!form.startDate || years <= 0) {
|
||
setForm({ ...form, contractYears: years, endDate: '' })
|
||
return
|
||
}
|
||
const start = new Date(form.startDate)
|
||
const end = new Date(start)
|
||
end.setFullYear(end.getFullYear() + years)
|
||
end.setDate(end.getDate() - 1)
|
||
setForm({ ...form, contractYears: years, endDate: end.toISOString().slice(0, 10) })
|
||
}
|
||
|
||
// 合同结束日期变更 → 自动计算签约年限
|
||
const handleEndDateChange = (endDate: string) => {
|
||
if (!form.startDate || !endDate) {
|
||
setForm({ ...form, endDate, contractYears: 0 })
|
||
return
|
||
}
|
||
const start = new Date(form.startDate)
|
||
const end = new Date(endDate)
|
||
const months = Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
|
||
setForm({ ...form, endDate, contractYears: Math.max(1, Math.round(months / 12)) })
|
||
}
|
||
|
||
// 入职日期变更 → 同步合同开始日期 + 重算结束日期
|
||
const handleHireDateChange = (hireDate: string) => {
|
||
if (form.contractType === 'FIXED' && form.contractYears > 0 && hireDate) {
|
||
const start = new Date(hireDate)
|
||
const end = new Date(start)
|
||
end.setFullYear(end.getFullYear() + form.contractYears)
|
||
end.setDate(end.getDate() - 1)
|
||
setForm({ ...form, hireDate, startDate: hireDate, endDate: end.toISOString().slice(0, 10) })
|
||
} else {
|
||
setForm({ ...form, hireDate, startDate: hireDate })
|
||
}
|
||
}
|
||
|
||
const handleSubmit = () => {
|
||
const data: any = {
|
||
hireDate: new Date(form.hireDate).toISOString(),
|
||
department: form.department,
|
||
socialInsBase: form.socialInsBase ? parseFloat(form.socialInsBase) : undefined,
|
||
socialInsStartMonth: form.socialInsStartMonth || undefined,
|
||
housingFundBase: form.housingFundBase ? parseFloat(form.housingFundBase) : undefined,
|
||
housingFundStartMonth: form.housingFundStartMonth || undefined,
|
||
}
|
||
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)
|
||
}
|
||
|
||
const canSubmit = form.hireDate
|
||
&& (form.contractType === 'UNSIGNED' || form.startDate)
|
||
&& !probationError && !probationSalaryError
|
||
|
||
return (
|
||
<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 className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label>员工</Label>
|
||
<div className="text-xs text-gray-600 py-1.5">{employee.name}</div>
|
||
</div>
|
||
<div>
|
||
<Label>部门 *</Label>
|
||
<Select value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })}>
|
||
<option value="">请选择部门</option>
|
||
{deptOptions.map(d => (
|
||
<option key={d.id} value={d.label}>{' '.repeat(d.level)}{d.label}</option>
|
||
))}
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<Label>新入职日期 *</Label>
|
||
<Input type="date" value={form.hireDate} onChange={(e) => handleHireDateChange(e.target.value)} />
|
||
</div>
|
||
<div className="border-t pt-3">
|
||
<Label>社保公积金</Label>
|
||
<div className="text-xs text-gray-400 mb-2">默认与月工资一致,可手动修改</div>
|
||
<div className="grid grid-cols-4 gap-3">
|
||
<div>
|
||
<Label>社保缴费基数</Label>
|
||
<Input type="number" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={employee?.monthlySalary || '默认为月工资'} />
|
||
</div>
|
||
<div>
|
||
<Label>社保开始年月</Label>
|
||
<Input type="month" value={form.socialInsStartMonth || hireMonth} onChange={(e) => setForm({ ...form, socialInsStartMonth: e.target.value })} />
|
||
</div>
|
||
<div>
|
||
<Label>公积金缴费基数</Label>
|
||
<Input type="number" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={employee?.monthlySalary || '默认为月工资'} />
|
||
</div>
|
||
<div>
|
||
<Label>公积金开始年月</Label>
|
||
<Input type="month" value={form.housingFundStartMonth || hireMonth} onChange={(e) => setForm({ ...form, housingFundStartMonth: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="border-t pt-3">
|
||
<div className="grid grid-cols-4 gap-3">
|
||
<div className="col-span-1">
|
||
<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 })
|
||
}}>
|
||
{contractTypes.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{form.contractType !== 'UNSIGNED' && (
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-4 gap-3">
|
||
<div>
|
||
<Label>签订日期</Label>
|
||
<Input type="date" value={form.signDate} onChange={(e) => setForm({ ...form, signDate: e.target.value })} />
|
||
<div className="text-xs text-gray-400 mt-0.5">留空表示尚未签订</div>
|
||
</div>
|
||
<div><Label>合同开始日期</Label><div className="text-xs text-gray-600 py-1.5">{form.startDate || '随入职日期'}</div></div>
|
||
{form.contractType === 'FIXED' && (
|
||
<div>
|
||
<Label>签约时长(年)</Label>
|
||
<Input type="number" value={form.contractYears} onChange={(e) => handleContractYearsChange(parseInt(e.target.value) || 0)} min={1} />
|
||
</div>
|
||
)}
|
||
{form.contractType === 'FIXED' && (
|
||
<div>
|
||
<Label>合同结束日期</Label>
|
||
<Input type="date" value={form.endDate} onChange={(e) => handleEndDateChange(e.target.value)} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
{form.contractType === 'FIXED' && (
|
||
<div className="text-xs text-gray-400">修改签约时长自动计算结束日期,修改结束日期自动计算签约时长</div>
|
||
)}
|
||
<div className="grid grid-cols-4 gap-3">
|
||
<div>
|
||
<Label>试用期(月)</Label>
|
||
<Input type="number" value={form.probationMonths} onChange={(e) => setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} min={0} max={6} />
|
||
{contractMonths > 0 && (
|
||
<div className="text-xs text-gray-400 mt-0.5">法定上限:{probationMax}个月</div>
|
||
)}
|
||
{probationError && <div className="text-xs text-danger mt-0.5">{probationError}</div>}
|
||
</div>
|
||
<div>
|
||
<Label>试用期工资{form.probationMonths > 0 ? ' *' : ''}</Label>
|
||
<Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseFloat(e.target.value) || 0 })} disabled={form.probationMonths <= 0} />
|
||
{monthlySalaryNum > 0 && form.probationMonths > 0 && (
|
||
<div className="text-xs text-gray-400 mt-0.5">不低于转正工资80%(≥¥{(monthlySalaryNum * 0.8).toFixed(0)})</div>
|
||
)}
|
||
{probationSalaryError && <div className="text-xs text-danger mt-0.5">{probationSalaryError}</div>}
|
||
</div>
|
||
</div>
|
||
</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 || !canSubmit}>{loading ? '提交中...' : '确认入职'}</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||
onClose: () => void
|
||
onSubmit: (data: any) => void
|
||
loading: boolean
|
||
error: any
|
||
}) {
|
||
const todayStr = new Date().toISOString().slice(0, 10)
|
||
const { data: cities = ['北京'] } = useQuery<string[]>({
|
||
queryKey: ['social-config-cities'],
|
||
queryFn: async () => {
|
||
const res = await socialInsuranceApi.cities() as any
|
||
return res?.length ? res : ['北京']
|
||
},
|
||
})
|
||
const { data: contractTypes = [] } = useQuery<Array<{ value: string; label: string; hasEndDate: boolean }>>({
|
||
queryKey: ['contract-types'],
|
||
queryFn: async () => {
|
||
return await rosterApi.contractTypes()
|
||
},
|
||
staleTime: Infinity,
|
||
})
|
||
// 拉取组织架构部门列表,用于部门下拉选择
|
||
const { data: departments = [] } = useQuery({
|
||
queryKey: ['departments'],
|
||
queryFn: () => api.get('/departments').then(r => r.data),
|
||
})
|
||
// 构建部门树形下拉选项(带层级缩进)
|
||
const deptOptions: { id: string; label: string; level: number }[] = []
|
||
const buildDeptOptions = (items: any[], parentId: string | null, level: number) => {
|
||
items.filter(d => d.parentId === parentId).sort((a, b) => a.sortOrder - b.sortOrder).forEach(d => {
|
||
deptOptions.push({ id: d.id, label: d.name, level })
|
||
buildDeptOptions(items, d.id, level + 1)
|
||
})
|
||
}
|
||
buildDeptOptions(departments, null, 0)
|
||
const defaultEndDate = (() => {
|
||
const d = new Date()
|
||
d.setFullYear(d.getFullYear() + 3)
|
||
d.setDate(d.getDate() - 1)
|
||
return d.toISOString().slice(0, 10)
|
||
})()
|
||
const [form, setForm] = useState(() => {
|
||
try {
|
||
const saved = localStorage.getItem('add-employee-draft')
|
||
if (saved) return JSON.parse(saved)
|
||
} catch {}
|
||
return {
|
||
name: '', department: '', departmentId: '', position: '', hireDate: todayStr, monthlySalary: '',
|
||
idCardNumber: '', gender: '男' as '男' | '女', femaleWorkerType: '' as '' | 'CADRE' | 'WORKER', phone: '',
|
||
city: '北京', education: '',
|
||
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
|
||
signDate: '', startDate: todayStr, endDate: defaultEndDate,
|
||
contractYears: 3, probationMonths: 0, probationSalary: 0,
|
||
socialInsBase: '', socialInsStartMonth: '',
|
||
housingFundBase: '', housingFundStartMonth: '',
|
||
}
|
||
})
|
||
|
||
// 持久化草稿到 localStorage,防止录入数据丢失
|
||
useEffect(() => {
|
||
try {
|
||
const isDirty = !!(form.name || form.department || form.idCardNumber || form.monthlySalary || form.phone)
|
||
if (isDirty) {
|
||
localStorage.setItem('add-employee-draft', JSON.stringify(form))
|
||
} else {
|
||
localStorage.removeItem('add-employee-draft')
|
||
}
|
||
} catch {}
|
||
}, [form])
|
||
|
||
const hireMonth = form.hireDate ? form.hireDate.slice(0, 7) : ''
|
||
|
||
// 选定部门后自动带出社保公积金账户
|
||
const [autoAccounts, setAutoAccounts] = useState<{ socialAccount: any; housingAccount: any; socialStandard: any; housingStandard: any } | null>(null)
|
||
const [manualSocialAccountId, setManualSocialAccountId] = useState<string>('')
|
||
const [manualHousingAccountId, setManualHousingAccountId] = useState<string>('')
|
||
|
||
// 部门变化时查询适用账户
|
||
useEffect(() => {
|
||
if (form.departmentId) {
|
||
socialAccountApi.departmentAccount(form.departmentId).then((res: any) => {
|
||
setAutoAccounts(res)
|
||
setManualSocialAccountId(res.socialAccount?.id || '')
|
||
setManualHousingAccountId(res.housingAccount?.id || '')
|
||
}).catch(() => {})
|
||
} else {
|
||
setAutoAccounts(null)
|
||
}
|
||
}, [form.departmentId])
|
||
|
||
// 加载所有账户列表(供手动调整)
|
||
const { data: allSocialAccounts = [] } = useQuery<any[]>({
|
||
queryKey: ['social-accounts', 'SOCIAL'],
|
||
queryFn: () => socialAccountApi.list('SOCIAL'),
|
||
enabled: !!form.departmentId,
|
||
})
|
||
const { data: allHousingAccounts = [] } = useQuery<any[]>({
|
||
queryKey: ['social-accounts', 'HOUSING'],
|
||
queryFn: () => socialAccountApi.list('HOUSING'),
|
||
enabled: !!form.departmentId,
|
||
})
|
||
|
||
// 入职日期变更 → 同步合同开始日期 + 重算结束日期
|
||
const handleHireDateChange = (hireDate: string) => {
|
||
if (form.contractType === 'FIXED' && form.contractYears > 0 && hireDate) {
|
||
const start = new Date(hireDate)
|
||
const end = new Date(start)
|
||
end.setFullYear(end.getFullYear() + form.contractYears)
|
||
end.setDate(end.getDate() - 1)
|
||
setForm({ ...form, hireDate, startDate: hireDate, endDate: end.toISOString().slice(0, 10) })
|
||
} else {
|
||
setForm({ ...form, hireDate, startDate: hireDate })
|
||
}
|
||
}
|
||
|
||
// 根据证件号码自动计算性别(第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) {
|
||
const digit = parseInt(idCard[16])
|
||
if (!isNaN(digit)) gender = digit % 2 === 1 ? '男' : '女'
|
||
}
|
||
setForm({ ...form, idCardNumber: idCard, gender })
|
||
setIdCardDuplicate(null)
|
||
setAgeWarning(null)
|
||
if (idCard.length === 18) {
|
||
// 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 (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
|
||
if (form.endDate) {
|
||
const start = new Date(form.startDate)
|
||
const end = new Date(form.endDate)
|
||
return Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
|
||
}
|
||
return form.contractYears * 12
|
||
})()
|
||
|
||
// 试用期上限(劳动合同法第19条)
|
||
const probationMax = (() => {
|
||
if (contractMonths >= 36) return 6
|
||
if (contractMonths >= 12) return 2
|
||
if (contractMonths >= 3) return 1
|
||
return 0
|
||
})()
|
||
|
||
const probationError = (() => {
|
||
if (form.probationMonths <= 0) return ''
|
||
if (contractMonths > 0 && contractMonths < 3) return '合同不足3个月,不得约定试用期'
|
||
if (form.probationMonths > probationMax) return `合同${contractMonths}个月,试用期最多${probationMax}个月`
|
||
return ''
|
||
})()
|
||
|
||
const monthlySalaryNum = parseFloat(form.monthlySalary) || 0
|
||
const probationSalaryError = (() => {
|
||
if (form.probationMonths <= 0) return ''
|
||
if (form.probationSalary <= 0) return '有试用期时试用期工资必填'
|
||
if (monthlySalaryNum > 0 && form.probationSalary < monthlySalaryNum * 0.8) {
|
||
return `试用期工资不得低于转正工资的80%(最低¥${(monthlySalaryNum * 0.8).toFixed(0)})`
|
||
}
|
||
return ''
|
||
})()
|
||
|
||
// 合同结束日期自动计算
|
||
const handleContractYearsChange = (years: number) => {
|
||
if (!form.startDate || years <= 0) {
|
||
setForm({ ...form, contractYears: years, endDate: '' })
|
||
return
|
||
}
|
||
const start = new Date(form.startDate)
|
||
const end = new Date(start)
|
||
end.setFullYear(end.getFullYear() + years)
|
||
end.setDate(end.getDate() - 1)
|
||
setForm({ ...form, contractYears: years, endDate: end.toISOString().slice(0, 10) })
|
||
}
|
||
|
||
// 合同结束日期变更 → 自动计算签约年限
|
||
const handleEndDateChange = (endDate: string) => {
|
||
if (!form.startDate || !endDate) {
|
||
setForm({ ...form, endDate, contractYears: 0 })
|
||
return
|
||
}
|
||
const start = new Date(form.startDate)
|
||
const end = new Date(endDate)
|
||
const months = Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
|
||
setForm({ ...form, endDate, contractYears: Math.max(1, Math.round(months / 12)) })
|
||
}
|
||
|
||
const handleSubmit = () => {
|
||
// 童工阻断:未满16周岁禁止录入
|
||
if (ageWarning?.type === 'BLOCK') {
|
||
toast.error(ageWarning.message)
|
||
return
|
||
}
|
||
const data: any = {
|
||
name: form.name, department: form.department, departmentId: form.departmentId || undefined,
|
||
position: form.position || undefined,
|
||
hireDate: new Date(form.hireDate).toISOString(),
|
||
monthlySalary: form.monthlySalary, gender: form.gender,
|
||
femaleWorkerType: form.gender === '女' && form.femaleWorkerType ? form.femaleWorkerType : undefined,
|
||
idCardNumber: form.idCardNumber || undefined,
|
||
phone: form.phone || undefined,
|
||
education: form.education || undefined,
|
||
socialInsBase: form.socialInsBase ? parseFloat(form.socialInsBase) : undefined,
|
||
socialInsStartMonth: form.socialInsStartMonth || undefined,
|
||
housingFundBase: form.housingFundBase ? parseFloat(form.housingFundBase) : undefined,
|
||
housingFundStartMonth: form.housingFundStartMonth || undefined,
|
||
socialAccountId: manualSocialAccountId || undefined,
|
||
housingAccountId: manualHousingAccountId || undefined,
|
||
}
|
||
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)
|
||
}
|
||
|
||
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)
|
||
|
||
return (
|
||
<Modal open onClose={onClose} title="添加员工" size="xl" closeOnOverlayClick={false}>
|
||
<div className="space-y-4">
|
||
{error && (
|
||
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">
|
||
{error.response?.data?.error?.details?.length > 0
|
||
? error.response.data.error.details.map((d: any, i: number) => (
|
||
<div key={i}>• {d.path}: {d.message}</div>
|
||
))
|
||
: (error.response?.data?.error?.message || '操作失败')}
|
||
</div>
|
||
)}
|
||
{/* 基本信息 */}
|
||
<div className="grid grid-cols-4 gap-4">
|
||
<div><Label>姓名 *</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="员工姓名" /></div>
|
||
<div><Label>部门 *</Label><Select value={form.departmentId} onChange={(e) => {
|
||
const opt = deptOptions.find(d => d.id === e.target.value)
|
||
setForm({ ...form, departmentId: e.target.value, department: opt?.label || '' })
|
||
}}><option value="">请选择部门</option>{deptOptions.map(d => <option key={d.id} value={d.id}>{' '.repeat(d.level)}{d.label}</option>)}</Select></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>
|
||
{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>
|
||
</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>
|
||
{form.gender === '女' && (
|
||
<div><Label>女性岗位类型</Label><Select value={form.femaleWorkerType} onChange={(e) => setForm({ ...form, femaleWorkerType: e.target.value as '' | 'CADRE' | 'WORKER' })}><option value="">未选择</option><option value="CADRE">干部/管理岗</option><option value="WORKER">工人/操作岗</option></Select></div>
|
||
)}
|
||
</div>
|
||
<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) => {
|
||
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
|
||
setForm({ ...form, city })
|
||
const salary = Number(form.socialInsBase === '' ? form.monthlySalary : form.socialInsBase) || 0
|
||
const hfBase = Number(form.housingFundBase === '' ? form.monthlySalary : form.housingFundBase) || 0
|
||
if (salary > 0) {
|
||
try {
|
||
const res = await socialInsuranceApi.calculate(salary, city)
|
||
if (res?.capped || res?.floored) {
|
||
setForm((prev: any) => ({ ...prev, socialInsBase: String(res.actualBase) }))
|
||
}
|
||
} catch {}
|
||
}
|
||
if (hfBase > 0) {
|
||
try {
|
||
const res = await socialInsuranceApi.housingCalculate(hfBase, city)
|
||
if (res?.capped || res?.floored) {
|
||
setForm((prev: any) => ({ ...prev, housingFundBase: String(res.actualBase) }))
|
||
}
|
||
} catch {}
|
||
}
|
||
}}>{cities.map((c) => <option key={c} value={c}>{c}</option>)}</Select></div>
|
||
<div><Label>学历</Label><Select value={form.education} onChange={(e) => setForm({ ...form, education: e.target.value })}><option value="">未选择</option><option value="博士">博士</option><option value="硕士">硕士</option><option value="本科">本科</option><option value="大专">大专</option><option value="高中">高中</option><option value="其他">其他</option></Select></div>
|
||
</div>
|
||
{/* 社保公积金 */}
|
||
<div className="border-t border-gray-200 pt-4">
|
||
<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>
|
||
{isNoSocialContract ? (
|
||
<span className="text-xs text-amber-600">劳务协议/实习协议人员不缴纳社保公积金</span>
|
||
) : (
|
||
<span className="text-xs text-gray-500">选定部门后自动带出账户,可手动调整。缴费基数默认与月工资一致。</span>
|
||
)}
|
||
</div>
|
||
{/* 账户选择(选部门后自动带出,可手动调整) */}
|
||
{!isNoSocialContract && (
|
||
<div className="grid grid-cols-2 gap-4 mb-3">
|
||
<div>
|
||
<Label>社保账户</Label>
|
||
<Select value={manualSocialAccountId} onChange={(e) => setManualSocialAccountId(e.target.value)} disabled={!form.departmentId}>
|
||
<option value="">{form.departmentId ? '未选择' : '请先选择部门'}</option>
|
||
{allSocialAccounts.map((a: any) => (
|
||
<option key={a.id} value={a.id}>{a.name}({a.city})</option>
|
||
))}
|
||
</Select>
|
||
{autoAccounts?.socialStandard && manualSocialAccountId === autoAccounts.socialAccount?.id && (
|
||
<div className="text-xs text-gray-400 mt-1">
|
||
当前标准:基数 {autoAccounts.socialStandard.baseMin}~{autoAccounts.socialStandard.baseMax},养老 {autoAccounts.socialStandard.pensionOrg}%/{autoAccounts.socialStandard.pensionEmp}%
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<Label>公积金账户</Label>
|
||
<Select value={manualHousingAccountId} onChange={(e) => setManualHousingAccountId(e.target.value)} disabled={!form.departmentId}>
|
||
<option value="">{form.departmentId ? '未选择' : '请先选择部门'}</option>
|
||
{allHousingAccounts.map((a: any) => (
|
||
<option key={a.id} value={a.id}>{a.name}({a.city})</option>
|
||
))}
|
||
</Select>
|
||
{autoAccounts?.housingStandard && manualHousingAccountId === autoAccounts.housingAccount?.id && (
|
||
<div className="text-xs text-gray-400 mt-1">
|
||
当前标准:基数 {autoAccounts.housingStandard.baseMin}~{autoAccounts.housingStandard.baseMax},公积金 {autoAccounts.housingStandard.housingOrg}%/{autoAccounts.housingStandard.housingEmp}%
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<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 || '默认为月工资'} disabled={isNoSocialContract} />
|
||
</div>
|
||
<div>
|
||
<Label>社保开始年月</Label>
|
||
<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 || '默认为月工资'} disabled={isNoSocialContract} />
|
||
</div>
|
||
<div>
|
||
<Label>公积金开始年月</Label>
|
||
<Input type="month" value={form.housingFundStartMonth || hireMonth} onChange={(e) => setForm({ ...form, housingFundStartMonth: e.target.value })} disabled={isNoSocialContract} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* 合同信息 */}
|
||
<div className="border-t border-gray-200 pt-4">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<FileSignature className="w-4 h-4 text-gray-500" />
|
||
<span className="text-sm font-medium text-gray-700">合同信息</span>
|
||
</div>
|
||
<div className="grid grid-cols-4 gap-4">
|
||
<div className="col-span-1">
|
||
<Label>合同类型</Label>
|
||
<Select value={form.contractType} onChange={(e) => {
|
||
const ct = contractTypes.find(t => t.value === e.target.value)
|
||
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
|
||
.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>
|
||
{form.contractType !== 'UNSIGNED' && (
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-4 gap-4">
|
||
<div>
|
||
<Label>签订日期</Label>
|
||
<Input type="date" value={form.signDate} onChange={(e) => setForm({ ...form, signDate: e.target.value })} />
|
||
<div className="text-xs text-gray-500 mt-1">留空表示尚未签订</div>
|
||
</div>
|
||
<div><Label>合同开始日期</Label><div className="text-sm text-gray-600 py-2">{form.startDate || '随入职日期'}</div></div>
|
||
{form.contractType === 'FIXED' && (
|
||
<div>
|
||
<Label>签约时长(年)</Label>
|
||
<Input type="number" value={form.contractYears} onChange={(e) => handleContractYearsChange(parseInt(e.target.value) || 0)} min={1} />
|
||
</div>
|
||
)}
|
||
{form.contractType === 'FIXED' && (
|
||
<div>
|
||
<Label>合同结束日期</Label>
|
||
<Input type="date" value={form.endDate} onChange={(e) => handleEndDateChange(e.target.value)} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
{form.contractType === 'FIXED' && (
|
||
<div className="text-xs text-gray-500">修改签约时长自动计算结束日期,修改结束日期自动计算签约时长</div>
|
||
)}
|
||
<div className="grid grid-cols-4 gap-4">
|
||
<div>
|
||
<Label>试用期(月)</Label>
|
||
<Input type="number" value={form.probationMonths} onChange={(e) => setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} min={0} max={6} />
|
||
{contractMonths > 0 && (
|
||
<div className="text-xs text-gray-500 mt-1">法定上限:{probationMax}个月</div>
|
||
)}
|
||
{probationError && <div className="text-xs text-danger mt-1">{probationError}</div>}
|
||
</div>
|
||
<div>
|
||
<Label>试用期工资{form.probationMonths > 0 ? ' *' : ''}</Label>
|
||
<Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseFloat(e.target.value) || 0 })} disabled={form.probationMonths <= 0} />
|
||
{monthlySalaryNum > 0 && form.probationMonths > 0 && (
|
||
<div className="text-xs text-gray-500 mt-1">不低于转正工资80%(≥¥{(monthlySalaryNum * 0.8).toFixed(0)})</div>
|
||
)}
|
||
{probationSalaryError && <div className="text-xs text-danger mt-1">{probationSalaryError}</div>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="flex justify-end gap-3 pt-3 border-t border-gray-200">
|
||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||
<Button onClick={handleSubmit} disabled={loading || !canSubmit}>{loading ? '保存中...' : '保存'}</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|