feat: 20260809 系统优化 - 全部28项问题修复(P0×6+P1×16+P2×6)
P0: 福利批量参保/离职证明下载防乱码/考勤模板合并Sheet/补卡修改/附件在线查看删除 P1: 分页pageSize修复/离职导出筛选/撤回删除草稿/加班费自动计算/考勤加班汇总/证据链异常详情/制度催办/模板导入Word/社保封顶保底/校验字段提示/职务字段/社保费用明细/弹窗防误关/身份证查重/证明员工下拉/培训批量 P2: 离职流程去重/社保基数覆盖输入/薪税入口改名/添加员工引导/绩效模板清理
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { QRCodeSVG } from "qrcode.react"
|
||||
import { useState, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { attachmentApi, employeeApi } from '../../lib/api-services'
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { attachmentApi, employeeApi, socialInsuranceApi } from '../../lib/api-services'
|
||||
import { copyToClipboard } from '../../lib/clipboard'
|
||||
import Card from "../../components/ui/Card"
|
||||
import Button from "../../components/ui/Button"
|
||||
@@ -58,6 +58,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
||||
|
||||
const [form, setForm] = useState({
|
||||
department: profile.department || '',
|
||||
position: profile.position || '',
|
||||
gender: profile.gender || '男',
|
||||
femaleWorkerType: profile.femaleWorkerType || '',
|
||||
phone: profile.phone || '',
|
||||
@@ -86,6 +87,26 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
||||
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 = () => {
|
||||
@@ -113,6 +134,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
||||
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,
|
||||
}
|
||||
updateMutation.mutate(data)
|
||||
@@ -128,6 +150,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
||||
{ 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
|
||||
@@ -302,6 +325,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
||||
)}
|
||||
<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>
|
||||
@@ -333,6 +357,25 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
||||
<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">
|
||||
@@ -342,11 +385,11 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
||||
</div>
|
||||
<div>
|
||||
<Label>社保缴费基数</Label>
|
||||
<Input type="number" placeholder="按人核定" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} />
|
||||
<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 })} />
|
||||
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>专项附加扣除(元/月)</Label>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef } from "react"
|
||||
import mammoth from "mammoth"
|
||||
import api from '../../lib/api'
|
||||
import { toast } from "sonner"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
@@ -18,18 +19,36 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
|
||||
const supplementFileRefs = useRef<Record<string, HTMLInputElement | null>>({})
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [previewName, setPreviewName] = useState<string>('附件')
|
||||
const [wordHtml, setWordHtml] = useState<string | null>(null)
|
||||
|
||||
const uploadAttachmentMutation = useMutation({
|
||||
mutationFn: async ({ contractId, attachmentUrl }: { contractId: string; attachmentUrl: string }) => {
|
||||
await api.patch(`/employees/contracts/${contractId}/attachment`, { attachmentUrl })
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
toast.success('附件已上传')
|
||||
queryClient.invalidateQueries({ queryKey: ['employee-detail'] })
|
||||
toast.success('附件已更新')
|
||||
},
|
||||
onError: () => toast.error('上传失败'),
|
||||
})
|
||||
|
||||
const deleteAttachmentMutation = useMutation({
|
||||
mutationFn: async ({ contractId, attachmentUrl }: { contractId: string; attachmentUrl: string }) => {
|
||||
await api.patch(`/employees/contracts/${contractId}/attachment`, { attachmentUrl })
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['employee-detail'] })
|
||||
toast.success('附件已删除')
|
||||
},
|
||||
onError: () => toast.error('删除失败'),
|
||||
})
|
||||
|
||||
const handleDeleteAttachment = async (contractId: string, atts: { name: string; url: string }[], idx: number) => {
|
||||
if (!await confirm({ title: '删除附件', message: '确定删除此附件?删除后不可恢复。' })) return
|
||||
const newAtts = atts.filter((_, i) => i !== idx)
|
||||
deleteAttachmentMutation.mutate({ contractId, attachmentUrl: newAtts.length > 0 ? JSON.stringify(newAtts) : '' })
|
||||
}
|
||||
|
||||
const handleSupplementUpload = (e: React.ChangeEvent<HTMLInputElement>, contractId: string, existingAtts: { name: string; url: string }[]) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
@@ -332,31 +351,42 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
|
||||
<button onClick={() => { setPreviewName(att.name); setPreviewUrl(att.url) }} className="text-primary hover:underline flex items-center gap-1 truncate">
|
||||
<Paperclip className="w-3 h-3 shrink-0" />{att.name}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-primary ml-2 shrink-0"
|
||||
title="下载附件"
|
||||
onClick={() => {
|
||||
const dataToBlobUrl = (dataUrl: string) => {
|
||||
try {
|
||||
const arr = dataUrl.split(',')
|
||||
const mime = arr[0].match(/:(.*?);/)?.[1] || 'application/octet-stream'
|
||||
const bstr = atob(arr[1])
|
||||
const u8 = new Uint8Array(bstr.length)
|
||||
for (let i = 0; i < bstr.length; i++) u8[i] = bstr.charCodeAt(i)
|
||||
return URL.createObjectURL(new Blob([u8], { type: mime }))
|
||||
} catch { return dataUrl }
|
||||
}
|
||||
const blobUrl = att.url.startsWith('data:') ? dataToBlobUrl(att.url) : att.url
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.download = att.name
|
||||
a.click()
|
||||
if (blobUrl !== att.url) URL.revokeObjectURL(blobUrl)
|
||||
}}
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1 ml-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-primary"
|
||||
title="下载附件"
|
||||
onClick={() => {
|
||||
const dataToBlobUrl = (dataUrl: string) => {
|
||||
try {
|
||||
const arr = dataUrl.split(',')
|
||||
const mime = arr[0].match(/:(.*?);/)?.[1] || 'application/octet-stream'
|
||||
const bstr = atob(arr[1])
|
||||
const u8 = new Uint8Array(bstr.length)
|
||||
for (let i = 0; i < bstr.length; i++) u8[i] = bstr.charCodeAt(i)
|
||||
return URL.createObjectURL(new Blob([u8], { type: mime }))
|
||||
} catch { return dataUrl }
|
||||
}
|
||||
const blobUrl = att.url.startsWith('data:') ? dataToBlobUrl(att.url) : att.url
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.download = att.name
|
||||
a.click()
|
||||
if (blobUrl !== att.url) URL.revokeObjectURL(blobUrl)
|
||||
}}
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-danger"
|
||||
title="删除附件"
|
||||
disabled={deleteAttachmentMutation.isPending}
|
||||
onClick={() => handleDeleteAttachment(c.id, atts, idx)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<input id={`contract-file-${c.id}`} type="file" multiple className="hidden" onChange={(e) => handleSupplementUpload(e, c.id, atts)} />
|
||||
@@ -405,12 +435,28 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
|
||||
const mime = previewUrl.startsWith('data:') ? previewUrl.match(/data:(.*?);/)?.[1] || '' : ''
|
||||
const isImage = mime.startsWith('image/')
|
||||
const isPdf = mime === 'application/pdf'
|
||||
const isWord = mime === 'application/msword' || mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || previewName.endsWith('.doc') || previewName.endsWith('.docx')
|
||||
|
||||
// 如果是 Word 文件且尚未转换,异步转换
|
||||
if (isWord && !wordHtml) {
|
||||
fetch(blobUrl)
|
||||
.then(r => r.arrayBuffer())
|
||||
.then(buf => mammoth.convertToHtml({ arrayBuffer: buf }))
|
||||
.then(result => setWordHtml(result.value))
|
||||
.catch(() => setWordHtml('<p style="text-align:center;color:#999;">Word 文件转换失败,请下载查看</p>'))
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl)
|
||||
setPreviewUrl(null)
|
||||
setWordHtml(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }}>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={closePreview}>
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b">
|
||||
<span className="text-sm font-medium">附件预览</span>
|
||||
<span className="text-sm font-medium">附件预览 - {previewName}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={() => {
|
||||
const a = document.createElement('a')
|
||||
@@ -422,7 +468,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
|
||||
}} className="text-xs text-primary hover:underline flex items-center gap-1">
|
||||
<Download className="w-3 h-3" />下载
|
||||
</button>
|
||||
<button onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }} className="text-gray-400 hover:text-gray-600">
|
||||
<button onClick={closePreview} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -432,6 +478,15 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
|
||||
<img src={blobUrl} alt="附件预览" className="max-w-full max-h-full object-contain" />
|
||||
) : isPdf ? (
|
||||
<embed src={blobUrl} type="application/pdf" className="w-full h-full" />
|
||||
) : isWord ? (
|
||||
wordHtml ? (
|
||||
<div className="prose prose-sm max-w-none w-full" dangerouslySetInnerHTML={{ __html: wordHtml }} />
|
||||
) : (
|
||||
<div className="text-center space-y-3">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full mx-auto" />
|
||||
<p className="text-sm text-gray-500">正在转换 Word 文档...</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-center space-y-3">
|
||||
<FileText className="w-12 h-12 text-gray-300 mx-auto" />
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function EmployeeProfile({ employeeId, onBack }: { employeeId: st
|
||||
<>
|
||||
{activeTab === 'basic' && <BasicInfo profile={profile} employeeId={employeeId} attachments={profile.attachments} />}
|
||||
{activeTab === 'contract' && <ContractInfo employeeId={employeeId} contracts={profile.contracts} hireDate={profile.hireDate} />}
|
||||
{activeTab === 'payslip' && <PayslipSocialInfo payslips={profile.payslips} socialInsRecords={profile.socialInsRecords} housingFundRecords={profile.housingFundRecords} monthlyProcessRecords={profile.monthlyProcessRecords} />}
|
||||
{activeTab === 'payslip' && <PayslipSocialInfo payslips={profile.payslips} socialInsRecords={profile.socialInsRecords} housingFundRecords={profile.housingFundRecords} monthlyProcessRecords={profile.monthlyProcessRecords} employeeId={employeeId} />}
|
||||
{activeTab === 'disciplinary' && <DisciplinaryInfo employeeId={employeeId} records={profile.disciplinaryRecords} />}
|
||||
{activeTab === 'attendance' && <AttendanceOvertimeInfo employeeId={employeeId} attendanceRecords={profile.attendanceRecords} overtimeRecords={profile.overtimeRecords} trainingRecords={profile.trainingRecords} />}
|
||||
{activeTab === 'performance' && <PerformanceInfo employeeId={employeeId} records={profile.performanceRecords} />}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { rosterApi } from '../../lib/api-services'
|
||||
import { rosterApi, evidenceApi } from '../../lib/api-services'
|
||||
import { useAuthStore } from "../../store/authStore"
|
||||
import Card from "../../components/ui/Card"
|
||||
import Button from "../../components/ui/Button"
|
||||
import { AlertTriangle, Scale } from "lucide-react"
|
||||
import { AlertTriangle, Scale, ShieldCheck } from "lucide-react"
|
||||
|
||||
// ========== 仲裁证据链 ==========
|
||||
|
||||
export default function EvidenceChain({ employeeId }: { employeeId: string }) {
|
||||
const [verifyResult, setVerifyResult] = useState<any>(null)
|
||||
const [verifying, setVerifying] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery<any>({
|
||||
queryKey: ['evidence-chain', employeeId],
|
||||
queryFn: async () => {
|
||||
@@ -16,6 +20,24 @@ export default function EvidenceChain({ employeeId }: { employeeId: string }) {
|
||||
},
|
||||
})
|
||||
|
||||
const handleVerify = async () => {
|
||||
setVerifying(true)
|
||||
try {
|
||||
const records = await evidenceApi.byEmployee(employeeId)
|
||||
const results: any[] = []
|
||||
for (const r of records) {
|
||||
const result = await evidenceApi.verify(r.id)
|
||||
results.push({ id: r.id, category: r.category, refId: r.refId, ...result })
|
||||
}
|
||||
setVerifyResult({ total: records.length, valid: results.filter(r => r.valid).length, invalid: results.filter(r => !r.valid).length, details: results })
|
||||
toast.success(`验证完成:${results.filter(r => r.valid).length}/${results.length} 条有效`)
|
||||
} catch {
|
||||
toast.error('验证失败')
|
||||
} finally {
|
||||
setVerifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">生成证据链中...</div>
|
||||
if (!data) return <div className="text-center py-8 text-gray-400">暂无证据链数据,请确保员工已录入合同、薪酬等信息</div>
|
||||
if (!data.evidence || data.evidence.length === 0) return (
|
||||
@@ -104,6 +126,10 @@ export default function EvidenceChain({ employeeId }: { employeeId: string }) {
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={handleExport}>导出证据链</Button>
|
||||
<Button variant="secondary" onClick={handleVerify} disabled={verifying}>
|
||||
<ShieldCheck className="w-4 h-4 mr-1" />
|
||||
{verifying ? '验证中...' : '验证完整性'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -129,6 +155,47 @@ export default function EvidenceChain({ employeeId }: { employeeId: string }) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{verifyResult && (
|
||||
<Card>
|
||||
<h3 className="text-xs font-medium mb-3 flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-safe" />
|
||||
证据链验证结果
|
||||
</h3>
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<div className="text-xs text-center">
|
||||
<div className="text-gray-500">总记录</div>
|
||||
<div className="text-lg font-bold">{verifyResult.total}</div>
|
||||
</div>
|
||||
<div className="text-xs text-center">
|
||||
<div className="text-gray-500">有效</div>
|
||||
<div className="text-lg font-bold text-safe">{verifyResult.valid}</div>
|
||||
</div>
|
||||
{verifyResult.invalid > 0 && (
|
||||
<div className="text-xs text-center">
|
||||
<div className="text-gray-500">无效</div>
|
||||
<div className="text-lg font-bold text-danger">{verifyResult.invalid}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{verifyResult.invalid > 0 && (
|
||||
<div className="space-y-1">
|
||||
{verifyResult.details.filter((r: any) => !r.valid).map((r: any, i: number) => (
|
||||
<div key={i} className="text-xs border rounded p-2 bg-red-50 border-red-200 text-red-700">
|
||||
<span className="font-medium">{r.category}</span>
|
||||
{r.refId && <span className="text-xs opacity-70 ml-2">ID: {r.refId}</span>}
|
||||
<div className="mt-0.5 opacity-90">哈希不匹配:预期 {r.expectedHash?.slice(0, 16)}...,实际 {r.actualHash?.slice(0, 16)}...</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{verifyResult.invalid === 0 && (
|
||||
<div className="text-xs text-safe flex items-center gap-1">
|
||||
<ShieldCheck className="w-3.5 h-3.5" /> 所有证据链哈希验证通过,数据完整无篡改
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{data.evidence.map((e: any, i: number) => (
|
||||
<Card key={i} className={e.riskLevel === 'HIGH' ? 'border-orange-300' : ''}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { socialInsuranceApi } from '../../lib/api-services'
|
||||
@@ -7,26 +8,36 @@ import Button from "../../components/ui/Button"
|
||||
import { Input, Label, Select } from "../../components/ui/Input"
|
||||
import Modal from "../../components/ui/Modal"
|
||||
import { fmt } from "./shared"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
|
||||
/** 薪酬社保合并组件(工资条 / 缴纳记录) */
|
||||
export default function PayslipSocialInfo({ payslips, monthlyProcessRecords }: { payslips: any[]; socialInsRecords: any[]; housingFundRecords: any[]; monthlyProcessRecords: any[] }) {
|
||||
export default function PayslipSocialInfo({ payslips, monthlyProcessRecords, employeeId }: { payslips: any[]; socialInsRecords: any[]; housingFundRecords: any[]; monthlyProcessRecords: any[]; employeeId?: string }) {
|
||||
const [subTab, setSubTab] = useState<'payslip' | 'monthly'>('payslip')
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setSubTab('payslip')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${subTab === 'payslip' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
工资条({payslips?.length || 0}条)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSubTab('monthly')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${subTab === 'monthly' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
缴纳记录({monthlyProcessRecords?.length || 0}条)
|
||||
</button>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setSubTab('payslip')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${subTab === 'payslip' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
工资条({payslips?.length || 0}条)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSubTab('monthly')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${subTab === 'monthly' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
缴纳记录({monthlyProcessRecords?.length || 0}条)
|
||||
</button>
|
||||
</div>
|
||||
{employeeId && (
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate(`/money?employeeId=${employeeId}&tab=payslip`)}>
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-1" />
|
||||
查看薪资历史
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{subTab === 'payslip' && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { rosterApi } from '../../lib/api-services'
|
||||
import Card from "../../components/ui/Card"
|
||||
import Button from "../../components/ui/Button"
|
||||
@@ -11,11 +11,17 @@ import { AlertTriangle, Check } from "lucide-react"
|
||||
export default function PerformanceInfo({ employeeId, records }: { employeeId: string; records: any[] }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [form, setForm] = useState({ period: '', periodType: 'MONTHLY' as 'MONTHLY' | 'QUARTERLY' | 'YEARLY', score: 80, grade: 'B', result: 'QUALIFIED', summary: '', improvementPlan: '', employeeAck: false, ackDate: '', reviewer: '' })
|
||||
const [form, setForm] = useState({ period: '', periodType: 'MONTHLY' as 'MONTHLY' | 'QUARTERLY' | 'YEARLY', score: 80, grade: 'B', result: 'QUALIFIED', summary: '', improvementPlan: '', employeeAck: false, ackDate: '', reviewer: '', templateId: '' })
|
||||
const [dimensionScores, setDimensionScores] = useState<Record<string, number>>({})
|
||||
|
||||
const { data: templates } = useQuery({
|
||||
queryKey: ['performance-templates'],
|
||||
queryFn: () => rosterApi.performanceTemplates(),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => rosterApi.performance(employeeId, data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false); setDimensionScores({}) },
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -38,6 +44,32 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
|
||||
setForm({ ...form, score, grade, result })
|
||||
}
|
||||
|
||||
const selectedTemplate = (templates || []).find((t: any) => t.id === form.templateId)
|
||||
const dimensions: any[] = selectedTemplate?.dimensions || []
|
||||
|
||||
const handleDimensionChange = (name: string, score: number) => {
|
||||
const updated = { ...dimensionScores, [name]: score }
|
||||
setDimensionScores(updated)
|
||||
if (dimensions.length > 0) {
|
||||
const totalScore = dimensions.reduce((sum: number, d: any) => {
|
||||
const s = updated[d.name] ?? 0
|
||||
const weight = d.weight || 0
|
||||
const maxScore = d.maxScore || 100
|
||||
return sum + (s / maxScore) * weight * 100
|
||||
}, 0)
|
||||
const { grade, result } = scoreToGrade(Math.round(totalScore))
|
||||
setForm(prev => ({ ...prev, score: Math.round(totalScore), grade, result }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
const data: any = { ...form }
|
||||
if (form.templateId) {
|
||||
data.dimensionScores = dimensionScores
|
||||
}
|
||||
createMutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -56,26 +88,58 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>考核周期</Label><Input value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} placeholder={form.periodType === 'MONTHLY' ? '如 2026-07' : form.periodType === 'QUARTERLY' ? '如 2026-Q3' : '如 2026'} /></div>
|
||||
<div><Label>考核得分</Label><Input type="number" value={form.score} onChange={(e) => handleScoreChange(Number(e.target.value))} /></div>
|
||||
<div><Label>等级(由得分自动计算)</Label>
|
||||
<Select value={form.grade} onChange={(e) => setForm({ ...form, grade: e.target.value })}>
|
||||
<option value="A">A</option><option value="B">B</option><option value="C">C</option><option value="D">D</option>
|
||||
<div className="md:col-span-2"><Label>绩效模板(选填)</Label>
|
||||
<Select value={form.templateId} onChange={(e) => { setForm({ ...form, templateId: e.target.value }); setDimensionScores({}) }}>
|
||||
<option value="">不使用模板(简单评分)</option>
|
||||
{(templates || []).map((t: any) => (
|
||||
<option key={t.id} value={t.id}>{t.name}{t.isDefault ? '(默认)' : ''}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
{dimensions.length > 0 ? (
|
||||
<div className="md:col-span-2 border border-gray-200 rounded-md p-3 space-y-2">
|
||||
<div className="text-xs font-medium text-gray-600">考核维度(按权重计算总分)</div>
|
||||
{dimensions.map((d: any) => (
|
||||
<div key={d.name} className="grid grid-cols-12 gap-2 items-center">
|
||||
<div className="col-span-5">
|
||||
<span className="text-sm">{d.name}</span>
|
||||
<span className="text-xs text-gray-400 ml-1">权重{d.weight}%</span>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<Input type="number" min={0} max={d.maxScore || 100} value={dimensionScores[d.name] ?? ''} onChange={(e) => handleDimensionChange(d.name, Number(e.target.value))} placeholder={`满分${d.maxScore || 100}`} className="text-sm" />
|
||||
</div>
|
||||
<div className="col-span-3 text-xs text-gray-400">/{d.maxScore || 100}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="grid grid-cols-2 gap-3 pt-2 border-t">
|
||||
<div><Label>总分(自动计算)</Label><Input type="number" value={form.score} readOnly className="bg-gray-50" /></div>
|
||||
<div><Label>等级(自动计算)</Label><Input value={form.grade} readOnly className="bg-gray-50" /></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div><Label>考核得分</Label><Input type="number" value={form.score} onChange={(e) => handleScoreChange(Number(e.target.value))} /></div>
|
||||
<div><Label>等级(由得分自动计算)</Label>
|
||||
<Select value={form.grade} onChange={(e) => setForm({ ...form, grade: e.target.value })}>
|
||||
<option value="A">A</option><option value="B">B</option><option value="C">C</option><option value="D">D</option>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div><Label>考核结果(由得分自动计算)</Label>
|
||||
<Select value={form.result} onChange={(e) => setForm({ ...form, result: e.target.value })}>
|
||||
{Object.entries(resultMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>考核人</Label><Input value={form.reviewer} onChange={(e) => setForm({ ...form, reviewer: e.target.value })} /></div>
|
||||
<div className="md:col-span-2"><Label>考核评语</Label><Input value={form.summary} onChange={(e) => setForm({ ...form, summary: e.target.value })} /></div>
|
||||
<div className="md:col-span-2"><Label>改进计划(不胜任时填写)</Label><Input value={form.improvementPlan} onChange={(e) => setForm({ ...form, improvementPlan: e.target.value })} placeholder="如:调岗至XX岗位,培训XX技能" /></div>
|
||||
<div><Label>考核人</Label><Input value={form.reviewer} onChange={(e) => setForm({ ...form, reviewer: e.target.value })} /></div>
|
||||
<div className="flex items-center gap-2 pt-6">
|
||||
<input type="checkbox" id="perfAck" checked={form.employeeAck} onChange={(e) => setForm({ ...form, employeeAck: e.target.checked })} />
|
||||
<label htmlFor="perfAck" className="text-xs">员工已签字确认</label>
|
||||
</div>
|
||||
{form.employeeAck && <div><Label>确认日期</Label><Input type="date" value={form.ackDate} onChange={(e) => setForm({ ...form, ackDate: e.target.value })} /></div>}
|
||||
<div className="md:col-span-2 flex gap-2"><Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.period}>{createMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}>取消</Button></div>
|
||||
<div className="md:col-span-2 flex gap-2"><Button onClick={handleSubmit} disabled={createMutation.isPending || !form.period}>{createMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}>取消</Button></div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
@@ -93,6 +157,13 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs">得分 {r.score} · 等级 {r.grade}</span>
|
||||
</div>
|
||||
{r.dimensionScores && Object.keys(r.dimensionScores).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(r.dimensionScores).map(([name, score]: [string, any]) => (
|
||||
<span key={name} className="text-xs px-2 py-0.5 rounded bg-gray-50 text-gray-600">{name}: {score}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{r.summary && <div className="text-xs text-gray-600 leading-relaxed">{r.summary}</div>}
|
||||
{r.improvementPlan && (
|
||||
<div className="text-xs bg-amber-50 text-amber-700 px-2 py-1.5 rounded leading-relaxed">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Search, Plus, Edit2, Trash2, X } from 'lucide-react'
|
||||
import { Search, Plus, Edit2, Trash2, X, LayoutTemplate } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { rosterApi, employeeApi } from '../../lib/api-services'
|
||||
import api from '../../lib/api'
|
||||
@@ -19,6 +19,7 @@ export default function PerformanceRecords() {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [editRecord, setEditRecord] = useState<any>(null)
|
||||
const [showTemplateModal, setShowTemplateModal] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['performance-list', page, pageSize, keyword],
|
||||
@@ -30,6 +31,11 @@ export default function PerformanceRecords() {
|
||||
queryFn: () => employeeApi.list({ status: 'ACTIVE' }),
|
||||
})
|
||||
|
||||
const { data: templates } = useQuery({
|
||||
queryKey: ['performance-templates'],
|
||||
queryFn: () => rosterApi.performanceTemplates(),
|
||||
})
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (data: any) => {
|
||||
const empId = data.employeeId
|
||||
@@ -71,9 +77,14 @@ export default function PerformanceRecords() {
|
||||
<h1 className="text-base font-semibold">绩效考核</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">管理全员绩效考核记录</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setShowCreate(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" /> 新增绩效记录
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowTemplateModal(true)}>
|
||||
<LayoutTemplate className="w-4 h-4 mr-1" /> 绩效模板
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowCreate(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" /> 新增绩效记录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -163,17 +174,26 @@ export default function PerformanceRecords() {
|
||||
{(showCreate || editRecord) && (
|
||||
<PerformanceForm
|
||||
employees={employees || []}
|
||||
templates={templates || []}
|
||||
record={editRecord}
|
||||
onSubmit={(data) => saveMut.mutate(data)}
|
||||
onClose={() => { setShowCreate(false); setEditRecord(null) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showTemplateModal && (
|
||||
<TemplateModal
|
||||
templates={templates || []}
|
||||
onClose={() => setShowTemplateModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PerformanceForm({ employees, record, onSubmit, onClose }: {
|
||||
function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
|
||||
employees: any[]
|
||||
templates: any[]
|
||||
record: any
|
||||
onSubmit: (data: any) => void
|
||||
onClose: () => void
|
||||
@@ -188,7 +208,12 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
|
||||
summary: record?.summary || '',
|
||||
improvementPlan: record?.improvementPlan || '',
|
||||
reviewer: record?.reviewer || '',
|
||||
templateId: record?.templateId || '',
|
||||
})
|
||||
const [dimensionScores, setDimensionScores] = useState<Record<string, number>>(record?.dimensionScores || {})
|
||||
|
||||
const selectedTemplate = templates.find((t: any) => t.id === form.templateId)
|
||||
const dimensions: any[] = selectedTemplate?.dimensions || []
|
||||
|
||||
const scoreToGrade = (score: number): { grade: string; result: string } => {
|
||||
if (score >= 90) return { grade: 'A', result: 'EXCELLENT' }
|
||||
@@ -202,6 +227,31 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
|
||||
setForm({ ...form, score, grade, result })
|
||||
}
|
||||
|
||||
const handleDimensionChange = (name: string, score: number) => {
|
||||
const updated = { ...dimensionScores, [name]: score }
|
||||
setDimensionScores(updated)
|
||||
// 按权重计算总分
|
||||
if (dimensions.length > 0) {
|
||||
const totalScore = dimensions.reduce((sum: number, d: any) => {
|
||||
const s = updated[d.name] ?? 0
|
||||
const weight = d.weight || 0
|
||||
const maxScore = d.maxScore || 100
|
||||
return sum + (s / maxScore) * weight * 100
|
||||
}, 0)
|
||||
const { grade, result } = scoreToGrade(Math.round(totalScore))
|
||||
setForm(prev => ({ ...prev, score: Math.round(totalScore), grade, result }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
const data: any = { ...form }
|
||||
if (form.templateId) {
|
||||
data.templateId = form.templateId
|
||||
data.dimensionScores = dimensionScores
|
||||
}
|
||||
onSubmit(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
@@ -235,21 +285,62 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
|
||||
<Label>考核周期</Label>
|
||||
<Input type={form.periodType === 'YEARLY' ? 'number' : 'month'} value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} placeholder={form.periodType === 'YEARLY' ? '如 2026' : undefined} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>得分</Label>
|
||||
<Input type="number" min={0} max={100} value={form.score} onChange={(e) => handleScoreChange(Number(e.target.value))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>等级(自动计算)</Label>
|
||||
<Select value={form.grade} onChange={(e) => setForm({ ...form, grade: e.target.value })}>
|
||||
<option value="A">A</option>
|
||||
<option value="B">B</option>
|
||||
<option value="C">C</option>
|
||||
<option value="D">D</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>绩效模板(选填)</Label>
|
||||
<Select value={form.templateId} onChange={(e) => { setForm({ ...form, templateId: e.target.value }); setDimensionScores({}) }}>
|
||||
<option value="">不使用模板(简单评分)</option>
|
||||
{templates.map((t: any) => (
|
||||
<option key={t.id} value={t.id}>{t.name}{t.isDefault ? '(默认)' : ''}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
{dimensions.length > 0 ? (
|
||||
<div className="border border-gray-200 rounded-md p-3 space-y-2">
|
||||
<div className="text-xs font-medium text-gray-600">考核维度(按权重计算总分)</div>
|
||||
{dimensions.map((d: any) => (
|
||||
<div key={d.name} className="grid grid-cols-12 gap-2 items-center">
|
||||
<div className="col-span-5">
|
||||
<span className="text-sm">{d.name}</span>
|
||||
{d.description && <span className="text-xs text-gray-400 ml-1">({d.description})</span>}
|
||||
<span className="text-xs text-gray-400 ml-1">权重{d.weight}%</span>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<Input type="number" min={0} max={d.maxScore || 100} value={dimensionScores[d.name] ?? ''} onChange={(e) => handleDimensionChange(d.name, Number(e.target.value))} placeholder={`满分${d.maxScore || 100}`} className="text-sm" />
|
||||
</div>
|
||||
<div className="col-span-3 text-xs text-gray-400">/{d.maxScore || 100}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-xs text-gray-500 pt-1 border-t">系统按各维度得分和权重自动计算总分</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>得分</Label>
|
||||
<Input type="number" min={0} max={100} value={form.score} onChange={(e) => handleScoreChange(Number(e.target.value))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>等级(自动计算)</Label>
|
||||
<Select value={form.grade} onChange={(e) => setForm({ ...form, grade: e.target.value })}>
|
||||
<option value="A">A</option>
|
||||
<option value="B">B</option>
|
||||
<option value="C">C</option>
|
||||
<option value="D">D</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dimensions.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>总分(自动计算)</Label>
|
||||
<Input type="number" value={form.score} readOnly className="bg-gray-50" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>等级(自动计算)</Label>
|
||||
<Input value={form.grade} readOnly className="bg-gray-50" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label>考核结果(自动计算)</Label>
|
||||
<Select value={form.result} onChange={(e) => setForm({ ...form, result: e.target.value })}>
|
||||
@@ -285,10 +376,200 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" size="sm" onClick={onClose}>取消</Button>
|
||||
<Button size="sm" onClick={() => onSubmit(form)} disabled={!form.employeeId || !form.period}>保存</Button>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!form.employeeId || !form.period}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplateModal({ templates, onClose }: {
|
||||
templates: any[]
|
||||
onClose: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const [editing, setEditing] = useState<any>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (data: any) => rosterApi.createPerformanceTemplate(data),
|
||||
onSuccess: () => {
|
||||
toast.success('模板已创建')
|
||||
queryClient.invalidateQueries({ queryKey: ['performance-templates'] })
|
||||
setShowForm(false)
|
||||
},
|
||||
onError: () => toast.error('创建失败'),
|
||||
})
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => rosterApi.updatePerformanceTemplate(id, data),
|
||||
onSuccess: () => {
|
||||
toast.success('模板已更新')
|
||||
queryClient.invalidateQueries({ queryKey: ['performance-templates'] })
|
||||
setShowForm(false)
|
||||
setEditing(null)
|
||||
},
|
||||
onError: () => toast.error('更新失败'),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: string) => rosterApi.deletePerformanceTemplate(id),
|
||||
onSuccess: () => {
|
||||
toast.success('模板已删除')
|
||||
queryClient.invalidateQueries({ queryKey: ['performance-templates'] })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium">绩效模板管理</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => { setEditing(null); setShowForm(true) }}>
|
||||
<Plus className="w-4 h-4 mr-1" />新建模板
|
||||
</Button>
|
||||
<button onClick={onClose}><X className="w-4 h-4 text-gray-400" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showForm ? (
|
||||
<TemplateForm
|
||||
template={editing}
|
||||
onSubmit={(data) => {
|
||||
if (editing) {
|
||||
updateMut.mutate({ id: editing.id, data })
|
||||
} else {
|
||||
createMut.mutate(data)
|
||||
}
|
||||
}}
|
||||
onClose={() => { setShowForm(false); setEditing(null) }}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{templates.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">
|
||||
暂无绩效模板,点击「新建模板」创建
|
||||
</div>
|
||||
) : templates.map((t: any) => (
|
||||
<div key={t.id} className="border border-gray-200 rounded-md p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{t.name}</span>
|
||||
{t.isDefault && <span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary">默认</span>}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => { setEditing(t); setShowForm(true) }} className="p-1 hover:bg-gray-100 rounded">
|
||||
<Edit2 className="w-3.5 h-3.5 text-gray-500" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm('确认删除此模板?')) deleteMut.mutate(t.id) }}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{t.description && <p className="text-xs text-gray-500 mt-1">{t.description}</p>}
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{(t.dimensions as any[]).map((d: any) => (
|
||||
<span key={d.name} className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600">
|
||||
{d.name}(权重{d.weight}%)
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplateForm({ template, onSubmit, onClose }: {
|
||||
template: any
|
||||
onSubmit: (data: any) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [name, setName] = useState(template?.name || '')
|
||||
const [description, setDescription] = useState(template?.description || '')
|
||||
const [isDefault, setIsDefault] = useState(template?.isDefault || false)
|
||||
const [dimensions, setDimensions] = useState<any[]>(
|
||||
template?.dimensions || [{ name: '', weight: 100, maxScore: 100, description: '' }]
|
||||
)
|
||||
|
||||
const addDimension = () => {
|
||||
setDimensions([...dimensions, { name: '', weight: 0, maxScore: 100, description: '' }])
|
||||
}
|
||||
|
||||
const removeDimension = (idx: number) => {
|
||||
setDimensions(dimensions.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
const updateDimension = (idx: number, field: string, value: any) => {
|
||||
setDimensions(dimensions.map((d, i) => i === idx ? { ...d, [field]: value } : d))
|
||||
}
|
||||
|
||||
const totalWeight = dimensions.reduce((sum, d) => sum + (Number(d.weight) || 0), 0)
|
||||
const canSubmit = name && dimensions.every(d => d.name) && totalWeight === 100
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>模板名称 *</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:月度绩效考核表" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>模板说明</Label>
|
||||
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="模板用途说明(选填)" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>考核维度 *</Label>
|
||||
<div className="space-y-2">
|
||||
{dimensions.map((d, idx) => (
|
||||
<div key={idx} className="grid grid-cols-12 gap-2 items-center border border-gray-200 rounded p-2">
|
||||
<div className="col-span-3">
|
||||
<Input value={d.name} onChange={(e) => updateDimension(idx, 'name', e.target.value)} placeholder="维度名称" className="text-sm" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Input type="number" min={0} max={100} value={d.weight} onChange={(e) => updateDimension(idx, 'weight', Number(e.target.value))} placeholder="权重%" className="text-sm" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Input type="number" min={1} value={d.maxScore} onChange={(e) => updateDimension(idx, 'maxScore', Number(e.target.value))} placeholder="满分" className="text-sm" />
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<Input value={d.description || ''} onChange={(e) => updateDimension(idx, 'description', e.target.value)} placeholder="说明(选填)" className="text-sm" />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
{dimensions.length > 1 && (
|
||||
<button onClick={() => removeDimension(idx)} className="p-1 hover:bg-gray-100 rounded">
|
||||
<X className="w-3.5 h-3.5 text-red-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<button onClick={addDimension} className="text-xs text-primary hover:underline">+ 添加维度</button>
|
||||
<span className={`text-xs ${totalWeight === 100 ? 'text-green-600' : 'text-amber-600'}`}>权重合计:{totalWeight}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} />
|
||||
设为默认模板
|
||||
</label>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" size="sm" onClick={onClose}>取消</Button>
|
||||
<Button size="sm" onClick={() => onSubmit({ name, description, dimensions, isDefault })} disabled={!canSubmit}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
{!canSubmit && totalWeight !== 100 && (
|
||||
<div className="text-xs text-amber-600">各维度权重合计必须为100%</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Search, Plus, Edit2, Trash2, X } from 'lucide-react'
|
||||
import { Search, Plus, Edit2, Trash2, X, Bell, Users, Check } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { rosterApi, employeeApi } from '../../lib/api-services'
|
||||
import api from '../../lib/api'
|
||||
import { usePageSize } from '../../hooks/usePageSize'
|
||||
import { Input, Label, Select } from '../../components/ui/Input'
|
||||
import Button from '../../components/ui/Button'
|
||||
import Modal from '../../components/ui/Modal'
|
||||
|
||||
const ACK_LABELS: Record<string, string> = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' }
|
||||
const ACK_COLORS: Record<string, string> = { PENDING: 'bg-amber-50 text-amber-700', SIGNED: 'bg-green-50 text-green-700', REFUSED: 'bg-red-50 text-red-700' }
|
||||
@@ -22,12 +23,13 @@ export default function TrainingRecords() {
|
||||
const pageSize = usePageSize()
|
||||
const [page, setPage] = useState(1)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [filterAckStatus, setFilterAckStatus] = useState('')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [editRecord, setEditRecord] = useState<any>(null)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['training-list', page, pageSize, keyword],
|
||||
queryFn: () => rosterApi.trainingList({ page, pageSize, keyword }),
|
||||
queryKey: ['training-list', page, pageSize, keyword, filterAckStatus],
|
||||
queryFn: () => rosterApi.trainingList({ page, pageSize, keyword, ackStatus: filterAckStatus }),
|
||||
})
|
||||
|
||||
const { data: employees } = useQuery({
|
||||
@@ -49,6 +51,16 @@ export default function TrainingRecords() {
|
||||
onError: () => toast.error('添加失败'),
|
||||
})
|
||||
|
||||
const batchCreateMut = useMutation({
|
||||
mutationFn: (data: any) => api.post('/roster/training/batch', data),
|
||||
onSuccess: (data: any) => {
|
||||
toast.success(`已为 ${data?.count || 0} 名员工添加培训记录`)
|
||||
queryClient.invalidateQueries({ queryKey: ['training-list'] })
|
||||
setShowCreate(false)
|
||||
},
|
||||
onError: () => toast.error('批量添加失败'),
|
||||
})
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: (data: any) => {
|
||||
const empId = data.employeeId
|
||||
@@ -74,6 +86,14 @@ export default function TrainingRecords() {
|
||||
},
|
||||
})
|
||||
|
||||
const remindMut = useMutation({
|
||||
mutationFn: (recordId: string) => rosterApi.trainingRemind(recordId),
|
||||
onSuccess: (data: any) => {
|
||||
toast.success(data?.message || '催办已发送')
|
||||
},
|
||||
onError: () => toast.error('催办失败'),
|
||||
})
|
||||
|
||||
const records = data?.records || []
|
||||
const total = data?.total || 0
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
@@ -100,6 +120,16 @@ export default function TrainingRecords() {
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={filterAckStatus}
|
||||
onChange={(e) => { setFilterAckStatus(e.target.value); setPage(1) }}
|
||||
className="w-32"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="PENDING">待签收</option>
|
||||
<option value="SIGNED">已签收</option>
|
||||
<option value="REFUSED">拒绝签收</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
@@ -138,6 +168,16 @@ export default function TrainingRecords() {
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<div className="flex gap-1">
|
||||
{r.ackStatus === 'PENDING' && (
|
||||
<button
|
||||
onClick={() => remindMut.mutate(r.id)}
|
||||
disabled={remindMut.isPending}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
title="催办签收"
|
||||
>
|
||||
<Bell className="w-3.5 h-3.5 text-amber-500" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setEditRecord(r)} className="p-1 hover:bg-gray-100 rounded">
|
||||
<Edit2 className="w-3.5 h-3.5 text-gray-500" />
|
||||
</button>
|
||||
@@ -173,6 +213,8 @@ export default function TrainingRecords() {
|
||||
onSubmit={(data) => {
|
||||
if (editRecord) {
|
||||
updateMut.mutate({ ...data, employeeId: editRecord.employeeId, recordId: editRecord.id })
|
||||
} else if (data.employeeIds) {
|
||||
batchCreateMut.mutate(data)
|
||||
} else {
|
||||
createMut.mutate(data)
|
||||
}
|
||||
@@ -190,6 +232,9 @@ function TrainingForm({ employees, record, onSubmit, onClose }: {
|
||||
onSubmit: (data: any) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [batchMode, setBatchMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [batchSearch, setBatchSearch] = useState('')
|
||||
const [form, setForm] = useState({
|
||||
employeeId: record?.employeeId || '',
|
||||
trainingDate: record?.trainingDate ? new Date(record.trainingDate).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10),
|
||||
@@ -200,6 +245,24 @@ function TrainingForm({ employees, record, onSubmit, onClose }: {
|
||||
remark: record?.remark || '',
|
||||
})
|
||||
|
||||
const filteredEmployees = batchSearch
|
||||
? employees.filter((e: any) => e.name.includes(batchSearch) || (e.department || '').includes(batchSearch))
|
||||
: employees
|
||||
|
||||
const toggleEmployee = (id: string) => {
|
||||
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (batchMode) {
|
||||
onSubmit({ ...form, employeeIds: selectedIds })
|
||||
} else {
|
||||
onSubmit(form)
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = batchMode ? selectedIds.length > 0 && form.topic : form.employeeId && form.topic
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
@@ -210,13 +273,60 @@ function TrainingForm({ employees, record, onSubmit, onClose }: {
|
||||
<div className="space-y-3">
|
||||
{!record && (
|
||||
<div>
|
||||
<Label>员工</Label>
|
||||
<Select value={form.employeeId} onChange={(e) => setForm({ ...form, employeeId: e.target.value })}>
|
||||
<option value="">请选择员工</option>
|
||||
{employees.map((emp: any) => (
|
||||
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department || ''}</option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Label>{batchMode ? '批量选择员工' : '员工'}</Label>
|
||||
<button
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
onClick={() => { setBatchMode(!batchMode); setSelectedIds([]) }}
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
{batchMode ? '切换为单选' : '切换为批量'}
|
||||
</button>
|
||||
</div>
|
||||
{batchMode ? (
|
||||
<div className="border border-gray-200 rounded-md">
|
||||
<div className="p-2 border-b border-gray-100">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索姓名/部门"
|
||||
value={batchSearch}
|
||||
onChange={(e) => setBatchSearch(e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm border border-gray-200 rounded focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[180px] overflow-y-auto">
|
||||
{filteredEmployees.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-xs text-gray-400">未找到匹配员工</div>
|
||||
) : filteredEmployees.map((emp: any) => (
|
||||
<label
|
||||
key={emp.id}
|
||||
className="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 cursor-pointer text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(emp.id)}
|
||||
onChange={() => toggleEmployee(emp.id)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>{emp.name}</span>
|
||||
<span className="text-gray-400 text-xs">{emp.department || ''}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="px-3 py-1.5 border-t border-gray-100 text-xs text-primary">
|
||||
已选择 {selectedIds.length} 名员工
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Select value={form.employeeId} onChange={(e) => setForm({ ...form, employeeId: e.target.value })}>
|
||||
<option value="">请选择员工</option>
|
||||
{employees.map((emp: any) => (
|
||||
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department || ''}</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
@@ -258,7 +368,7 @@ function TrainingForm({ employees, record, onSubmit, onClose }: {
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" size="sm" onClick={onClose}>取消</Button>
|
||||
<Button size="sm" onClick={() => onSubmit(form)} disabled={!form.employeeId || !form.topic}>保存</Button>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!canSubmit}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { rosterApi, socialInsuranceApi } from '../../lib/api-services'
|
||||
import { rosterApi, socialInsuranceApi, employeeApi } from '../../lib/api-services'
|
||||
import Button from "../../components/ui/Button"
|
||||
import { Input, Label, Select } from "../../components/ui/Input"
|
||||
import Modal from "../../components/ui/Modal"
|
||||
@@ -395,7 +395,7 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div>
|
||||
<Label>社保缴费基数</Label>
|
||||
<Input type="number" value={form.socialInsBase || employee?.monthlySalary || ''} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} placeholder="默认为月工资" />
|
||||
<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>
|
||||
@@ -403,7 +403,7 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
</div>
|
||||
<div>
|
||||
<Label>公积金缴费基数</Label>
|
||||
<Input type="number" value={form.housingFundBase || employee?.monthlySalary || ''} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} placeholder="默认为月工资" />
|
||||
<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>
|
||||
@@ -510,17 +510,35 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
d.setDate(d.getDate() - 1)
|
||||
return d.toISOString().slice(0, 10)
|
||||
})()
|
||||
const [form, setForm] = useState({
|
||||
name: '', department: '', 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: '',
|
||||
const [form, setForm] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('add-employee-draft')
|
||||
if (saved) return JSON.parse(saved)
|
||||
} catch {}
|
||||
return {
|
||||
name: '', department: '', 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) : ''
|
||||
|
||||
// 入职日期变更 → 同步合同开始日期 + 重算结束日期
|
||||
@@ -536,7 +554,8 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
}
|
||||
}
|
||||
|
||||
// 根据身份证号自动计算性别(第17位:奇数=男,偶数=女)
|
||||
// 根据身份证号自动计算性别(第17位:奇数=男,偶数=女)+ 查重
|
||||
const [idCardDuplicate, setIdCardDuplicate] = useState<{ exists: boolean; employee?: any } | null>(null)
|
||||
const handleIdCardChange = (idCard: string) => {
|
||||
let gender = form.gender
|
||||
if (idCard.length >= 17) {
|
||||
@@ -544,6 +563,12 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
if (!isNaN(digit)) gender = digit % 2 === 1 ? '男' : '女'
|
||||
}
|
||||
setForm({ ...form, idCardNumber: idCard, gender })
|
||||
setIdCardDuplicate(null)
|
||||
if (idCard.length === 18) {
|
||||
employeeApi.checkIdCard(idCard).then((data: { exists: boolean; employee?: any }) => {
|
||||
setIdCardDuplicate(data)
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// 计算合同月数
|
||||
@@ -610,6 +635,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
const handleSubmit = () => {
|
||||
const data: any = {
|
||||
name: form.name, department: form.department,
|
||||
position: form.position || undefined,
|
||||
hireDate: new Date(form.hireDate).toISOString(),
|
||||
monthlySalary: form.monthlySalary, gender: form.gender,
|
||||
femaleWorkerType: form.gender === '女' && form.femaleWorkerType ? form.femaleWorkerType : undefined,
|
||||
@@ -642,18 +668,29 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
useUnsavedChanges(isDirty)
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title="添加员工" size="xl">
|
||||
<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?.message || '操作失败'}
|
||||
{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><Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} placeholder="如:技术部" /></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>
|
||||
)}
|
||||
<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>
|
||||
@@ -665,7 +702,28 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
<div><Label>手机号</Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div><Label>参保城市</Label><Select value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })}>{cities.map((c) => <option key={c} value={c}>{c}</option>)}</Select></div>
|
||||
<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>
|
||||
{/* 社保公积金 */}
|
||||
@@ -678,7 +736,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label>社保缴费基数</Label>
|
||||
<Input type="number" value={form.socialInsBase || form.monthlySalary} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} placeholder="默认为月工资" />
|
||||
<Input type="number" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={form.monthlySalary || '默认为月工资'} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>社保开始年月</Label>
|
||||
@@ -686,7 +744,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
</div>
|
||||
<div>
|
||||
<Label>公积金缴费基数</Label>
|
||||
<Input type="number" value={form.housingFundBase || form.monthlySalary} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} placeholder="默认为月工资" />
|
||||
<Input type="number" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={form.monthlySalary || '默认为月工资'} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>公积金开始年月</Label>
|
||||
|
||||
Reference in New Issue
Block a user