e1b5ae9aab
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>
553 lines
31 KiB
TypeScript
553 lines
31 KiB
TypeScript
import { QRCodeSVG } from "qrcode.react"
|
||
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"
|
||
import { Input, Label, Select } from "../../components/ui/Input"
|
||
import { AlertTriangle, Paperclip, Trash2, Eye, Download, Copy } from "lucide-react"
|
||
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')
|
||
|
||
const addAttachmentMutation = useMutation({
|
||
mutationFn: (data: any) => attachmentApi.add(data),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
|
||
})
|
||
|
||
const deleteAttachmentMutation = useMutation({
|
||
mutationFn: (id: string) => attachmentApi.remove(id),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
|
||
})
|
||
|
||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0]
|
||
if (!file) return
|
||
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.tiff', '.tif']
|
||
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
|
||
if (!allowedExts.includes(ext)) {
|
||
toast.error('不支持的文件格式,支持 PDF、图片、Word、Excel 等常见格式')
|
||
return
|
||
}
|
||
if (file.size > 10 * 1024 * 1024) {
|
||
toast.error('文件过大,请上传小于 10MB 的文件')
|
||
return
|
||
}
|
||
const reader = new FileReader()
|
||
reader.onload = (event) => {
|
||
const fileUrl = event.target?.result as string
|
||
addAttachmentMutation.mutate({ employeeId, fileName: file.name, fileType, fileUrl, fileSize: file.size })
|
||
}
|
||
reader.readAsDataURL(file)
|
||
}
|
||
|
||
const fileTypeLabels: Record<string, string> = { ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书', CERTIFICATE: '职业资格证书', CONTRACT: '合同扫描件', PHOTO: '员工照片', OTHER: '其他' }
|
||
const fileTypeColors: Record<string, string> = { ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe', EDUCATION: 'bg-amber-50 text-amber-600', CERTIFICATE: 'bg-purple-50 text-purple-600', CONTRACT: 'bg-cyan-50 text-cyan-600', PHOTO: 'bg-pink-50 text-pink-600', OTHER: 'bg-gray-100 text-gray-500' }
|
||
const formatSize = (bytes: number) => {
|
||
if (!bytes) return '-'
|
||
if (bytes < 1024) return `${bytes}B`
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`
|
||
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
|
||
}
|
||
|
||
const [form, setForm] = useState({
|
||
department: profile.department || '',
|
||
position: profile.position || '',
|
||
gender: profile.gender || '男',
|
||
femaleWorkerType: profile.femaleWorkerType || '',
|
||
phone: profile.phone || '',
|
||
hireDate: profile.hireDate?.toString().slice(0, 10) || '',
|
||
monthlySalary: profile.monthlySalary || '',
|
||
emergencyContact: profile.emergencyContact || '',
|
||
emergencyPhone: profile.emergencyPhone || '',
|
||
address: profile.address || '',
|
||
bankName: profile.bankName || '',
|
||
bankAccount: profile.bankAccount || '',
|
||
isPregnant: profile.isPregnant || false,
|
||
isInMedicalPeriod: profile.isInMedicalPeriod || false,
|
||
isWorkInjured: profile.isWorkInjured || false,
|
||
socialInsBase: profile.socialInsBase ?? '',
|
||
housingFundBase: profile.housingFundBase ?? '',
|
||
specialDeduction: profile.specialDeduction ?? 0,
|
||
city: profile.city || '',
|
||
education: profile.education || '',
|
||
cityChangeReason: '',
|
||
})
|
||
|
||
const updateMutation = useMutation({
|
||
mutationFn: (data: any) => employeeApi.update(profile.id, data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||
setEditing(false)
|
||
},
|
||
onError: (err: any) => {
|
||
const details = err?.response?.data?.error?.details
|
||
if (details?.length > 0) {
|
||
toast.error(details.map((d: any) => `${d.path}: ${d.message}`).join(';'))
|
||
} else {
|
||
toast.error(err?.response?.data?.error?.message || '保存失败')
|
||
}
|
||
},
|
||
})
|
||
|
||
// 查询社保费用明细(按险种分别计算)
|
||
const { data: socialDetail } = useQuery<any>({
|
||
queryKey: ['social-calc', profile.id, profile.socialInsBase, profile.city],
|
||
queryFn: async () => {
|
||
if (!profile.socialInsBase || !profile.city) return null
|
||
try {
|
||
return await socialInsuranceApi.calculate(Number(profile.socialInsBase), profile.city)
|
||
} catch { return null }
|
||
},
|
||
enabled: !editing && !!profile.socialInsBase && !!profile.city,
|
||
})
|
||
|
||
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,
|
||
femaleWorkerType: form.femaleWorkerType || undefined,
|
||
phone: form.phone || undefined,
|
||
hireDate: new Date(form.hireDate).toISOString(),
|
||
monthlySalary: String(form.monthlySalary),
|
||
emergencyContact: form.emergencyContact || undefined,
|
||
emergencyPhone: form.emergencyPhone || undefined,
|
||
address: form.address || undefined,
|
||
bankName: form.bankName || undefined,
|
||
bankAccount: form.bankAccount || undefined,
|
||
isPregnant: form.isPregnant,
|
||
isInMedicalPeriod: form.isInMedicalPeriod,
|
||
isWorkInjured: form.isWorkInjured,
|
||
socialInsBase: form.socialInsBase === '' ? null : Number(form.socialInsBase),
|
||
housingFundBase: form.housingFundBase === '' ? null : Number(form.housingFundBase),
|
||
specialDeduction: Number(form.specialDeduction) || 0,
|
||
city: form.city || undefined,
|
||
education: form.education || undefined,
|
||
position: form.position || undefined,
|
||
cityChangeReason: form.city !== profile.city ? form.cityChangeReason || undefined : undefined,
|
||
}
|
||
if (statusUpdate) data.status = statusUpdate
|
||
updateMutation.mutate(data)
|
||
}
|
||
|
||
const personalFields = [
|
||
{ label: '姓名', value: profile.name },
|
||
{ label: '部门', value: profile.department },
|
||
{ label: '性别', value: profile.gender || '未填写' },
|
||
...(profile.gender === '女'
|
||
? [{ label: '女性岗位', value: profile.femaleWorkerType === 'CADRE' ? '干部/管理岗' : profile.femaleWorkerType === 'WORKER' ? '工人/操作岗' : '未填写' }]
|
||
: []),
|
||
{ label: '证件号码', value: profile.idCardNumber || '未填写' },
|
||
{ label: '手机号', value: profile.phone || '未填写' },
|
||
{ label: '学历', value: profile.education || '未填写' },
|
||
{ label: '职务/岗位', value: profile.position || '未填写' },
|
||
{ label: '入职日期', value: profile.hireDate?.toString().slice(0, 10) },
|
||
{ label: '状态', value: profile.status === 'ACTIVE' ? '在职' : '离职' },
|
||
...(profile.retirementDaysLeft != null
|
||
? (() => {
|
||
if (profile.retirementDaysLeft <= 0) return [{ label: '距退休', value: '已到退休年龄' }]
|
||
if (profile.birthDate) {
|
||
const bd = new Date(profile.birthDate)
|
||
const gender = profile.gender || '男'
|
||
const fwt = profile.femaleWorkerType || null
|
||
const baseAge = gender === '男' ? 60 : (fwt === 'WORKER' ? 50 : 55)
|
||
const delayInterval = gender === '男' ? 4 : (fwt === 'WORKER' ? 2 : 4)
|
||
const maxDelay = gender === '男' ? 36 : (fwt === 'WORKER' ? 60 : 36)
|
||
const baseRetireDate = new Date(bd)
|
||
baseRetireDate.setFullYear(baseRetireDate.getFullYear() + baseAge)
|
||
const reformStart = new Date(2025, 0, 1)
|
||
const monthsSince = Math.max(0, (baseRetireDate.getFullYear() - reformStart.getFullYear()) * 12 + (baseRetireDate.getMonth() - reformStart.getMonth()))
|
||
const delayMonths = Math.min(maxDelay, Math.floor(monthsSince / delayInterval))
|
||
const retireDate = new Date(baseRetireDate)
|
||
retireDate.setMonth(retireDate.getMonth() + delayMonths)
|
||
return [{ label: '退休日期', value: `${retireDate.getFullYear()}年${retireDate.getMonth() + 1}月${retireDate.getDate()}日` }]
|
||
}
|
||
return [{ label: '距退休', value: `${profile.retirementDaysLeft}天` }]
|
||
})()
|
||
: []),
|
||
...(profile.status !== 'ACTIVE' && profile.terminations && profile.terminations.length > 0
|
||
? [{ label: '离职日期', value: profile.terminations
|
||
.map((t: any) => t.terminationDate?.toString().slice(0, 10))
|
||
.filter(Boolean)
|
||
.sort()
|
||
.reverse()[0] || '未记录' }]
|
||
: []),
|
||
]
|
||
const salaryFields = [
|
||
{ label: '月工资', value: `¥${fmt(profile.monthlySalary)}` },
|
||
{ label: '紧急联系人', value: profile.emergencyContact || '未填写' },
|
||
{ label: '紧急联系电话', value: profile.emergencyPhone || '未填写' },
|
||
{ label: '住址', value: profile.address || '未填写' },
|
||
{ label: '开户行', value: profile.bankName || '未填写' },
|
||
{ label: '银行账号', value: profile.bankAccount || '未填写' },
|
||
]
|
||
const special = [
|
||
{ label: '孕期', value: profile.isPregnant },
|
||
{ label: '医疗期', value: profile.isInMedicalPeriod },
|
||
{ label: '工伤', value: profile.isWorkInjured },
|
||
]
|
||
return (
|
||
<Card>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="text-xs font-medium">基本信息</h2>
|
||
{!editing ? (
|
||
<Button size="sm" variant="secondary" onClick={() => setEditing(true)}>编辑</Button>
|
||
) : (
|
||
<div className="flex gap-2">
|
||
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>
|
||
{updateMutation.isPending ? '保存中...' : '保存'}
|
||
</Button>
|
||
<Button size="sm" variant="secondary" onClick={() => setEditing(false)}>取消</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{!editing ? (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<h3 className="text-xs font-medium text-gray-600 mb-2">个人信息</h3>
|
||
<div className="grid md:grid-cols-3 gap-x-6 gap-y-3">
|
||
{personalFields.map((f) => (
|
||
<div key={f.label} className="flex justify-between border-b pb-1.5 text-xs">
|
||
<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 && (
|
||
<button
|
||
type="button"
|
||
className="text-gray-400 hover:text-primary transition-colors shrink-0"
|
||
title="复制证件号码"
|
||
onClick={() => {
|
||
copyToClipboard(profile.idCardNumber, '已复制证件号码')
|
||
}}
|
||
>
|
||
<Copy className="w-3 h-3" />
|
||
</button>
|
||
)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="pt-3 border-t">
|
||
<h3 className="text-xs font-medium text-gray-600 mb-2">薪酬与银行</h3>
|
||
<div className="grid md:grid-cols-3 gap-x-6 gap-y-3">
|
||
{salaryFields.map((f) => (
|
||
<div key={f.label} className="flex justify-between border-b pb-1.5 text-xs">
|
||
<span className="text-gray-500 shrink-0">{f.label}</span>
|
||
<span className="font-medium text-right truncate ml-2">{f.value}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{profile.socialInsBase != null && (
|
||
<div className="pt-3 border-t">
|
||
<h3 className="text-xs font-medium text-gray-600 mb-2">社保与公积金明细</h3>
|
||
<div className="grid md:grid-cols-2 gap-x-6 gap-y-1 text-xs">
|
||
<div className="flex justify-between border-b pb-1">
|
||
<span className="text-gray-500">社保缴纳基数</span>
|
||
<span className="font-medium">¥{fmt(profile.socialInsBase)}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b pb-1">
|
||
<span className="text-gray-500">公积金缴纳基数</span>
|
||
<span className="font-medium">¥{fmt(profile.housingFundBase || 0)}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b pb-1">
|
||
<span className="text-gray-500">个人养老(8%)</span>
|
||
<span>¥{fmt((profile.socialInsBase || 0) * 0.08)}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b pb-1">
|
||
<span className="text-gray-500">单位养老(16%)</span>
|
||
<span>¥{fmt((profile.socialInsBase || 0) * 0.16)}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b pb-1">
|
||
<span className="text-gray-500">个人医疗(2%)</span>
|
||
<span>¥{fmt((profile.socialInsBase || 0) * 0.02)}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b pb-1">
|
||
<span className="text-gray-500">单位医疗(8%)</span>
|
||
<span>¥{fmt((profile.socialInsBase || 0) * 0.08)}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b pb-1">
|
||
<span className="text-gray-500">个人失业(0.5%)</span>
|
||
<span>¥{fmt((profile.socialInsBase || 0) * 0.005)}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b pb-1">
|
||
<span className="text-gray-500">单位失业(0.5%)</span>
|
||
<span>¥{fmt((profile.socialInsBase || 0) * 0.005)}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b pb-1">
|
||
<span className="text-gray-500">单位工伤(0.2%)</span>
|
||
<span>¥{fmt((profile.socialInsBase || 0) * 0.002)}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b pb-1">
|
||
<span className="text-gray-500">单位生育(0.8%)</span>
|
||
<span>¥{fmt((profile.socialInsBase || 0) * 0.008)}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b pb-1">
|
||
<span className="text-gray-500">个人公积金(7%)</span>
|
||
<span>¥{fmt((profile.housingFundBase || 0) * 0.07)}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b pb-1">
|
||
<span className="text-gray-500">单位公积金(7%)</span>
|
||
<span>¥{fmt((profile.housingFundBase || 0) * 0.07)}</span>
|
||
</div>
|
||
<div className="flex justify-between font-medium pt-1">
|
||
<span>个人合计</span>
|
||
<span className="text-primary">¥{fmt((profile.socialInsBase || 0) * 0.105 + (profile.housingFundBase || 0) * 0.07)}</span>
|
||
</div>
|
||
<div className="flex justify-between font-medium pt-1">
|
||
<span>单位合计</span>
|
||
<span className="text-primary">¥{fmt((profile.socialInsBase || 0) * 0.255 + (profile.housingFundBase || 0) * 0.07)}</span>
|
||
</div>
|
||
</div>
|
||
<div className="text-xs text-gray-400 mt-1">注:比例为通用参考值,实际比例以当地政策为准</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<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={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 === '女' && (
|
||
<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><Label>手机号</Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></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><Label>职务/岗位</Label><Input value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" /></div>
|
||
<div><Label>入职日期</Label><Input type="date" value={form.hireDate} onChange={(e) => setForm({ ...form, hireDate: e.target.value })} /></div>
|
||
<div><Label>月工资</Label><Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: Number(e.target.value) })} /></div>
|
||
<div><Label>紧急联系人</Label><Input value={form.emergencyContact} onChange={(e) => setForm({ ...form, emergencyContact: e.target.value })} placeholder="选填" /></div>
|
||
<div><Label>紧急联系电话</Label><Input value={form.emergencyPhone} onChange={(e) => setForm({ ...form, emergencyPhone: e.target.value })} placeholder="选填" /></div>
|
||
<div className="md:col-span-2"><Label>住址</Label><Input value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} placeholder="选填" /></div>
|
||
<div><Label>开户行</Label><Input value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} placeholder="选填" /></div>
|
||
<div><Label>银行账号</Label><Input value={form.bankAccount} onChange={(e) => setForm({ ...form, bankAccount: e.target.value })} placeholder="选填" /></div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 薪税信息 */}
|
||
<div className="mt-4 pt-4 border-t">
|
||
<h3 className="text-xs font-medium text-gray-600 mb-3">薪税信息</h3>
|
||
{!editing ? (
|
||
<div className="grid md:grid-cols-4 gap-4">
|
||
<div className="flex justify-between border-b pb-2 text-xs">
|
||
<span className="text-gray-500">参保城市</span>
|
||
<span className="font-medium">{profile.city || '未设置'}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b pb-2 text-xs">
|
||
<span className="text-gray-500">社保缴费基数</span>
|
||
<span className="font-medium">{profile.socialInsBase ? `¥${fmt(profile.socialInsBase)}` : '未设置'}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b pb-2 text-xs">
|
||
<span className="text-gray-500">公积金缴费基数</span>
|
||
<span className="font-medium">{profile.housingFundBase ? `¥${fmt(profile.housingFundBase)}` : '未设置'}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b pb-2 text-xs">
|
||
<span className="text-gray-500">专项附加扣除</span>
|
||
<span className="font-medium">{profile.specialDeduction ? `¥${fmt(profile.specialDeduction)}/月` : '¥0.00/月'}</span>
|
||
</div>
|
||
{socialDetail?.items?.length > 0 && (
|
||
<div className="md:col-span-4 mt-2">
|
||
<div className="text-xs font-medium text-gray-600 mb-2">社保费用明细(按险种分别计算)</div>
|
||
<div className="grid md:grid-cols-5 gap-2">
|
||
{socialDetail.items.map((item: any) => (
|
||
<div key={item.name} className="px-2 py-1.5 rounded bg-gray-50 text-xs">
|
||
<div className="font-medium text-gray-700">{item.name}</div>
|
||
<div className="text-gray-500 mt-0.5">企业 ¥{fmt(item.orgAmount)}({item.orgRate}%)</div>
|
||
<div className="text-gray-500">个人 ¥{fmt(item.empAmount)}({item.empRate}%)</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{socialDetail.capped && <div className="text-xs text-amber-600 mt-1">提示:社保基数已封顶(上限 ¥{fmt(socialDetail.actualBase)})</div>}
|
||
{socialDetail.floored && <div className="text-xs text-amber-600 mt-1">提示:社保基数已保底(下限 ¥{fmt(socialDetail.actualBase)})</div>}
|
||
{socialDetail.medicalBase && socialDetail.medicalBase !== socialDetail.actualBase && (
|
||
<div className="text-xs text-blue-600 mt-1">医保基数:¥{fmt(socialDetail.medicalBase)}(与养老基数不同)</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="grid md:grid-cols-4 gap-4">
|
||
<div>
|
||
<Label>参保城市</Label>
|
||
<Input placeholder="如 北京" value={form.city || ''} onChange={(e) => setForm({ ...form, city: e.target.value })} />
|
||
</div>
|
||
<div>
|
||
<Label>社保缴费基数</Label>
|
||
<Input type="number" placeholder="按人核定" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} />
|
||
</div>
|
||
<div>
|
||
<Label>公积金缴费基数</Label>
|
||
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} />
|
||
</div>
|
||
<div>
|
||
<Label>专项附加扣除(元/月)</Label>
|
||
<Input type="number" placeholder="子女教育、赡养老人等" value={form.specialDeduction} onChange={(e) => setForm({ ...form, specialDeduction: Number(e.target.value) || 0 })} />
|
||
</div>
|
||
{form.city !== (profile.city || '') && (
|
||
<div className="md:col-span-4">
|
||
<Label>参保城市变更原因(必填)</Label>
|
||
<Input placeholder="如:员工从北京调往上海工作" value={form.cityChangeReason} onChange={(e) => setForm({ ...form, cityChangeReason: e.target.value })} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
<p className="text-xs text-gray-400 mt-2">社保/公积金基数按上年度月均工资核定,每年7月调整。专项附加扣除由员工在portal端填报,无则为0。</p>
|
||
{!editing && (!profile.socialInsBase || !profile.housingFundBase) && (
|
||
<div className="mt-2 flex items-center gap-2 px-3 py-2 rounded-md bg-amber-50 text-warning text-xs">
|
||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||
<span>该员工{!profile.socialInsBase ? '社保' : '公积金'}基数未设置,发薪时社保/公积金将按0计算。请点击右上角「编辑」填写缴费基数。</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 特殊状态 */}
|
||
<div className="mt-4 pt-4 border-t">
|
||
<h3 className="text-xs font-medium text-gray-600 mb-3">特殊状态</h3>
|
||
{!editing ? (
|
||
<div className="flex gap-4">
|
||
{special.map((s) => (
|
||
<span key={s.label} className={`px-3 py-1 rounded text-xs ${s.value ? 'bg-red-50 text-danger' : 'bg-gray-50 text-gray-400'}`}>
|
||
{s.label}:{s.value ? '是' : '否'}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="flex gap-4">
|
||
<label className="flex items-center gap-1.5 text-xs">
|
||
<input type="checkbox" checked={form.isPregnant} onChange={(e) => setForm({ ...form, isPregnant: e.target.checked })} />
|
||
孕期/哺乳期
|
||
</label>
|
||
<label className="flex items-center gap-1.5 text-xs">
|
||
<input type="checkbox" checked={form.isInMedicalPeriod} onChange={(e) => setForm({ ...form, isInMedicalPeriod: e.target.checked })} />
|
||
医疗期
|
||
</label>
|
||
<label className="flex items-center gap-1.5 text-xs">
|
||
<input type="checkbox" checked={form.isWorkInjured} onChange={(e) => setForm({ ...form, isWorkInjured: e.target.checked })} />
|
||
工伤
|
||
</label>
|
||
</div>
|
||
)}
|
||
{(profile.isPregnant || profile.isInMedicalPeriod || profile.isWorkInjured) && !editing && (
|
||
<p className="text-xs text-amber-600 mt-2">⚠️ 该员工处于特殊保护期,解聘操作将触发法律风险预警</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 员工端二维码 */}
|
||
{!editing && profile.phone && (
|
||
<div className="mt-4 pt-4 border-t">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h3 className="text-xs font-medium text-gray-600">员工端入口</h3>
|
||
<Button size="sm" variant="secondary" onClick={() => {
|
||
const url = `${window.location.origin}/portal/login`
|
||
navigator.clipboard?.writeText(url)
|
||
}}>
|
||
复制链接
|
||
</Button>
|
||
</div>
|
||
<div className="flex items-center gap-4">
|
||
<div className="bg-white p-3 rounded-lg border">
|
||
<QRCodeSVG
|
||
value={`${window.location.origin}/portal/login`}
|
||
size={120}
|
||
level="M"
|
||
/>
|
||
</div>
|
||
<div className="text-xs text-gray-500 space-y-1">
|
||
<p>员工扫码进入员工端,使用手机号登录</p>
|
||
<p>可查看工资条、合同信息、确认签署</p>
|
||
<p className="text-gray-400">链接:{window.location.origin}/portal/login</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 附件管理 */}
|
||
<div className="mt-4 pt-4 border-t">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h3 className="text-xs font-medium text-gray-600">附件管理({attachments?.length || 0}个)</h3>
|
||
{!editing && (
|
||
<div className="flex gap-2 items-center">
|
||
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs !w-28">
|
||
<option value="ID_CARD">身份证</option><option value="BANK_CARD">银行卡</option><option value="EDUCATION">学历证书</option><option value="CERTIFICATE">职业资格证书</option><option value="CONTRACT">合同扫描件</option><option value="PHOTO">员工照片</option><option value="OTHER">其他</option>
|
||
</Select>
|
||
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileUpload} />
|
||
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={addAttachmentMutation.isPending} className="shrink-0 whitespace-nowrap">
|
||
{addAttachmentMutation.isPending ? '上传中...' : '上传附件'}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{!editing && attachments?.length ? (
|
||
<div className="space-y-1.5">
|
||
{attachments.map((att) => (
|
||
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2 text-xs hover:bg-gray-100">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<Paperclip className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||
<div className="min-w-0">
|
||
<div className="truncate font-medium text-gray-700">{att.fileName}</div>
|
||
<div className="flex items-center gap-2 mt-0.5">
|
||
<span className={`px-1.5 py-0.5 rounded text-xs ${fileTypeColors[att.fileType] || 'bg-gray-100 text-gray-500'}`}>{fileTypeLabels[att.fileType] || att.fileType}</span>
|
||
<span className="text-gray-400">{formatSize(att.fileSize)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
||
<button onClick={() => window.open(att.fileUrl, '_blank')} className="text-gray-400 hover:text-blue-600" title="查看">
|
||
<Eye className="w-3.5 h-3.5" />
|
||
</button>
|
||
<a href={att.fileUrl} download={att.fileName} className="text-gray-400 hover:text-blue-600" title="下载">
|
||
<Download className="w-3.5 h-3.5" />
|
||
</a>
|
||
<button onClick={() => deleteAttachmentMutation.mutate(att.id)} className="text-gray-300 hover:text-danger" title="删除">
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : !editing ? <div className="text-gray-400 text-xs text-center py-3">暂无附件</div> : null}
|
||
</div>
|
||
</Card>
|
||
)
|
||
}
|