Files
TurboHR/frontend/src/pages/roster/BasicInfo.tsx
T
freedakgmail 007109e425 feat: 附件管理拆为独立tab + 员工端附件上传
管理端:
- 花名册详情新增「附件资料」独立tab(人事信息分组下)
- 从 BasicInfo 移除附件管理代码到独立 AttachmentsTab 组件
- shared.ts 添加 attachments tab 类型和计数映射

员工端:
- 后端新增 GET/POST/DELETE /portal/attachments 接口
- 前端 api-services 添加 portalDelete + 附件 API 方法
- MyProfile 新增附件资料卡片,支持上传/查看/删除附件
- 附件类型:身份证/银行卡/学历证书/职业资格证书/合同/照片/其他
2026-08-17 19:27:45 +08:00

569 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 api from '../../lib/api'
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, KeyRound } 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] }),
})
/** 重置员工密码(重置为手机号后6位) */
const resetPasswordMutation = useMutation({
mutationFn: () => employeeApi.resetPassword(employeeId),
onSuccess: (data: any) => toast.success(data?.message || '密码已重置'),
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '重置失败'),
})
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 { 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 [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 || '',
baseSalary: profile.baseSalary != null ? profile.baseSalary : '',
performanceSalary: profile.performanceSalary != null ? profile.performanceSalary : '0',
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),
baseSalary: String(form.baseSalary),
performanceSalary: String(form.performanceSalary || '0'),
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: profile.baseSalary != null || profile.performanceSalary != null
? `¥${fmt(profile.monthlySalary)}(基本 ¥${fmt(profile.baseSalary || 0)} + 绩效 ¥${fmt(profile.performanceSalary || 0)}`
: `¥${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><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><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.baseSalary} onChange={(e) => {
const base = parseFloat(e.target.value) || 0
const perf = parseFloat(form.performanceSalary) || 0
setForm({ ...form, baseSalary: e.target.value, monthlySalary: base + perf })
}} placeholder="元" />
</div>
<div>
<Label></Label>
<Input type="number" value={form.performanceSalary} onChange={(e) => {
const perf = parseFloat(e.target.value) || 0
const base = parseFloat(form.baseSalary) || 0
setForm({ ...form, performanceSalary: e.target.value, monthlySalary: base + perf })
}} placeholder="可为0" />
</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>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => {
const url = `${window.location.origin}/portal/login`
navigator.clipboard?.writeText(url)
}}>
</Button>
<Button
size="sm"
variant="secondary"
onClick={async () => {
const ok = await confirm({
title: '重置员工密码',
message: `将重置「${profile.name}」的员工端密码为手机号后6位(${profile.phone?.slice(-6)}),确认操作?`,
})
if (ok) resetPasswordMutation.mutate()
}}
disabled={resetPasswordMutation.isPending}
>
<KeyRound className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</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>6{profile.phone?.slice(-6)}</p>
<p></p>
<p className="text-gray-400">{window.location.origin}/portal/login</p>
</div>
</div>
</div>
)}
</Card>
)
}