Files
TurboHR/frontend/src/pages/roster/BasicInfo.tsx
T
selfrelease e1b5ae9aab 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>
2026-08-15 12:37:27 +08:00

553 lines
31 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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">/7portal端填报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>
)
}