feat: 20260809 系统优化 - 全部28项问题修复(P0×6+P1×16+P2×6)

P0: 福利批量参保/离职证明下载防乱码/考勤模板合并Sheet/补卡修改/附件在线查看删除
P1: 分页pageSize修复/离职导出筛选/撤回删除草稿/加班费自动计算/考勤加班汇总/证据链异常详情/制度催办/模板导入Word/社保封顶保底/校验字段提示/职务字段/社保费用明细/弹窗防误关/身份证查重/证明员工下拉/培训批量
P2: 离职流程去重/社保基数覆盖输入/薪税入口改名/添加员工引导/绩效模板清理
This commit is contained in:
freedakgmail
2026-08-09 11:59:02 +08:00
parent c355a7d208
commit a2e9ba55c2
43 changed files with 2913 additions and 324 deletions
+47 -4
View File
@@ -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>
+85 -30
View File
@@ -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} />}
+69 -2
View File
@@ -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' : ''}>
+25 -14
View File
@@ -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' && (
+80 -9
View File
@@ -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">
+301 -20
View File
@@ -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>
)
}
+121 -11
View File
@@ -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>
+77 -19
View File
@@ -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>