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:
@@ -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) => {
|
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||||
try {
|
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 status = req.query.status as string // ACTIVE | PRE_HIRE | RESIGNED
|
||||||
const contractStatus = req.query.contractStatus as string // active | expiring | expired | unsigned | etc.
|
const contractStatus = req.query.contractStatus as string // active | expiring | expired | unsigned | etc.
|
||||||
const department = req.query.department as string
|
const department = req.query.department as string
|
||||||
|
const position = req.query.position as string
|
||||||
const skip = (page - 1) * pageSize
|
const skip = (page - 1) * pageSize
|
||||||
|
|
||||||
// 使用本地日期午夜,避免时区问题导致当天入职被误判为预入职
|
// 使用本地日期午夜,避免时区问题导致当天入职被误判为预入职
|
||||||
@@ -71,6 +87,9 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
|||||||
if (department) {
|
if (department) {
|
||||||
whereBase.department = department
|
whereBase.department = department
|
||||||
}
|
}
|
||||||
|
if (position) {
|
||||||
|
whereBase.position = position
|
||||||
|
}
|
||||||
if (search && !isIdCardSearch) {
|
if (search && !isIdCardSearch) {
|
||||||
whereBase.OR = [
|
whereBase.OR = [
|
||||||
{ name: { contains: search } },
|
{ name: { contains: search } },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { useConfirm } from '../hooks/useConfirm'
|
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 { 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 { rosterApi, employeeApi, terminationApi, workProcessApi } from '../lib/api-services'
|
||||||
|
import api from '../lib/api'
|
||||||
import { copyToClipboard } from '../lib/clipboard'
|
import { copyToClipboard } from '../lib/clipboard'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import { useDebouncedValue } from '../hooks/useDebouncedValue'
|
import { useDebouncedValue } from '../hooks/useDebouncedValue'
|
||||||
@@ -98,13 +99,14 @@ export default function Roster() {
|
|||||||
const [filterStatus, setFilterStatus] = useState('')
|
const [filterStatus, setFilterStatus] = useState('')
|
||||||
const [filterContractStatus, setFilterContractStatus] = useState('')
|
const [filterContractStatus, setFilterContractStatus] = useState('')
|
||||||
const [filterDepartment, setFilterDepartment] = useState('')
|
const [filterDepartment, setFilterDepartment] = useState('')
|
||||||
|
const [filterPosition, setFilterPosition] = useState('')
|
||||||
const [showBatchTerminateModal, setShowBatchTerminateModal] = useState(false)
|
const [showBatchTerminateModal, setShowBatchTerminateModal] = useState(false)
|
||||||
const [batchTerminateDate, setBatchTerminateDate] = useState(() => new Date().toISOString().slice(0, 10))
|
const [batchTerminateDate, setBatchTerminateDate] = useState(() => new Date().toISOString().slice(0, 10))
|
||||||
const [batchTerminateReason, setBatchTerminateReason] = useState('NEGOTIATED')
|
const [batchTerminateReason, setBatchTerminateReason] = useState('NEGOTIATED')
|
||||||
const [terminatePreviewData, setTerminatePreviewData] = useState<any>(null)
|
const [terminatePreviewData, setTerminatePreviewData] = useState<any>(null)
|
||||||
|
|
||||||
const { data: rosterData, isLoading, isError, error, refetch } = useQuery<any>({
|
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 () => {
|
queryFn: async () => {
|
||||||
const params: any = { page, pageSize }
|
const params: any = { page, pageSize }
|
||||||
if (debouncedSearch) params.search = debouncedSearch
|
if (debouncedSearch) params.search = debouncedSearch
|
||||||
@@ -112,7 +114,8 @@ export default function Roster() {
|
|||||||
if (filterStatus === 'PROBATION') params.status = 'ACTIVE'
|
if (filterStatus === 'PROBATION') params.status = 'ACTIVE'
|
||||||
if (filterContractStatus) params.contractStatus = filterContractStatus
|
if (filterContractStatus) params.contractStatus = filterContractStatus
|
||||||
if (filterDepartment) params.department = filterDepartment
|
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
|
return res
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -425,10 +428,11 @@ export default function Roster() {
|
|||||||
setFilterStatus('')
|
setFilterStatus('')
|
||||||
setFilterContractStatus('')
|
setFilterContractStatus('')
|
||||||
setFilterDepartment('')
|
setFilterDepartment('')
|
||||||
|
setFilterPosition('')
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasActiveFilters = search || filterStatus || filterContractStatus || filterDepartment
|
const hasActiveFilters = search || filterStatus || filterContractStatus || filterDepartment || filterPosition
|
||||||
|
|
||||||
/** 全局合同风险统计(来自后端,与风险中心同口径) */
|
/** 全局合同风险统计(来自后端,与风险中心同口径) */
|
||||||
const globalRiskStats = rosterData?.globalRiskStats
|
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 { isVisible: colVisible, toggle: colToggle } = useRosterColumns()
|
||||||
const [showColSettings, setShowColSettings] = useState(false)
|
const [showColSettings, setShowColSettings] = useState(false)
|
||||||
|
|
||||||
@@ -521,6 +533,14 @@ export default function Roster() {
|
|||||||
<option value="">全部部门</option>
|
<option value="">全部部门</option>
|
||||||
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
|
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
|
||||||
</select>
|
</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 && (
|
{hasActiveFilters && (
|
||||||
<button onClick={clearFilters} className="h-9 px-2 text-sm text-gray-400 transition hover:text-gray-700">清除筛选</button>
|
<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 (debouncedSearch) params.set('search', debouncedSearch)
|
||||||
if (filterStatus) params.set('status', filterStatus)
|
if (filterStatus) params.set('status', filterStatus)
|
||||||
if (filterDepartment) params.set('department', filterDepartment)
|
if (filterDepartment) params.set('department', filterDepartment)
|
||||||
|
if (filterPosition) params.set('position', filterPosition)
|
||||||
if (filterContractStatus) params.set('contractStatus', filterContractStatus)
|
if (filterContractStatus) params.set('contractStatus', filterContractStatus)
|
||||||
const token = useAuthStore.getState().accessToken
|
const token = useAuthStore.getState().accessToken
|
||||||
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||||||
|
|||||||
@@ -803,11 +803,6 @@ export default function Termination() {
|
|||||||
<p className="mt-1 text-sm text-gray-500">规范处理离职、解聘审批及补偿核算流程</p>
|
<p className="mt-1 text-sm text-gray-500">规范处理离职、解聘审批及补偿核算流程</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{view === 'list' && (
|
|
||||||
<Button size="sm" onClick={handleNewTermination}>
|
|
||||||
<Plus className="w-4 h-4 mr-1" />新建解聘
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{view !== 'list' && (
|
{view !== 'list' && (
|
||||||
<Button variant="secondary" size="sm" onClick={() => { setView('list'); resetWizard() }}>
|
<Button variant="secondary" size="sm" onClick={() => { setView('list'); resetWizard() }}>
|
||||||
<ChevronLeft className="w-4 h-4 mr-1" />返回列表
|
<ChevronLeft className="w-4 h-4 mr-1" />返回列表
|
||||||
@@ -896,7 +891,7 @@ export default function Termination() {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<List className="w-8 h-8 text-gray-400" />}
|
icon={<List className="w-8 h-8 text-gray-400" />}
|
||||||
title="暂无解聘记录"
|
title="暂无解聘记录"
|
||||||
description="点击「新建解聘」开始创建"
|
description="请在花名册中选择员工发起解聘"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
|||||||
Reference in New Issue
Block a user