feat: 完成23项系统优化 - 花名册社保状态列/身份证复制/附件类型扩展, 用工办理姓名检索/直接提交/文书查看/批量证明, 风险中心跳转筛选+批量处理, 日历7/15/35天分组+逾期统计, 考勤单条编辑+按人导出, 工资条查看状态+工资流水导出, 辞职申请附件上传, 交接清单PDF下载, 操作完成下一步引导, 休假审批入口, 人效成本分部门
This commit is contained in:
+150
-51
@@ -1,9 +1,10 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import { usePageSize } from '../hooks/usePageSize'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Wallet, Download } from 'lucide-react'
|
||||
import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Wallet, Download, Phone, MapPin, Search, Settings2 } from 'lucide-react'
|
||||
import { rosterApi, employeeApi, terminationApi } from '../lib/api-services'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useDebouncedValue } from '../hooks/useDebouncedValue'
|
||||
@@ -20,10 +21,45 @@ import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChan
|
||||
import { ImportSettings } from './Settings'
|
||||
import QueryError from '../components/ui/QueryError'
|
||||
|
||||
const ROSTER_COLUMNS = [
|
||||
{ key: 'department', label: '部门' },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'hireDate', label: '入职日期' },
|
||||
{ key: 'position', label: '职务' },
|
||||
{ key: 'phone', label: '手机号' },
|
||||
{ key: 'gender', label: '性别' },
|
||||
{ key: 'contractType', label: '合同类型' },
|
||||
{ key: 'contractStatus', label: '合同状态' },
|
||||
{ key: 'contractExpiry', label: '合同到期' },
|
||||
{ key: 'socialStatus', label: '社保状态' },
|
||||
{ key: 'records', label: '记录' },
|
||||
] as const
|
||||
|
||||
const DEFAULT_VISIBLE = ['department', 'status', 'hireDate', 'position', 'phone', 'gender', 'contractType', 'contractStatus', 'contractExpiry', 'socialStatus']
|
||||
|
||||
function useRosterColumns() {
|
||||
const [visible, setVisible] = useState<string[]>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('roster-columns')
|
||||
return saved ? JSON.parse(saved) : DEFAULT_VISIBLE
|
||||
} catch { return DEFAULT_VISIBLE }
|
||||
})
|
||||
const toggle = (key: string) => {
|
||||
setVisible(prev => {
|
||||
const next = prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
||||
localStorage.setItem('roster-columns', JSON.stringify(next))
|
||||
return next
|
||||
})
|
||||
}
|
||||
const isVisible = (key: string) => visible.includes(key)
|
||||
return { isVisible, toggle, visible }
|
||||
}
|
||||
|
||||
export default function Roster() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebouncedValue(search, 300)
|
||||
@@ -37,8 +73,8 @@ export default function Roster() {
|
||||
const [salaryEmployee, setSalaryEmployee] = useState<any>(null)
|
||||
const [showDeptModal, setShowDeptModal] = useState(false)
|
||||
const [deptEmployee, setDeptEmployee] = useState<any>(null)
|
||||
const pageSize = usePageSize()
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [showBatchRenewModal, setShowBatchRenewModal] = useState(false)
|
||||
const [batchRenewYears, setBatchRenewYears] = useState(3)
|
||||
@@ -108,7 +144,9 @@ export default function Roster() {
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
toast.success('已创建离职草稿,请前往「解聘补偿」页面完成流程')
|
||||
toast.success('已创建离职草稿,请前往「解聘补偿」页面完成流程', {
|
||||
action: { label: '前往处理', onClick: () => navigate('/termination') },
|
||||
})
|
||||
setShowResignModal(false)
|
||||
setResignEmployee(null)
|
||||
},
|
||||
@@ -264,10 +302,14 @@ export default function Roster() {
|
||||
},
|
||||
})
|
||||
|
||||
const { isVisible: colVisible, toggle: colToggle } = useRosterColumns()
|
||||
const [showColSettings, setShowColSettings] = useState(false)
|
||||
|
||||
const filtered = employees?.filter((e: any) =>
|
||||
!search || e.name.includes(search) || e.department.includes(search) || (e.idCardMasked && e.idCardMasked.includes(search))
|
||||
) || []
|
||||
|
||||
// 通讯录视图数据
|
||||
if (selectedId) {
|
||||
return <EmployeeProfile employeeId={selectedId} onBack={() => setSelectedId(null)} />
|
||||
}
|
||||
@@ -275,7 +317,7 @@ export default function Roster() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageGuide>
|
||||
员工花名册统一管理所有员工档案信息,包括基本信息、合同、薪酬、社保等。支持搜索、筛选、导出。点击员工可查看详细档案。支持入职登记、离职办理、合同签订等操作。
|
||||
员工花名册统一管理所有员工档案信息,包括基本信息、合同、薪酬、社保等。支持搜索、筛选、导出。点击员工可查看详细档案。支持入职登记、离职办理、合同签订等操作。关联:入职后请前往「用工办理」签订合同;合同到期请前往「风险中心」处理;离职请前往「离职管理」计算补偿。
|
||||
</PageGuide>
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
@@ -355,6 +397,30 @@ export default function Roster() {
|
||||
}} className="h-9 shrink-0">
|
||||
<Download className="mr-1.5 h-4 w-4" />导出
|
||||
</Button>
|
||||
<div className="relative shrink-0">
|
||||
<Button variant="secondary" onClick={() => setShowColSettings(v => !v)} className="h-9">
|
||||
<Settings2 className="mr-1.5 h-4 w-4" />列设置
|
||||
</Button>
|
||||
{showColSettings && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setShowColSettings(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-lg border border-gray-200 bg-white shadow-lg py-2">
|
||||
<div className="px-3 pb-1 text-xs font-medium text-gray-400">显示列</div>
|
||||
{ROSTER_COLUMNS.map(col => (
|
||||
<label key={col.key} className="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-gray-50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={colVisible(col.key)}
|
||||
onChange={() => colToggle(col.key)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-gray-700">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -418,37 +484,26 @@ export default function Roster() {
|
||||
<Card><div className="py-12 text-center text-sm text-gray-400">暂无符合条件的员工</div></Card>
|
||||
) : (
|
||||
<Card className="overflow-hidden p-0">
|
||||
<div className="border-b border-gray-100 px-5">
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
pageSize={pagination.pageSize}
|
||||
total={pagination.total}
|
||||
onPageChange={(p) => setPage(p)}
|
||||
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1200px] text-sm">
|
||||
<table className="w-full min-w-[900px] text-sm">
|
||||
<thead className="bg-gray-50/90">
|
||||
<tr className="border-b border-gray-200 text-xs font-medium text-gray-500">
|
||||
<th className="px-4 py-3 text-left w-8">
|
||||
<input type="checkbox" checked={employees.length > 0 && selectedIds.size === employees.length} onChange={toggleSelectAll} />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left">姓名</th>
|
||||
<th className="px-4 py-3 text-left">身份证号</th>
|
||||
<th className="px-4 py-3 text-left">部门</th>
|
||||
<th className="px-4 py-3 text-left">状态</th>
|
||||
<th className="hidden px-4 py-3 text-left">入职日期</th>
|
||||
{colVisible('department') && <th className="px-4 py-3 text-left">部门</th>}
|
||||
{colVisible('status') && <th className="px-4 py-3 text-left">状态</th>}
|
||||
{colVisible('hireDate') && <th className="px-4 py-3 text-left">入职日期</th>}
|
||||
<th className="hidden px-4 py-3 text-left">离职日期</th>
|
||||
<th className="px-4 py-3 text-right">月薪</th>
|
||||
<th className="px-4 py-3 text-left">合同类型</th>
|
||||
<th className="px-4 py-3 text-left">合同状态</th>
|
||||
<th className="hidden px-4 py-3 text-left">合同到期</th>
|
||||
<th className="px-4 py-3 text-center">违纪</th>
|
||||
<th className="px-4 py-3 text-center">考勤</th>
|
||||
<th className="px-4 py-3 text-center">培训</th>
|
||||
<th className="px-4 py-3 text-center">绩效</th>
|
||||
<th className="px-4 py-3 text-center">工资条</th>
|
||||
{colVisible('position') && <th className="px-4 py-3 text-left">职务</th>}
|
||||
{colVisible('phone') && <th className="px-4 py-3 text-left">手机号</th>}
|
||||
{colVisible('gender') && <th className="px-4 py-3 text-center">性别</th>}
|
||||
{colVisible('contractType') && <th className="px-4 py-3 text-left">合同类型</th>}
|
||||
{colVisible('contractStatus') && <th className="px-4 py-3 text-left">合同状态</th>}
|
||||
{colVisible('contractExpiry') && <th className="px-4 py-3 text-left">合同到期</th>}
|
||||
{colVisible('socialStatus') && <th className="px-4 py-3 text-left">社保状态</th>}
|
||||
{colVisible('records') && <th className="px-4 py-3 text-center">记录</th>}
|
||||
<th className="px-4 py-3 text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -462,10 +517,17 @@ export default function Roster() {
|
||||
<td className="px-4 py-3" onClick={(ev) => ev.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.has(e.id)} onChange={() => toggleSelect(e.id)} />
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium">{e.name}</td>
|
||||
<td className="px-4 py-3 text-gray-500 text-xs font-mono">{e.idCardMasked || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{e.department}</td>
|
||||
<td className="px-4 py-3">
|
||||
<td className="px-4 py-3 font-medium">
|
||||
<div>{e.name}</div>
|
||||
<div className="text-gray-400 text-xs font-mono cursor-pointer hover:text-primary transition-colors" title="点击复制完整身份证号" onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
if (e.idCardNumber) {
|
||||
navigator.clipboard.writeText(e.idCardNumber).then(() => toast.success('已复制身份证号')).catch(() => toast.error('复制失败'))
|
||||
}
|
||||
}}>{e.idCardMasked || '—'}</div>
|
||||
</td>
|
||||
{colVisible('department') && <td className="px-4 py-3 text-gray-500">{e.department}</td>}
|
||||
{colVisible('status') && <td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
e.status === 'ACTIVE' ? 'bg-green-50 text-safe'
|
||||
: e.status === 'PRE_HIRE' ? 'bg-blue-50 text-blue-600'
|
||||
@@ -482,8 +544,8 @@ export default function Roster() {
|
||||
试用期{e.probationInfo.isExpiring ? `即将到期(${e.probationInfo.daysToConfirm}天)` : `剩${e.probationInfo.daysToConfirm}天`}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>
|
||||
</td>}
|
||||
{colVisible('hireDate') && <td className="px-4 py-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>}
|
||||
<td className="hidden px-4 py-3 text-gray-500">
|
||||
{e.hasTermination && e.latestTerminationDate && e.latestTerminationStatus !== 'CANCELLED' && e.latestTerminationStatus !== 'COMPLETED' ? (
|
||||
<span className={e.status === 'RESIGNED' ? 'text-gray-500' : 'text-amber-600'}>
|
||||
@@ -494,8 +556,10 @@ export default function Roster() {
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">¥{fmt(e.monthlySalary)}</td>
|
||||
<td className="px-4 py-3">
|
||||
{colVisible('position') && <td className="px-4 py-3 text-gray-500">{e.position || '—'}</td>}
|
||||
{colVisible('phone') && <td className="px-4 py-3 text-gray-500">{e.phone || '—'}</td>}
|
||||
{colVisible('gender') && <td className="px-4 py-3 text-center text-gray-500">{e.gender || '—'}</td>}
|
||||
{colVisible('contractType') && <td className="px-4 py-3">
|
||||
{(() => {
|
||||
const typeConfig: Record<string, { label: string; style: string }> = {
|
||||
FIXED: { label: '劳动合同-固定期', style: 'bg-blue-50 text-blue-700 border border-blue-200' },
|
||||
@@ -511,8 +575,8 @@ export default function Roster() {
|
||||
const cfg = typeConfig[ct] || typeConfig.UNSIGNED
|
||||
return <span className={`px-2 py-0.5 rounded text-xs ${cfg.style}`}>{cfg.label}</span>
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
</td>}
|
||||
{colVisible('contractStatus') && <td className="px-4 py-3">
|
||||
{(() => {
|
||||
const tagStyles: Record<string, string> = {
|
||||
expired: 'bg-red-50 text-danger',
|
||||
@@ -536,8 +600,8 @@ export default function Roster() {
|
||||
const text = statusTextMap[e.contractStatus] || '无合同'
|
||||
return <span className={`px-2 py-0.5 rounded text-xs ${style}`}>{text}</span>
|
||||
})()}
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 text-gray-500">
|
||||
</td>}
|
||||
{colVisible('contractExpiry') && <td className="px-4 py-3 text-gray-500">
|
||||
{(() => {
|
||||
const endDate = e.latestContract?.endDate
|
||||
if (!endDate) {
|
||||
@@ -556,16 +620,42 @@ export default function Roster() {
|
||||
if (diffDays <= 90) return <span className="text-amber-600 text-xs">{dateStr} ({diffDays}天)</span>
|
||||
return <span className="text-xs">{dateStr}</span>
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{e.counts?.disciplinaryRecords ? (
|
||||
<span className="text-danger font-medium">{e.counts.disciplinaryRecords}</span>
|
||||
) : <span className="text-gray-300">0</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.attendanceRecords || 0}</td>
|
||||
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.trainingRecords || 0}</td>
|
||||
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.performanceRecords || 0}</td>
|
||||
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.payslips || 0}</td>
|
||||
</td>}
|
||||
{colVisible('socialStatus') && <td className="px-4 py-3">
|
||||
{(() => {
|
||||
const status = e.socialInsuranceStatus
|
||||
if (!status) return <span className="text-gray-300 text-xs">—</span>
|
||||
const cfg: Record<string, { label: string; style: string }> = {
|
||||
ACTIVE: { label: '在保', style: 'bg-green-50 text-safe' },
|
||||
SUSPENDED: { label: '停保', style: 'bg-amber-50 text-amber-600' },
|
||||
UNINSURED: { label: '未参保', style: 'bg-red-50 text-danger' },
|
||||
PENDING: { label: '待办理', style: 'bg-blue-50 text-blue-600' },
|
||||
}
|
||||
const c = cfg[status] || { label: status, style: 'bg-gray-100 text-gray-500' }
|
||||
return <span className={`px-2 py-0.5 rounded text-xs ${c.style}`}>{c.label}</span>
|
||||
})()}
|
||||
</td>}
|
||||
{colVisible('records') && <td className="px-4 py-3 text-center">
|
||||
<div className="flex items-center justify-center gap-1 flex-wrap">
|
||||
{(() => {
|
||||
const items = [
|
||||
{ label: '违纪', count: e.counts?.disciplinaryRecords || 0, danger: true },
|
||||
{ label: '考勤', count: e.counts?.attendanceRecords || 0 },
|
||||
{ label: '培训', count: e.counts?.trainingRecords || 0 },
|
||||
{ label: '绩效', count: e.counts?.performanceRecords || 0 },
|
||||
{ label: '工资条', count: e.counts?.payslips || 0 },
|
||||
]
|
||||
return items.map((it, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`text-xs px-1.5 py-0.5 rounded ${it.count > 0 ? (it.danger ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-600') : 'text-gray-300'}`}
|
||||
>
|
||||
{it.label}{it.count}
|
||||
</span>
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
</td>}
|
||||
<td className="px-4 py-3 text-center">
|
||||
{e.status === 'ACTIVE' && (!e.hasTermination || e.latestTerminationStatus === 'CANCELLED' || e.latestTerminationStatus === 'COMPLETED') && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
@@ -589,7 +679,7 @@ export default function Roster() {
|
||||
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
window.location.hash = '#/money'
|
||||
navigate('/money')
|
||||
}}
|
||||
>
|
||||
<Wallet className="h-4 w-4" />
|
||||
@@ -667,6 +757,15 @@ export default function Roster() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="border-t border-gray-100 px-5 py-3">
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
pageSize={pagination.pageSize}
|
||||
total={pagination.total}
|
||||
onPageChange={(p) => setPage(p)}
|
||||
onPageSizeChange={() => setPage(1)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user