feat: 花名册增加职务筛选 + 去掉解聘页新建按钮

- 后端:roster list 接口增加 position 筛选参数
- 后端:新增 /roster/positions 接口返回在职员工职务去重列表
- 前端:花名册筛选栏增加"全部职务"下拉框
- 前端:导出也支持 position 参数
- 前端:去掉解聘补偿页面的"新建解聘"按钮,解聘统一从花名册发起
- 前端:空状态提示改为"请在花名册中选择员工发起解聘"

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-15 15:22:06 +08:00
parent 81e1cd0f72
commit 5c3f3503ea
3 changed files with 44 additions and 9 deletions
+19
View File
@@ -46,6 +46,21 @@ router.get('/departments', authMiddleware, async (req: AuthRequest, res, next) =
}
})
/** 花名册可选职务列表(从在职员工中提取去重) */
router.get('/positions', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const employees = await prisma.employee.findMany({
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
select: { position: true },
distinct: 'position',
})
const positions = employees.map((e) => e.position).filter(Boolean).sort()
res.json({ success: true, data: positions })
} catch (err) {
next(err)
}
})
// 花名册列表(含汇总信息,支持分页和过滤)
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
@@ -55,6 +70,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
const status = req.query.status as string // ACTIVE | PRE_HIRE | RESIGNED
const contractStatus = req.query.contractStatus as string // active | expiring | expired | unsigned | etc.
const department = req.query.department as string
const position = req.query.position as string
const skip = (page - 1) * pageSize
// 使用本地日期午夜,避免时区问题导致当天入职被误判为预入职
@@ -71,6 +87,9 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
if (department) {
whereBase.department = department
}
if (position) {
whereBase.position = position
}
if (search && !isIdCardSearch) {
whereBase.OR = [
{ name: { contains: search } },
+24 -3
View File
@@ -7,6 +7,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Download, Phone, MapPin, Search, Settings2, CheckCircle, FileText } from 'lucide-react'
import { rosterApi, employeeApi, terminationApi, workProcessApi } from '../lib/api-services'
import api from '../lib/api'
import { copyToClipboard } from '../lib/clipboard'
import { useAuthStore } from '../store/authStore'
import { useDebouncedValue } from '../hooks/useDebouncedValue'
@@ -98,13 +99,14 @@ export default function Roster() {
const [filterStatus, setFilterStatus] = useState('')
const [filterContractStatus, setFilterContractStatus] = useState('')
const [filterDepartment, setFilterDepartment] = useState('')
const [filterPosition, setFilterPosition] = useState('')
const [showBatchTerminateModal, setShowBatchTerminateModal] = useState(false)
const [batchTerminateDate, setBatchTerminateDate] = useState(() => new Date().toISOString().slice(0, 10))
const [batchTerminateReason, setBatchTerminateReason] = useState('NEGOTIATED')
const [terminatePreviewData, setTerminatePreviewData] = useState<any>(null)
const { data: rosterData, isLoading, isError, error, refetch } = useQuery<any>({
queryKey: ['roster', page, pageSize, debouncedSearch, filterStatus, filterContractStatus, filterDepartment],
queryKey: ['roster', page, pageSize, debouncedSearch, filterStatus, filterContractStatus, filterDepartment, filterPosition],
queryFn: async () => {
const params: any = { page, pageSize }
if (debouncedSearch) params.search = debouncedSearch
@@ -112,7 +114,8 @@ export default function Roster() {
if (filterStatus === 'PROBATION') params.status = 'ACTIVE'
if (filterContractStatus) params.contractStatus = filterContractStatus
if (filterDepartment) params.department = filterDepartment
const res = await rosterApi.list({ search: debouncedSearch, page, pageSize, status: filterStatus === 'PROBATION' ? 'ACTIVE' : filterStatus || undefined, contractStatus: filterContractStatus || undefined, department: filterDepartment || undefined } as any) as any
if (filterPosition) params.position = filterPosition
const res = await rosterApi.list({ search: debouncedSearch, page, pageSize, status: filterStatus === 'PROBATION' ? 'ACTIVE' : filterStatus || undefined, contractStatus: filterContractStatus || undefined, department: filterDepartment || undefined, position: filterPosition || undefined } as any) as any
return res
},
})
@@ -425,10 +428,11 @@ export default function Roster() {
setFilterStatus('')
setFilterContractStatus('')
setFilterDepartment('')
setFilterPosition('')
setPage(1)
}
const hasActiveFilters = search || filterStatus || filterContractStatus || filterDepartment
const hasActiveFilters = search || filterStatus || filterContractStatus || filterDepartment || filterPosition
/** 全局合同风险统计(来自后端,与风险中心同口径) */
const globalRiskStats = rosterData?.globalRiskStats
@@ -457,6 +461,14 @@ export default function Roster() {
},
})
const { data: positionList } = useQuery<string[]>({
queryKey: ['roster-positions'],
queryFn: async () => {
const res = await api.get('/roster/positions')
return res.data as string[]
},
})
const { isVisible: colVisible, toggle: colToggle } = useRosterColumns()
const [showColSettings, setShowColSettings] = useState(false)
@@ -521,6 +533,14 @@ export default function Roster() {
<option value=""></option>
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
<select
value={filterPosition}
onChange={(e) => { setFilterPosition(e.target.value); setPage(1) }}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
>
<option value=""></option>
{positionList?.map((p: string) => <option key={p} value={p}>{p}</option>)}
</select>
{hasActiveFilters && (
<button onClick={clearFilters} className="h-9 px-2 text-sm text-gray-400 transition hover:text-gray-700"></button>
)}
@@ -536,6 +556,7 @@ export default function Roster() {
if (debouncedSearch) params.set('search', debouncedSearch)
if (filterStatus) params.set('status', filterStatus)
if (filterDepartment) params.set('department', filterDepartment)
if (filterPosition) params.set('position', filterPosition)
if (filterContractStatus) params.set('contractStatus', filterContractStatus)
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
+1 -6
View File
@@ -803,11 +803,6 @@ export default function Termination() {
<p className="mt-1 text-sm text-gray-500"></p>
</div>
</div>
{view === 'list' && (
<Button size="sm" onClick={handleNewTermination}>
<Plus className="w-4 h-4 mr-1" />
</Button>
)}
{view !== 'list' && (
<Button variant="secondary" size="sm" onClick={() => { setView('list'); resetWizard() }}>
<ChevronLeft className="w-4 h-4 mr-1" />
@@ -896,7 +891,7 @@ export default function Termination() {
<EmptyState
icon={<List className="w-8 h-8 text-gray-400" />}
title="暂无解聘记录"
description="点击「新建解聘」开始创建"
description="请在花名册中选择员工发起解聘"
/>
) : (
<div className="overflow-x-auto">