fix: 优化文档16项问题修复
- 问题1/3: 绩效考核/培训记录员工姓名可点击跳转员工详情页 - 问题2: 离职证明模板支持自定义+员工端下载 - 问题4(P0): 修复工资填写后数据归零问题 - 问题5: 社保添加员工参保信息列表 - 问题6(P0): 商业保险支持为员工参保 - 问题7(P0): 员工福利支持为员工添加福利 - 问题8: 规章制度支持导入Word文档 - 问题9: 文本模板下载Word增加HTML格式 - 问题10: 模板下载变量替换修复(排除token参数) - 问题11(P0): 电子签署发起时员工下拉框有选项 - 问题12: 新增绩效记录添加考评人选项 - 问题13: 违纪记录添加处罚执行细节 - 问题14: 特殊员工列表添加查看详情按钮和姓名链接 - 问题15: 员工福利汇总正确显示参保人员 - 问题16(P0): 证据链验证修复(递归排序key+自动修复历史哈希)
This commit is contained in:
@@ -585,6 +585,9 @@ export const socialInsuranceApi = {
|
||||
/** 公积金活跃申报 */
|
||||
housingActiveDeclaration: (month: string) =>
|
||||
get('/social/housing/active-declaration', { params: { month } }).then(unwrap<any>()),
|
||||
/** 员工参保信息列表 */
|
||||
employeeEnrollment: (keyword?: string) =>
|
||||
get('/social/employee-enrollment', { params: keyword ? { keyword } : {} }).then(unwrap<any[]>()),
|
||||
}
|
||||
|
||||
// ========== 商业保险 ==========
|
||||
@@ -1053,6 +1056,9 @@ export const portalApi = {
|
||||
/** 撤回离职申请 */
|
||||
resignationWithdraw: (id: string) =>
|
||||
portalPost(`/resignation/${id}/withdraw`).then(unwrap<any>()),
|
||||
/** 下载离职证明 */
|
||||
downloadCertificate: (id: string) =>
|
||||
portalGet(`/resignation/${id}/certificate`, { responseType: 'blob' }) as any,
|
||||
/** 我的休假申请列表 */
|
||||
myLeaves: () =>
|
||||
portalGet('/leaves').then(unwrap<any[]>()),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { PenTool, Plus, X, RefreshCw, ExternalLink, FileText, AlertCircle } from 'lucide-react'
|
||||
import { esignApi, rosterApi } from '../lib/api-services'
|
||||
import { esignApi, employeeApi } from '../lib/api-services'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
@@ -45,10 +45,10 @@ export default function ESign() {
|
||||
},
|
||||
})
|
||||
|
||||
const { data: rosterData } = useQuery<any>({
|
||||
queryKey: ['roster-for-esign'],
|
||||
const { data: rosterData = [] } = useQuery<any[]>({
|
||||
queryKey: ['employees-for-esign'],
|
||||
queryFn: async () => {
|
||||
return await rosterApi.list({ search: '', page: 1, pageSize: 200 } as any) as any
|
||||
return await employeeApi.allLite({ status: 'ACTIVE' })
|
||||
},
|
||||
enabled: showCreate,
|
||||
})
|
||||
@@ -224,7 +224,7 @@ export default function ESign() {
|
||||
onChange={(e) => setFormData({ ...formData, employeeId: e.target.value })}
|
||||
>
|
||||
<option value="">请选择员工</option>
|
||||
{rosterData?.items?.filter((e: any) => e.status === 'ACTIVE').map((emp: any) => (
|
||||
{rosterData.map((emp: any) => (
|
||||
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -230,7 +230,7 @@ export default function EmployeeBenefits() {
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium">参保人员({enrollments.length}人)</h3>
|
||||
<Button size="sm" variant="secondary" onClick={() => { setEnrollEmployeeIds([]); setShowEnrollModal(true) }}>
|
||||
<Users className="w-3.5 h-3.5 mr-1" />批量参保
|
||||
<Users className="w-3.5 h-3.5 mr-1" />为员工添加福利
|
||||
</Button>
|
||||
</div>
|
||||
{enrollLoading ? (
|
||||
@@ -393,7 +393,7 @@ export default function EmployeeBenefits() {
|
||||
|
||||
{/* 批量参保 Modal */}
|
||||
{showEnrollModal && (
|
||||
<Modal open={true} onClose={() => setShowEnrollModal(false)} title="批量参保" size="lg">
|
||||
<Modal open={true} onClose={() => setShowEnrollModal(false)} title="为员工添加福利" size="lg">
|
||||
<div className="space-y-3">
|
||||
<InlineAlert type="info">
|
||||
选择需要参保的员工,设置生效月份后点击「确认参保」。
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function Evidence() {
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{verifyResult?.invalid === 0
|
||||
? `全部 ${verifyResult?.total || 0} 条证据链验证通过,数据完整无篡改`
|
||||
? `全部 ${verifyResult?.total || 0} 条证据链验证通过,数据完整无篡改${verifyResult?.repaired ? `(自动修复 ${verifyResult.repaired} 条历史数据)` : ''}`
|
||||
: `${verifyResult?.valid || 0} 条通过,${verifyResult?.invalid || 0} 条异常,请检查`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { usePageSize } from '../hooks/usePageSize'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { FileText, Plus, ChevronRight, CheckCircle, Clock, X, Bell } from 'lucide-react'
|
||||
import { FileText, Plus, ChevronRight, CheckCircle, Clock, X, Bell, Upload } from 'lucide-react'
|
||||
import mammoth from 'mammoth'
|
||||
import { policiesApi } from '../lib/api-services'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
@@ -201,6 +202,7 @@ function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSucc
|
||||
const [title, setTitle] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [type, setType] = useState('RULES')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => policiesApi.create({ title, content, type }),
|
||||
@@ -230,7 +232,32 @@ function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSucc
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">制度内容</label>
|
||||
<textarea value={content} onChange={e => setContent(e.target.value)} rows={8} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="输入制度正文..." />
|
||||
<div className="flex items-center gap-2 mt-1 mb-1">
|
||||
<Button size="sm" variant="secondary" className="!h-7" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload className="w-3.5 h-3.5 mr-1" />导入 Word 文档
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".docx"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const result = await mammoth.convertToHtml({ arrayBuffer })
|
||||
setContent(result.value)
|
||||
toast.success('文档导入成功')
|
||||
} catch {
|
||||
toast.error('文档解析失败,请确保为 .docx 格式')
|
||||
}
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-gray-400">支持 .docx 格式,导入后转为 HTML</span>
|
||||
</div>
|
||||
<textarea value={content} onChange={e => setContent(e.target.value)} rows={8} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="输入制度正文或导入 Word 文档..." />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={onClose}>取消</Button>
|
||||
|
||||
@@ -111,9 +111,16 @@ export default function Roster() {
|
||||
})
|
||||
const pagination = rosterData?.pagination || { page, pageSize, total: 0, totalPages: 0 }
|
||||
|
||||
// 从 URL query 参数自动定位员工(从待办跳转时)
|
||||
// 从 URL query 参数自动定位员工(从待办跳转或绩效/培训列表跳转时)
|
||||
const employeeParam = searchParams.get('employee')
|
||||
const employeeIdParam = searchParams.get('employeeId')
|
||||
useEffect(() => {
|
||||
// 优先按 employeeId 直接定位
|
||||
if (employeeIdParam && !selectedId) {
|
||||
setSelectedId(employeeIdParam)
|
||||
setSearchParams({}, { replace: true })
|
||||
return
|
||||
}
|
||||
if (!employeeParam) return
|
||||
setSearch(employeeParam)
|
||||
// 只有当 debouncedSearch 与参数一致时,搜索结果才真正匹配
|
||||
@@ -122,7 +129,7 @@ export default function Roster() {
|
||||
// 清除 URL 参数,避免后续搜索时重复触发
|
||||
setSearchParams({}, { replace: true })
|
||||
}
|
||||
}, [employeeParam, debouncedSearch, isLoading, employees, selectedId, setSearchParams])
|
||||
}, [employeeParam, employeeIdParam, debouncedSearch, isLoading, employees, selectedId, setSearchParams])
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (data: any) => employeeApi.create(data),
|
||||
|
||||
@@ -11,6 +11,7 @@ import Button from '../components/ui/Button'
|
||||
import { Input, Label } from '../components/ui/Input'
|
||||
import { MonthlyRow, MonthlyHousingRow } from './social-insurance/MonthlyRows'
|
||||
import SpecialDeductionTab from './social-insurance/SpecialDeductionTab'
|
||||
import EmployeeEnrollmentTab from './social-insurance/EmployeeEnrollmentTab'
|
||||
|
||||
// 金额格式化:保留两位小数 + 千分位
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
@@ -18,7 +19,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
|
||||
export default function SocialInsurance() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction'>('monthly')
|
||||
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment'>('monthly')
|
||||
const [city, setCity] = useState<string>('北京')
|
||||
const [showAddCity, setShowAddCity] = useState(false)
|
||||
const [newCityName, setNewCityName] = useState('')
|
||||
@@ -374,7 +375,7 @@ export default function SocialInsurance() {
|
||||
|
||||
{/* Tab 切换 + 城市选择 */}
|
||||
<div className="flex items-center gap-4 border-b">
|
||||
{(['monthly', 'social', 'housing', 'deduction'] as const).map((t) => (
|
||||
{(['monthly', 'social', 'housing', 'enrollment', 'deduction'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
@@ -382,7 +383,7 @@ export default function SocialInsurance() {
|
||||
}`}
|
||||
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null); setMonthlyProcessed(false); setProcessStatus(null) }}
|
||||
>
|
||||
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : '专项附加扣除'}
|
||||
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : t === 'enrollment' ? '员工参保' : '专项附加扣除'}
|
||||
</button>
|
||||
))}
|
||||
{(tab === 'social' || tab === 'housing') && (
|
||||
@@ -1150,6 +1151,11 @@ export default function SocialInsurance() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== 员工参保信息 Tab ========== */}
|
||||
{tab === 'enrollment' && (
|
||||
<EmployeeEnrollmentTab />
|
||||
)}
|
||||
|
||||
{/* ========== 专项附加扣除 Tab ========== */}
|
||||
{tab === 'deduction' && (
|
||||
<SpecialDeductionTab month={deductionMonth} setMonth={setDeductionMonth} />
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usePageSize } from '../hooks/usePageSize'
|
||||
import { Search, Plus, Edit2, Trash2, AlertTriangle, Clock, Baby, HeartPulse, Activity, X } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Search, Plus, Edit2, Trash2, AlertTriangle, Clock, Baby, HeartPulse, Activity, X, Eye } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { specialStatusApi, employeeApi } from '../lib/api-services'
|
||||
import { Input, Select, Label } from '../components/ui/Input'
|
||||
@@ -351,6 +352,9 @@ export default function SpecialStatus() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Link to={`/roster?employeeId=${item.employee.id}`} className="p-1 rounded text-gray-400 hover:text-primary hover:bg-gray-100" title="查看员工详情">
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
<button onClick={() => handleOpenEdit(item)} className="p-1 rounded text-gray-400 hover:text-primary hover:bg-gray-100" title="编辑">
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -362,7 +366,7 @@ export default function SpecialStatus() {
|
||||
|
||||
{/* 员工信息 */}
|
||||
<div className="mb-3">
|
||||
<span className="font-medium text-gray-900">{item.employee.name}</span>
|
||||
<Link to={`/roster?employeeId=${item.employee.id}`} className="font-medium text-gray-900 hover:text-primary hover:underline">{item.employee.name}</Link>
|
||||
<span className="text-sm text-gray-500 ml-2">{item.employee.department}</span>
|
||||
{item.employee.status === 'RESIGNED' && (
|
||||
<span className="ml-2 text-xs text-gray-400">已离职</span>
|
||||
|
||||
@@ -142,25 +142,21 @@ function SystemTemplates() {
|
||||
toast.success('已复制到剪贴板')
|
||||
}
|
||||
|
||||
const handleDownloadWord = async () => {
|
||||
const handleDownloadWord = () => {
|
||||
if (!selected) return
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||||
const res = await fetch(`${baseURL}/templates/${selected.id}/download`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${selected.name}.doc`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('已下载 Word 文档')
|
||||
} catch {
|
||||
toast.error('下载失败')
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||||
const query = new URLSearchParams()
|
||||
query.set('token', token || '')
|
||||
for (const [k, v] of Object.entries(variables)) {
|
||||
if (v) query.append(k, v)
|
||||
}
|
||||
const a = document.createElement('a')
|
||||
a.href = `${baseURL}/templates/${selected.id}/download?${query.toString()}`
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
const handleCopyAsNew = () => {
|
||||
@@ -373,25 +369,21 @@ function EnterpriseTemplates() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadWord = async () => {
|
||||
const handleDownloadWord = () => {
|
||||
if (!selected) return
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||||
const res = await fetch(`${baseURL}/enterprise-templates/${selected.id}/download`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${selected.name}.doc`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('已下载')
|
||||
} catch {
|
||||
toast.error('下载失败')
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||||
const query = new URLSearchParams()
|
||||
query.set('token', token || '')
|
||||
for (const [k, v] of Object.entries(variables)) {
|
||||
if (v) query.append(k, v)
|
||||
}
|
||||
const a = document.createElement('a')
|
||||
a.href = `${baseURL}/enterprise-templates/${selected.id}/download?${query.toString()}`
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
const handleEdit = (item: any) => {
|
||||
|
||||
@@ -482,6 +482,9 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.error?.message || '保存失败,请重试')
|
||||
},
|
||||
})
|
||||
|
||||
const removeEmployeeMutation = useMutation({
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { UserX, Clock, FileText, Camera, X, Image as ImageIcon } from 'lucide-react'
|
||||
import { UserX, Clock, FileText, Camera, X, Image as ImageIcon, Download } from 'lucide-react'
|
||||
import { portalApi } from '../../lib/api-services'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
@@ -205,6 +205,7 @@ export default function ResignationApply() {
|
||||
{records.map((r: any) => {
|
||||
const statusCfg = STATUS_MAP[r.status] || STATUS_MAP.DRAFT
|
||||
const canWithdraw = r.status === 'DRAFT' || r.status === 'PENDING_APPROVAL'
|
||||
const canDownload = r.status === 'COMPLETED'
|
||||
return (
|
||||
<div key={r.id} className="p-3 rounded-lg border border-gray-100">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@@ -223,6 +224,29 @@ export default function ResignationApply() {
|
||||
<div className="text-xs text-gray-500 mt-1">{r.remark}</div>
|
||||
)}
|
||||
</div>
|
||||
{canDownload && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-50">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const blob = await portalApi.downloadCertificate(r.id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `离职证明.doc`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
toast.error('下载失败')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-1" />下载离职证明
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{canWithdraw && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-50">
|
||||
<Button
|
||||
|
||||
@@ -106,19 +106,20 @@ export default function DisciplinaryRecords() {
|
||||
<th className="pb-2 pr-4 font-medium">描述</th>
|
||||
<th className="pb-2 pr-4 font-medium">严重程度</th>
|
||||
<th className="pb-2 pr-4 font-medium">处理</th>
|
||||
<th className="pb-2 pr-4 font-medium">执行细节</th>
|
||||
<th className="pb-2 pr-4 font-medium">签字</th>
|
||||
<th className="pb-2 pr-4 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr><td colSpan={9} className="py-8 text-center text-gray-400">加载中...</td></tr>
|
||||
<tr><td colSpan={10} className="py-8 text-center text-gray-400">加载中...</td></tr>
|
||||
) : records.length === 0 ? (
|
||||
<tr><td colSpan={9} className="py-8 text-center text-gray-400">暂无违纪记录</td></tr>
|
||||
<tr><td colSpan={10} className="py-8 text-center text-gray-400">暂无违纪记录</td></tr>
|
||||
) : records.map((r: any) => (
|
||||
<tr key={r.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-2 pr-4">
|
||||
<Link to={`/roster/${r.employeeId}`} className="text-primary hover:underline">{r.employee?.name}</Link>
|
||||
<Link to={`/roster?employeeId=${r.employeeId}`} className="text-primary hover:underline">{r.employee?.name}</Link>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{r.employee?.department || '-'}</td>
|
||||
<td className="py-2 pr-4">{fmtDate(r.violationDate)}</td>
|
||||
@@ -130,6 +131,7 @@ export default function DisciplinaryRecords() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{ACTION_LABELS[r.action] || r.action}</td>
|
||||
<td className="py-2 pr-4 max-w-xs truncate text-gray-500 text-xs" title={r.actionDetail}>{r.actionDetail || '-'}</td>
|
||||
<td className="py-2 pr-4">
|
||||
{r.employeeAck ? (
|
||||
<span className="text-xs text-green-600">已签字</span>
|
||||
@@ -284,8 +286,8 @@ function DisciplinaryForm({ employees, record, onSubmit, onClose }: {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>处理详情</Label>
|
||||
<Input value={form.actionDetail} onChange={(e) => setForm({ ...form, actionDetail: e.target.value })} placeholder="处理详情(选填)" />
|
||||
<Label>处罚执行细节</Label>
|
||||
<textarea value={form.actionDetail} onChange={(e) => setForm({ ...form, actionDetail: e.target.value })} rows={3} className="w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="详细说明处罚执行情况,如扣款金额、书面警告文号、降职后岗位等" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>见证人</Label>
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function PerformanceRecords() {
|
||||
) : records.map((r: any) => (
|
||||
<tr key={r.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-2 pr-4">
|
||||
<Link to={`/roster/${r.employeeId}`} className="text-primary hover:underline">{r.employee?.name}</Link>
|
||||
<Link to={`/roster?employeeId=${r.employeeId}`} className="text-primary hover:underline">{r.employee?.name}</Link>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{r.employee?.department || '-'}</td>
|
||||
<td className="py-2 pr-4">{r.period}</td>
|
||||
@@ -198,10 +198,19 @@ function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
|
||||
onSubmit: (data: any) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const detectPeriodType = (period: string, fallback?: string): string => {
|
||||
if (/^\d{4}-Q[1-4]$/.test(period)) return 'QUARTERLY'
|
||||
if (/^\d{4}$/.test(period)) return 'YEARLY'
|
||||
if (/^\d{4}-\d{2}$/.test(period)) return 'MONTHLY'
|
||||
return fallback || 'MONTHLY'
|
||||
}
|
||||
|
||||
const initialPeriodType = record ? detectPeriodType(record.period, record.periodType) : 'MONTHLY'
|
||||
|
||||
const [form, setForm] = useState({
|
||||
employeeId: record?.employeeId || '',
|
||||
period: record?.period || new Date().toISOString().slice(0, 7),
|
||||
periodType: record?.periodType || 'MONTHLY',
|
||||
periodType: initialPeriodType,
|
||||
score: record?.score || 80,
|
||||
grade: record?.grade || 'B',
|
||||
result: record?.result || 'QUALIFIED',
|
||||
@@ -260,16 +269,22 @@ function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
|
||||
<button onClick={onClose}><X className="w-4 h-4 text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{!record && (
|
||||
<div>
|
||||
<Label>考核类型</Label>
|
||||
<Select value={form.periodType} onChange={(e) => setForm({ ...form, periodType: e.target.value })}>
|
||||
<option value="MONTHLY">月度考核</option>
|
||||
<option value="QUARTERLY">季度考核</option>
|
||||
<option value="YEARLY">年度考核</option>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label>考核类型</Label>
|
||||
<Select value={form.periodType} onChange={(e) => {
|
||||
const newType = e.target.value
|
||||
let newPeriod = form.period
|
||||
const year = form.period.slice(0, 4) || new Date().toISOString().slice(0, 4)
|
||||
if (newType === 'MONTHLY') newPeriod = year + '-' + (new Date().getMonth() + 1).toString().padStart(2, '0')
|
||||
else if (newType === 'QUARTERLY') newPeriod = year + '-Q1'
|
||||
else if (newType === 'YEARLY') newPeriod = year
|
||||
setForm({ ...form, periodType: newType, period: newPeriod })
|
||||
}}>
|
||||
<option value="MONTHLY">月度考核</option>
|
||||
<option value="QUARTERLY">季度考核</option>
|
||||
<option value="YEARLY">年度考核</option>
|
||||
</Select>
|
||||
</div>
|
||||
{!record && (
|
||||
<div>
|
||||
<Label>员工</Label>
|
||||
@@ -283,7 +298,12 @@ function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
|
||||
)}
|
||||
<div>
|
||||
<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} />
|
||||
<Input
|
||||
type={form.periodType === 'YEARLY' ? 'number' : form.periodType === 'QUARTERLY' ? 'text' : 'month'}
|
||||
value={form.period}
|
||||
onChange={(e) => setForm({ ...form, period: e.target.value })}
|
||||
placeholder={form.periodType === 'YEARLY' ? '如 2026' : form.periodType === 'QUARTERLY' ? '如 2026-Q1' : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>绩效模板(选填)</Label>
|
||||
@@ -351,8 +371,8 @@ function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>考评人</Label>
|
||||
<Input value={form.reviewer} onChange={(e) => setForm({ ...form, reviewer: e.target.value })} placeholder="考评人姓名" />
|
||||
<Label>考评人 *</Label>
|
||||
<Input value={form.reviewer} onChange={(e) => setForm({ ...form, reviewer: e.target.value })} placeholder="请输入考评人姓名" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>评语</Label>
|
||||
@@ -376,7 +396,7 @@ function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" size="sm" onClick={onClose}>取消</Button>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!form.employeeId || !form.period}>保存</Button>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!form.employeeId || !form.period || !form.reviewer}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -154,7 +154,7 @@ export default function TrainingRecords() {
|
||||
) : records.map((r: any) => (
|
||||
<tr key={r.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-2 pr-4">
|
||||
<Link to={`/roster/${r.employeeId}`} className="text-primary hover:underline">{r.employee?.name}</Link>
|
||||
<Link to={`/roster?employeeId=${r.employeeId}`} className="text-primary hover:underline">{r.employee?.name}</Link>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{r.employee?.department || '-'}</td>
|
||||
<td className="py-2 pr-4">{fmtDate(r.trainingDate)}</td>
|
||||
|
||||
@@ -2,12 +2,13 @@ import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import { Plus, Settings as SettingsIcon, X, Shield } from 'lucide-react'
|
||||
import { commercialInsuranceApi } from '../../lib/api-services'
|
||||
import { Plus, Settings as SettingsIcon, X, Shield, UserPlus } from 'lucide-react'
|
||||
import { commercialInsuranceApi, employeeApi } from '../../lib/api-services'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
import { Input, Label } from '../../components/ui/Input'
|
||||
import { InlineAlert } from '../../components/ui/InlineAlert'
|
||||
import Modal from '../../components/ui/Modal'
|
||||
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
@@ -36,6 +37,9 @@ export default function CommercialInsuranceTab() {
|
||||
const [editingPlan, setEditingPlan] = useState<any>(null)
|
||||
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null)
|
||||
const [newPlan, setNewPlan] = useState<any>({ ...DEFAULT_PLAN })
|
||||
const [showEnrollModal, setShowEnrollModal] = useState(false)
|
||||
const [enrollEmployeeIds, setEnrollEmployeeIds] = useState<string[]>([])
|
||||
const [enrollEffectiveFrom, setEnrollEffectiveFrom] = useState(new Date().toISOString().slice(0, 10))
|
||||
|
||||
const { data: plans = [], isLoading } = useQuery<any[]>({
|
||||
queryKey: ['commercial-insurance-plans'],
|
||||
@@ -79,6 +83,27 @@ export default function CommercialInsuranceTab() {
|
||||
},
|
||||
})
|
||||
|
||||
const { data: rosterData } = useQuery<any[]>({
|
||||
queryKey: ['employees-for-commercial-insurance'],
|
||||
queryFn: async () => {
|
||||
return await employeeApi.allLite({ status: 'ACTIVE' })
|
||||
},
|
||||
enabled: showEnrollModal,
|
||||
})
|
||||
|
||||
const enrollMutation = useMutation({
|
||||
mutationFn: async (data: { employeeIds: string[]; effectiveFrom?: string }) =>
|
||||
commercialInsuranceApi.enroll(selectedPlanId!, data) as any,
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['commercial-insurance-enrollments'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['commercial-insurance-employee-summary'] })
|
||||
setShowEnrollModal(false)
|
||||
setEnrollEmployeeIds([])
|
||||
toast.success(`已添加 ${res.data?.enrolled || 0} 名员工`)
|
||||
},
|
||||
onError: () => toast.error('参保失败'),
|
||||
})
|
||||
|
||||
const handleEdit = (plan: any) => {
|
||||
setEditingPlan(plan)
|
||||
setNewPlan({ ...plan })
|
||||
@@ -157,7 +182,12 @@ export default function CommercialInsuranceTab() {
|
||||
|
||||
{selectedPlanId && (
|
||||
<Card>
|
||||
<h3 className="text-sm font-medium mb-3">参保人员({enrollments.length}人)</h3>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium">参保人员({enrollments.length}人)</h3>
|
||||
<Button size="sm" onClick={() => { setEnrollEmployeeIds([]); setEnrollEffectiveFrom(new Date().toISOString().slice(0, 10)); setShowEnrollModal(true) }}>
|
||||
<UserPlus className="w-4 h-4 mr-1" />为员工参保
|
||||
</Button>
|
||||
</div>
|
||||
{enrollLoading ? (
|
||||
<div className="text-center py-4 text-gray-400 text-sm">加载中...</div>
|
||||
) : enrollments.length === 0 ? (
|
||||
@@ -197,6 +227,70 @@ export default function CommercialInsuranceTab() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 批量参保 Modal */}
|
||||
{showEnrollModal && (
|
||||
<Modal open={true} onClose={() => setShowEnrollModal(false)} title="批量参保" size="lg">
|
||||
<div className="space-y-3">
|
||||
<InlineAlert type="info">
|
||||
选择需要参保的员工,设置生效日期后点击「确认参保」。
|
||||
</InlineAlert>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="shrink-0">生效日期</Label>
|
||||
<Input type="date" value={enrollEffectiveFrom} onChange={(e) => setEnrollEffectiveFrom(e.target.value)} className="!w-40" />
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto border rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-white">
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 px-3 text-left w-8">
|
||||
<input type="checkbox" checked={enrollEmployeeIds.length === (rosterData?.length || 0) && enrollEmployeeIds.length > 0}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setEnrollEmployeeIds(rosterData?.map((emp: any) => emp.id) || [])
|
||||
} else {
|
||||
setEnrollEmployeeIds([])
|
||||
}
|
||||
}} />
|
||||
</th>
|
||||
<th className="py-2 px-3 text-left">姓名</th>
|
||||
<th className="py-2 px-3 text-left">部门</th>
|
||||
<th className="py-2 px-3 text-left">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rosterData?.map((emp: any) => (
|
||||
<tr key={emp.id} className="border-b last:border-0 hover:bg-gray-50">
|
||||
<td className="py-2 px-3">
|
||||
<input type="checkbox" checked={enrollEmployeeIds.includes(emp.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) setEnrollEmployeeIds([...enrollEmployeeIds, emp.id])
|
||||
else setEnrollEmployeeIds(enrollEmployeeIds.filter((id) => id !== emp.id))
|
||||
}} />
|
||||
</td>
|
||||
<td className="py-2 px-3 font-medium">{emp.name}</td>
|
||||
<td className="py-2 px-3 text-gray-500">{emp.department}</td>
|
||||
<td className="py-2 px-3">
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe">在职</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400">已选 {enrollEmployeeIds.length} 人</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowEnrollModal(false)}>取消</Button>
|
||||
<Button size="sm" onClick={() => enrollMutation.mutate({ employeeIds: enrollEmployeeIds, effectiveFrom: enrollEffectiveFrom })}
|
||||
disabled={enrollEmployeeIds.length === 0 || enrollMutation.isPending}>
|
||||
{enrollMutation.isPending ? '参保中...' : '确认参保'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showAddPlan && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={() => setShowAddPlan(false)}>
|
||||
<Card className="w-full max-w-lg mx-4" >
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Search, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { socialInsuranceApi } from '../../lib/api-services'
|
||||
import { Input } from '../../components/ui/Input'
|
||||
import { useDebouncedValue } from '../../hooks/useDebouncedValue'
|
||||
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
export default function EmployeeEnrollmentTab() {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const debouncedSearch = useDebouncedValue(keyword, 300)
|
||||
|
||||
const { data: list = [], isLoading } = useQuery<any[]>({
|
||||
queryKey: ['social-employee-enrollment', debouncedSearch],
|
||||
queryFn: () => socialInsuranceApi.employeeEnrollment(debouncedSearch || undefined),
|
||||
})
|
||||
|
||||
const insuredCount = list.filter(e => e.socialInsStatus === 'INSURED').length
|
||||
const housingCount = list.filter(e => e.housingFundStatus === 'INSURED').length
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-gray-500">共 {list.length} 人</span>
|
||||
<span className="text-gray-500">社保参保 <span className="font-medium text-primary">{insuredCount}</span></span>
|
||||
<span className="text-gray-500">公积金参保 <span className="font-medium text-primary">{housingCount}</span></span>
|
||||
</div>
|
||||
<div className="relative w-48">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="搜索员工姓名"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs text-gray-500">
|
||||
<th className="pb-2 pr-4 font-medium">姓名</th>
|
||||
<th className="pb-2 pr-4 font-medium">部门</th>
|
||||
<th className="pb-2 pr-4 font-medium">职务</th>
|
||||
<th className="pb-2 pr-4 font-medium">社保状态</th>
|
||||
<th className="pb-2 pr-4 font-medium">社保基数</th>
|
||||
<th className="pb-2 pr-4 font-medium">社保城市</th>
|
||||
<th className="pb-2 pr-4 font-medium">社保起缴</th>
|
||||
<th className="pb-2 pr-4 font-medium">公积金状态</th>
|
||||
<th className="pb-2 pr-4 font-medium">公积金基数</th>
|
||||
<th className="pb-2 pr-4 font-medium">公积金城市</th>
|
||||
<th className="pb-2 pr-4 font-medium">公积金起缴</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr><td colSpan={11} className="py-8 text-center text-gray-400">加载中...</td></tr>
|
||||
) : list.length === 0 ? (
|
||||
<tr><td colSpan={11} className="py-8 text-center text-gray-400">暂无员工参保信息</td></tr>
|
||||
) : list.map((emp: any) => (
|
||||
<tr key={emp.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-2 pr-4 font-medium">{emp.name}</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{emp.department || '-'}</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{emp.position || '-'}</td>
|
||||
<td className="py-2 pr-4">
|
||||
{emp.socialInsStatus === 'INSURED' ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-green-600">
|
||||
<CheckCircle className="w-3.5 h-3.5" />已参保
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-gray-400">
|
||||
<XCircle className="w-3.5 h-3.5" />未参保
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4">{emp.socialInsBase ? `¥${fmt(emp.socialInsBase)}` : '-'}</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{emp.socialInsCity || '-'}</td>
|
||||
<td className="py-2 pr-4 text-gray-500 text-xs">{emp.socialInsStart || '-'}</td>
|
||||
<td className="py-2 pr-4">
|
||||
{emp.housingFundStatus === 'INSURED' ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-green-600">
|
||||
<CheckCircle className="w-3.5 h-3.5" />已参保
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-gray-400">
|
||||
<XCircle className="w-3.5 h-3.5" />未参保
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4">{emp.housingFundBase ? `¥${fmt(emp.housingFundBase)}` : '-'}</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{emp.housingFundCity || '-'}</td>
|
||||
<td className="py-2 pr-4 text-gray-500 text-xs">{emp.housingFundStart || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user