ux: 花名册操作优化 - 去掉发薪、开具证明/续签合同直接操作、删除用工办理页面

- 去掉花名册操作栏的"发薪"按钮
- 开具证明改为弹窗直接创建+提交,不再跳转用工办理页面
- 续签合同改为弹窗直接创建+提交,自动推导新合同开始日期
- 批量开具证明改为直接API调用,不再跳转用工办理页面
- 删除用工办理页面(WorkProcess.tsx)和路由
- 去掉工具栏"用工办理"按钮

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 14:41:37 +08:00
parent 757cbc8740
commit 4486adc957
3 changed files with 188 additions and 973 deletions
-2
View File
@@ -41,7 +41,6 @@ const MedicalPeriodCalculator = lazy(() => import('./pages/tools/MedicalPeriodCa
const HealthCheck = lazy(() => import('./pages/tools/HealthCheck'))
const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport'))
const CalendarPage = lazy(() => import('./pages/Calendar'))
const WorkProcess = lazy(() => import('./pages/WorkProcess'))
const MyAttendance = lazy(() => import('./pages/portal/MyAttendance'))
const MyLeave = lazy(() => import('./pages/portal/MyLeave'))
const SpecialStatus = lazy(() => import('./pages/SpecialStatus'))
@@ -204,7 +203,6 @@ export default function App() {
<Route path="/tools/medical-period" element={<ProtectedRoute><AdminLayout><MedicalPeriodCalculator /></AdminLayout></ProtectedRoute>} />
<Route path="/tools/health-check" element={<ProtectedRoute><AdminLayout><HealthCheck /></AdminLayout></ProtectedRoute>} />
<Route path="/tools/annual-value" element={<ProtectedRoute><AdminLayout><AnnualValueReport /></AdminLayout></ProtectedRoute>} />
<Route path="/work-process" element={<ProtectedRoute><AdminLayout><WorkProcess /></AdminLayout></ProtectedRoute>} />
<Route path="/special-status" element={<ProtectedRoute><AdminLayout><SpecialStatus /></AdminLayout></ProtectedRoute>} />
<Route path="/company-files" element={<ProtectedRoute><AdminLayout><CompanyFiles /></AdminLayout></ProtectedRoute>} />
<Route path="/leave-approval" element={<ProtectedRoute><AdminLayout><LeaveApproval /></AdminLayout></ProtectedRoute>} />
+188 -28
View File
@@ -5,7 +5,7 @@ import { toast } from 'sonner'
import { toastError } from '../lib/errorToast'
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, 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 { copyToClipboard } from '../lib/clipboard'
import { useAuthStore } from '../store/authStore'
@@ -79,6 +79,16 @@ export default function Roster() {
const [deptEmployee, setDeptEmployee] = useState<any>(null)
const [showConfirmModal, setShowConfirmModal] = useState(false)
const [confirmEmployee, setConfirmEmployee] = useState<any>(null)
// 开具证明弹窗
const [showCertModal, setShowCertModal] = useState(false)
const [certEmployee, setCertEmployee] = useState<any>(null)
const [certPurpose, setCertPurpose] = useState('')
// 续签合同弹窗
const [showRenewModal, setShowRenewModal] = useState(false)
const [renewEmployee, setRenewEmployee] = useState<any>(null)
const [renewNewStartDate, setRenewNewStartDate] = useState('')
const [renewNewEndDate, setRenewNewEndDate] = useState('')
const [renewNewSalary, setRenewNewSalary] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
@@ -141,9 +151,7 @@ export default function Roster() {
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
localStorage.removeItem('add-employee-draft')
setShowAddModal(false)
toast.success('员工已添加', {
action: { label: '前往用工办理', onClick: () => navigate('/work-process') },
})
toast.success('员工已添加')
},
onError: (err: any) => toastError(err, '创建失败'),
})
@@ -281,6 +289,47 @@ export default function Roster() {
},
})
/** 开具收入证明:直接创建并提交工单 */
const certMutation = useMutation({
mutationFn: async (data: { employeeId: string; formData: Record<string, unknown> }) => {
const created: any = await workProcessApi.create({ type: 'INCOME_CERT', title: '开具收入证明', employeeId: data.employeeId, formData: data.formData, status: 'DRAFT' })
if (created?.id) {
await workProcessApi.submit(created.id)
}
return created
},
onSuccess: () => {
toast.success('收入证明已开具,可在「证据管理」中查看和下载')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setShowCertModal(false)
setCertEmployee(null)
setCertPurpose('')
},
onError: (err: any) => toastError(err, '开具证明失败'),
})
/** 续签合同:直接创建并提交工单 */
const renewMutation = useMutation({
mutationFn: async (data: { employeeId: string; formData: Record<string, unknown> }) => {
const created: any = await workProcessApi.create({ type: 'RENEW', title: '合同续签', employeeId: data.employeeId, formData: data.formData, status: 'DRAFT' })
if (created?.id) {
await workProcessApi.submit(created.id)
}
return created
},
onSuccess: () => {
toast.success('合同续签已完成')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
queryClient.invalidateQueries({ queryKey: ['roster'] })
setShowRenewModal(false)
setRenewEmployee(null)
setRenewNewStartDate('')
setRenewNewEndDate('')
setRenewNewSalary('')
},
onError: (err: any) => toastError(err, '续签合同失败'),
})
/** 批量转正:为选中的试用期员工创建转正草稿 */
const handleBatchConfirm = async () => {
const probationEmployees = employees.filter((e: any) => selectedIds.has(e.id) && e.probationInfo?.isProbation)
@@ -314,14 +363,44 @@ export default function Roster() {
setSelectedIds(new Set())
}
/** 批量开具证明:跳转到用工办理批量开具收入证明 */
const handleBatchCert = () => {
/** 批量开具证明:直接为选中员工创建并提交收入证明 */
const handleBatchCert = async () => {
if (selectedIds.size === 0) {
toast.error('请至少选择一名员工')
return
}
const ids = Array.from(selectedIds).join(',')
window.location.href = `/work-process?type=INCOME_CERT&employeeIds=${ids}`
let success = 0
let failed = 0
for (const id of selectedIds) {
const emp = employees.find((e: any) => e.id === id)
if (!emp) continue
try {
const created: any = await workProcessApi.create({
type: 'INCOME_CERT',
title: '开具收入证明',
employeeId: id,
formData: {
employeeId: id,
employeeName: emp.name,
idCardNumber: emp.idCardMasked || '',
position: emp.position || '',
monthlyIncome: emp.monthlySalary ? `¥${emp.monthlySalary}` : '',
purpose: '批量开具',
},
status: 'DRAFT',
})
if (created?.id) {
await workProcessApi.submit(created.id)
}
success++
} catch {
failed++
}
}
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
if (success > 0) toast.success(`已为 ${success} 名员工开具收入证明`)
if (failed > 0) toast.error(`${failed} 名员工开具证明失败`)
setSelectedIds(new Set())
}
const toggleSelect = (id: string) => {
@@ -448,9 +527,6 @@ export default function Roster() {
<Button onClick={() => setShowAddModal(true)} className="h-9 shrink-0">
<Plus className="mr-1.5 h-4 w-4" />
</Button>
<Button variant="secondary" onClick={() => navigate('/work-process')} className="h-9 shrink-0" title="完整的入职办理流程">
<UserPlus className="mr-1.5 h-4 w-4" />
</Button>
<Button variant="secondary" onClick={() => setShowImportModal(true)} className="h-9 shrink-0">
<Upload className="mr-1.5 h-4 w-4" />
</Button>
@@ -779,8 +855,8 @@ export default function Roster() {
>
<DollarSign className="h-4 w-4" />
</button>
{/* 试用期员工显示转正按钮,其他员工显示发薪按钮 */}
{e.probationInfo?.isProbation ? (
{/* 试用期员工显示转正按钮 */}
{e.probationInfo?.isProbation && (
<button
type="button"
title="转正"
@@ -794,19 +870,6 @@ export default function Roster() {
>
<CheckCircle className="h-4 w-4" />
</button>
) : (
<button
type="button"
title="发薪"
aria-label={`${e.name}发薪`}
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
onClick={(ev) => {
ev.stopPropagation()
navigate('/money')
}}
>
<Wallet className="h-4 w-4" />
</button>
)}
<button
type="button"
@@ -828,7 +891,9 @@ 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.href = `/work-process?type=INCOME_CERT&employeeId=${e.id}`
setCertEmployee(e)
setCertPurpose('')
setShowCertModal(true)
}}
>
<FileText className="h-4 w-4" />
@@ -840,7 +905,19 @@ 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.href = `/work-process?type=RENEW&employeeId=${e.id}`
// 自动推导新合同开始日期:原合同结束日 + 1天
const oldEndDate = e.latestContract?.endDate
let newStart = new Date().toISOString().slice(0, 10)
if (oldEndDate) {
const d = new Date(oldEndDate)
d.setDate(d.getDate() + 1)
newStart = d.toISOString().slice(0, 10)
}
setRenewEmployee(e)
setRenewNewStartDate(newStart)
setRenewNewEndDate('')
setRenewNewSalary(e.monthlySalary ? String(e.monthlySalary) : '')
setShowRenewModal(true)
}}
>
<RotateCcw className="h-4 w-4" />
@@ -982,6 +1059,89 @@ export default function Roster() {
/>
)}
{/* 开具收入证明弹窗 */}
{showCertModal && certEmployee && (
<Modal open={showCertModal} onClose={() => { setShowCertModal(false); setCertEmployee(null) }} title={`${certEmployee.name} 开具收入证明`}>
<div className="space-y-3">
<div className="text-xs text-gray-500">
</div>
<div>
<Label></Label>
<Input value={certPurpose} onChange={(e) => setCertPurpose(e.target.value)} placeholder="如:办理签证、租房、贷款等" />
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => { setShowCertModal(false); setCertEmployee(null) }}></Button>
<Button
disabled={certMutation.isPending}
onClick={() => {
certMutation.mutate({
employeeId: certEmployee.id,
formData: {
employeeId: certEmployee.id,
employeeName: certEmployee.name,
idCardNumber: certEmployee.idCardMasked || '',
position: certEmployee.position || '',
monthlyIncome: certEmployee.monthlySalary ? `¥${certEmployee.monthlySalary}` : '',
purpose: certPurpose || '通用',
},
})
}}
></Button>
</div>
</div>
</Modal>
)}
{/* 续签合同弹窗 */}
{showRenewModal && renewEmployee && (
<Modal open={showRenewModal} onClose={() => { setShowRenewModal(false); setRenewEmployee(null) }} title={`${renewEmployee.name} 续签合同`}>
<div className="space-y-3">
<div className="text-xs text-gray-500">
{renewEmployee.latestContract?.endDate ? new Date(renewEmployee.latestContract.endDate).toISOString().slice(0, 10) : '无'}
</div>
<div>
<Label> *</Label>
<Input type="date" value={renewNewStartDate} onChange={(e) => setRenewNewStartDate(e.target.value)} />
</div>
<div>
<Label></Label>
<Input type="date" value={renewNewEndDate} onChange={(e) => setRenewNewEndDate(e.target.value)} placeholder="留空表示无固定期限" />
</div>
<div>
<Label></Label>
<Input type="number" value={renewNewSalary} onChange={(e) => setRenewNewSalary(e.target.value)} placeholder="续签后月薪" />
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => { setShowRenewModal(false); setRenewEmployee(null) }}></Button>
<Button
disabled={renewMutation.isPending}
onClick={() => {
if (!renewNewStartDate) {
toast.error('请填写新合同开始日期')
return
}
if (renewNewEndDate && renewNewEndDate < renewNewStartDate) {
toast.error('结束日期不能早于开始日期')
return
}
renewMutation.mutate({
employeeId: renewEmployee.id,
formData: {
employeeId: renewEmployee.id,
oldContractId: renewEmployee.latestContract?.id || '',
newStartDate: renewNewStartDate,
newEndDate: renewNewEndDate || undefined,
newSalary: renewNewSalary || undefined,
},
})
}}
></Button>
</div>
</div>
</Modal>
)}
{showBatchRenewModal && (
<Modal open={showBatchRenewModal} onClose={() => { setShowBatchRenewModal(false); setPreviewData(null); }} title="批量续签">
<div className="space-y-3">
-943
View File
@@ -1,943 +0,0 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { usePageSize } from '../hooks/usePageSize'
import { useUnsavedChanges } from '../hooks/useUnsavedChanges'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { toastError } from '../lib/errorToast'
import {
UserPlus, LogIn, FileSignature, Edit, CheckCircle, RefreshCw,
Repeat, Pause, FileText, Briefcase,
Loader2, ChevronRight, Trash2, Send, X, Eye, Search, Download, Users,
} from 'lucide-react'
import { workProcessApi, templatesApi, employeeApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import Pagination from '../components/ui/Pagination'
import PageGuide from '../components/ui/PageGuide'
import QueryError from '../components/ui/QueryError'
const PROCESS_ICONS: Record<string, any> = {
HIRE: UserPlus, ONBOARD: LogIn, CUSTOM_CONTRACT: FileSignature,
INFO_SUBMIT: Edit, CONFIRM: CheckCircle, CHANGE: RefreshCw,
RENEW: Repeat, SUSPEND: Pause, INCOME_CERT: FileText,
FLEXIBLE: Briefcase,
}
const PROCESS_TYPES: Record<string, { label: string; description: string }> = {
HIRE: { label: '员工录用', description: '录用新员工并起草劳动合同' },
ONBOARD: { label: '员工入职', description: '办理员工入职手续' },
CUSTOM_CONTRACT: { label: '自定义合同签署', description: '自定义合同内容并签署' },
INFO_SUBMIT: { label: '员工信息提交', description: '提交员工信息变更' },
CONFIRM: { label: '员工转正', description: '试用期员工转正' },
CHANGE: { label: '合同变更', description: '变更合同内容' },
RENEW: { label: '合同续签', description: '到期合同续签' },
SUSPEND: { label: '合同中止', description: '中止履行合同' },
INCOME_CERT: { label: '开具收入证明', description: '为员工开具收入证明' },
FLEXIBLE: { label: '灵活用工', description: '灵活用工协议签署' },
}
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
DRAFT: { label: '草稿', color: 'bg-gray-100 text-gray-600' },
PENDING_APPROVAL: { label: '待审批', color: 'bg-amber-50 text-amber-700' },
APPROVED: { label: '已审批', color: 'bg-blue-50 text-blue-700' },
REJECTED: { label: '已驳回', color: 'bg-red-50 text-red-700' },
EXECUTING: { label: '执行中', color: 'bg-purple-50 text-purple-700' },
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe' },
CANCELLED: { label: '已撤销', color: 'bg-gray-100 text-gray-400' },
}
// 各流程类型的表单字段配置(required 标记必填项)
const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | 'date' | 'number' | 'select' | 'textarea' | 'enterprise-template' | 'employee-select' | 'contract-select'; options?: string[]; required?: boolean }[]> = {
HIRE: [
{ key: 'name', label: '员工姓名', type: 'text', required: true },
{ key: 'department', label: '部门', type: 'text', required: true },
{ key: 'hireDate', label: '入职日期', type: 'date', required: true },
{ key: 'monthlySalary', label: '月薪', type: 'number' },
{ key: 'phone', label: '手机号', type: 'text', required: true },
{ key: 'idCardNumber', label: '证件号码', type: 'text', required: true },
{ key: 'gender', label: '性别', type: 'select', options: ['男', '女'] },
{ key: 'contractStartDate', label: '合同开始日期', type: 'date' },
{ key: 'contractEndDate', label: '合同结束日期', type: 'date' },
],
ONBOARD: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'hireDate', label: '入职日期', type: 'date', required: true },
],
CUSTOM_CONTRACT: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'contractStartDate', label: '合同开始日期', type: 'date', required: true },
{ key: 'contractEndDate', label: '合同结束日期', type: 'date' },
{ key: 'contractType', label: '合同类型', type: 'select', options: ['FIXED', 'UNFIXED', 'INTERNSHIP'] },
],
INFO_SUBMIT: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'department', label: '部门', type: 'text' },
{ key: 'phone', label: '手机号', type: 'text' },
{ key: 'address', label: '地址', type: 'text' },
{ key: 'emergencyContact', label: '紧急联系人', type: 'text' },
{ key: 'emergencyPhone', label: '紧急联系电话', type: 'text' },
],
CONFIRM: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'confirmDate', label: '转正日期', type: 'date', required: true },
{ key: 'regularSalary', label: '转正薪资', type: 'number' },
],
CHANGE: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'contractId', label: '选择合同', type: 'contract-select', required: true },
{ key: 'newEndDate', label: '新到期日期', type: 'date', required: true },
],
RENEW: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'oldContractId', label: '原合同', type: 'contract-select' },
{ key: 'newStartDate', label: '新合同开始日期', type: 'date', required: true },
{ key: 'newEndDate', label: '新合同结束日期', type: 'date' },
{ key: 'newSalary', label: '新薪资', type: 'number' },
],
SUSPEND: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'contractId', label: '选择合同', type: 'contract-select', required: true },
{ key: 'suspendDate', label: '中止日期', type: 'date', required: true },
],
INCOME_CERT: [
{ key: 'enterpriseTemplateId', label: '关联企业模板(选填)', type: 'enterprise-template' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'employeeName', label: '员工姓名', type: 'text' },
{ key: 'idCardNumber', label: '证件号码', type: 'text' },
{ key: 'position', label: '职务', type: 'text' },
{ key: 'monthlyIncome', label: '月收入', type: 'text' },
{ key: 'purpose', label: '用途', type: 'text' },
],
TERMINATE: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'contractId', label: '选择合同', type: 'contract-select' },
{ key: 'terminateDate', label: '终止日期', type: 'date', required: true },
{ key: 'reason', label: '终止原因', type: 'select', options: ['EXPIRED', 'NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF'], required: true },
{ key: 'compensation', label: '经济补偿金', type: 'number' },
],
RESCIND: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'contractId', label: '选择合同', type: 'contract-select' },
{ key: 'rescindDate', label: '解除日期', type: 'date', required: true },
{ key: 'reason', label: '解除原因', type: 'select', options: ['NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF'], required: true },
{ key: 'compensation', label: '经济补偿金', type: 'number' },
],
LEAVING_CERT: [
{ key: 'enterpriseTemplateId', label: '关联企业模板(选填)', type: 'enterprise-template' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'employeeName', label: '员工姓名', type: 'text' },
{ key: 'idCardNumber', label: '证件号码', type: 'text' },
{ key: 'position', label: '职务', type: 'text' },
{ key: 'hireDate', label: '入职日期', type: 'date' },
{ key: 'leaveDate', label: '离职日期', type: 'date', required: true },
],
FLEXIBLE: [
{ key: 'name', label: '姓名', type: 'text', required: true },
{ key: 'phone', label: '手机号', type: 'text' },
{ key: 'idCardNumber', label: '证件号码', type: 'text', required: true },
{ key: 'department', label: '部门', type: 'text' },
{ key: 'agreementStartDate', label: '协议开始日期', type: 'date', required: true },
{ key: 'agreementEndDate', label: '协议结束日期', type: 'date' },
{ key: 'payMethod', label: '计酬方式', type: 'text' },
],
}
// 字段 key → 中文 label 映射(用于展示已保存的表单数据)
const FIELD_LABEL_MAP: Record<string, string> = Object.values(FORM_FIELDS).flat().reduce((acc, f) => {
acc[f.key] = f.label
return acc
}, {} as Record<string, string>)
/** 日期字段对配置:各流程类型的开始/结束日期字段对 */
const DATE_RANGE_FIELDS: Record<string, Array<{ start: string; end: string; startLabel: string; endLabel: string }>> = {
HIRE: [{ start: 'contractStartDate', end: 'contractEndDate', startLabel: '合同开始日期', endLabel: '合同结束日期' }],
CUSTOM_CONTRACT: [{ start: 'contractStartDate', end: 'contractEndDate', startLabel: '合同开始日期', endLabel: '合同结束日期' }],
RENEW: [{ start: 'newStartDate', end: 'newEndDate', startLabel: '新合同开始日期', endLabel: '新合同结束日期' }],
FLEXIBLE: [{ start: 'agreementStartDate', end: 'agreementEndDate', startLabel: '协议开始日期', endLabel: '协议结束日期' }],
}
/** 校验日期前后关系:结束日期不能早于开始日期 */
function validateDateRange(type: string, data: Record<string, any>): string | null {
const pairs = DATE_RANGE_FIELDS[type]
if (!pairs) return null
for (const pair of pairs) {
const start = data[pair.start]
const end = data[pair.end]
if (start && end && new Date(end) < new Date(start)) {
return `${pair.endLabel}不能早于${pair.startLabel}`
}
}
return null
}
export default function WorkProcess() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [selectedType, setSelectedType] = useState<string>(() => {
try { return localStorage.getItem('workprocess-draft-type') || '' } catch { return '' }
})
const [formData, setFormData] = useState<Record<string, any>>(() => {
try {
const saved = localStorage.getItem('workprocess-draft-data')
return saved ? JSON.parse(saved) : {}
} catch { return {} }
})
const [filterType, setFilterType] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [detailId, setDetailId] = useState<string | null>(null)
const [previewContent, setPreviewContent] = useState<string | null>(null)
const [showBatch, setShowBatch] = useState(false)
const [batchType, setBatchType] = useState<string>('INCOME_CERT')
const [batchEmployees, setBatchEmployees] = useState<string[]>([])
const [batchSearch, setBatchSearch] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
// 持久化草稿到 localStorage,防止录入数据丢失
useEffect(() => {
try {
if (selectedType && Object.keys(formData).length > 0) {
localStorage.setItem('workprocess-draft-type', selectedType)
localStorage.setItem('workprocess-draft-data', JSON.stringify(formData))
} else {
localStorage.removeItem('workprocess-draft-type')
localStorage.removeItem('workprocess-draft-data')
}
} catch {}
}, [selectedType, formData])
const isDirty = selectedType && Object.keys(formData).length > 0
useUnsavedChanges(!!isDirty)
const { data: listData, isLoading, isError, error, refetch } = useQuery({
queryKey: ['work-processes', filterType, filterStatus, page, pageSize],
queryFn: async () => {
const params: any = { page, pageSize }
if (filterType) params.type = filterType
if (filterStatus) params.status = filterStatus
return await workProcessApi.list({ page, pageSize, type: filterType || undefined, status: filterStatus || undefined } as any)
},
})
const createMutation = useMutation({
mutationFn: async (data: any) => {
return await workProcessApi.create(data)
},
onSuccess: () => {
toast.success('已创建草稿,可在列表中查看详情并提交', {
action: { label: '去提交', onClick: () => {} },
})
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setShowCreate(false)
setFormData({})
setSelectedType('')
},
onError: (err: any) => toastError(err, '创建失败'),
})
const submitMutation = useMutation({
mutationFn: async (id: string) => {
return await workProcessApi.submit(id)
},
onSuccess: () => {
toast.success('已提交并执行', {
action: { label: '查看文书', onClick: () => navigate('/evidence') },
})
toast.info('文书已生成,可在「证据管理」中查看和下载', { duration: 5000 })
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setDetailId(null)
},
onError: (err: any) => toastError(err, '提交失败'),
})
const cancelMutation = useMutation({
mutationFn: async (id: string) => {
return await workProcessApi.cancel(id)
},
onSuccess: () => {
toast.success('已撤销')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setDetailId(null)
},
onError: (err: any) => toastError(err, '撤销失败'),
})
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await workProcessApi.remove(id)
},
onSuccess: () => {
toast.success('已删除')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
},
onError: (err: any) => toastError(err, '删除失败'),
})
const previewMutation = useMutation({
mutationFn: async (id: string) => {
return await workProcessApi.preview(id)
},
onSuccess: (data) => {
setPreviewContent(data.content)
},
onError: (err: any) => toastError(err, '预览失败'),
})
const handleCreate = () => {
if (!selectedType) {
toast.error('请选择流程类型')
return
}
// 校验必填项
const requiredFields = (FORM_FIELDS[selectedType] || []).filter(f => f.required)
const missingFields = requiredFields.filter(f => !formData[f.key] || String(formData[f.key]).trim() === '')
if (missingFields.length > 0) {
toast.error(`请填写必填项:${missingFields.map(f => f.label).join('、')}`)
return
}
// 校验日期前后关系
const dateError = validateDateRange(selectedType, formData)
if (dateError) {
toast.error(dateError)
return
}
createMutation.mutate({
type: selectedType,
title: PROCESS_TYPES[selectedType].label,
employeeId: formData.employeeId || undefined,
formData,
status: 'DRAFT',
})
}
const handleCreateAndSubmit = () => {
if (!selectedType) {
toast.error('请选择流程类型')
return
}
// 校验日期前后关系
const dateError = validateDateRange(selectedType, formData)
if (dateError) {
toast.error(dateError)
return
}
createMutation.mutate(
{ type: selectedType, title: PROCESS_TYPES[selectedType].label, employeeId: formData.employeeId || undefined, formData, status: 'DRAFT' },
{
onSuccess: (data: any) => {
const newId = data?.id
if (newId) {
submitMutation.mutate(newId)
} else {
toast.success('草稿已创建,请手动提交')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
}
setShowCreate(false)
setFormData({})
setSelectedType('')
},
}
)
}
const handleFieldChange = (key: string, value: any) => {
setFormData(prev => ({ ...prev, [key]: value }))
// 选择员工后自动填充相关字段
if (key === 'employeeId' && value) {
employeeApi.detail(value).then((emp: any) => {
setFormData(prev => ({
...prev,
employeeName: emp.name || prev.employeeName,
idCardNumber: emp.idCardNumber || prev.idCardNumber,
position: emp.position || prev.position,
monthlyIncome: emp.monthlySalary ? String(emp.monthlySalary) : prev.monthlyIncome,
hireDate: emp.hireDate ? emp.hireDate.slice(0, 10) : prev.hireDate,
department: emp.department || prev.department,
phone: emp.phone || prev.phone,
}))
// RENEW 类型:自动推导新合同开始日期 = 原合同结束日期 + 1天
if (selectedType === 'RENEW' && emp.contracts?.length > 0) {
const latestContract = emp.contracts[0]
if (latestContract?.endDate) {
const endDate = new Date(latestContract.endDate)
endDate.setDate(endDate.getDate() + 1)
setFormData(prev => ({
...prev,
oldContractId: latestContract.id,
newStartDate: endDate.toISOString().slice(0, 10),
}))
}
}
}).catch(() => {})
}
}
const handleBatchSubmit = () => {
if (batchEmployees.length === 0) {
toast.error('请至少选择一名员工')
return
}
let success = 0
let failed = 0
Promise.all(
batchEmployees.map(async (empId) => {
try {
const emp = allEmployees.find((e: any) => e.id === empId)
if (!emp) return
const data: any = {
type: batchType,
title: PROCESS_TYPES[batchType].label,
employeeId: empId,
formData: {
employeeName: emp.name,
idCardNumber: emp.idCardNumber || '',
position: emp.position || '',
},
status: 'DRAFT',
}
const res: any = await workProcessApi.create(data)
if (res?.id) {
await workProcessApi.submit(res.id)
success++
}
} catch {
failed++
}
})
).then(() => {
toast.success(`批量开具完成:成功 ${success}${failed > 0 ? ',失败 ' + failed + ' 个' : ''}`)
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setShowBatch(false)
setBatchEmployees([])
setBatchSearch('')
})
}
const { data: allEmployees = [] } = useQuery<any[]>({
queryKey: ['employee-list-batch'],
queryFn: async () => {
return await employeeApi.allLite()
},
})
const filteredEmployees = allEmployees.filter((e: any) => {
if (!batchSearch) return true
return e.name.includes(batchSearch) || (e.department || '').includes(batchSearch)
})
const items = listData?.items || []
const total = listData?.total || 0
return (
<div className="space-y-4">
<PageGuide>
</PageGuide>
{/* 发起办理 */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
<div className="flex items-center gap-2">
{isDirty && (
<span className="text-xs text-amber-600 flex items-center gap-1">
<FileText className="w-3.5 h-3.5" />
稿{PROCESS_TYPES[selectedType]?.label}
<button className="text-primary hover:underline" onClick={() => { setSelectedType(''); setFormData({}) }}></button>
</span>
)}
<Button size="sm" onClick={() => setShowCreate(true)}>
<UserPlus className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" variant="secondary" onClick={() => setShowBatch(true)}>
<Users className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* 13类流程卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-2">
{Object.entries(PROCESS_TYPES).map(([key, config]) => {
const Icon = PROCESS_ICONS[key] || FileText
return (
<button
key={key}
onClick={() => {
setSelectedType(key)
setShowCreate(true)
setFormData({})
}}
className="flex items-start gap-2 p-3 rounded-md border border-gray-200 hover:border-primary hover:bg-primary/5 transition-colors text-left"
>
<Icon className="w-4 h-4 text-primary mt-0.5 shrink-0" />
<div className="min-w-0">
<div className="text-xs font-medium text-gray-900">{config.label}</div>
<div className="text-[10px] text-gray-500 truncate">{config.description}</div>
</div>
</button>
)
})}
</div>
</Card>
{/* 办理记录 */}
<Card>
<div className="flex items-center gap-3 mb-4">
<h3 className="text-sm font-medium"></h3>
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="w-32">
<option value=""></option>
{Object.entries(PROCESS_TYPES).map(([key, config]) => (
<option key={key} value={key}>{config.label}</option>
))}
</Select>
<Select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="w-32">
<option value=""></option>
{Object.entries(STATUS_CONFIG).map(([key, config]) => (
<option key={key} value={key}>{config.label}</option>
))}
</Select>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8"><Loader2 className="w-5 h-5 animate-spin text-gray-400" /></div>
) : isError ? (
<QueryError error={error} onRetry={refetch} />
) : items.length === 0 ? (
<div className="text-center py-8 text-sm text-gray-400"></div>
) : (
<div className="space-y-2">
{items.map((item: any) => {
const Icon = PROCESS_ICONS[item.type] || FileText
const statusCfg = STATUS_CONFIG[item.status] || STATUS_CONFIG.DRAFT
return (
<div
key={item.id}
className="flex items-center gap-3 p-3 rounded-md border border-gray-200 hover:bg-gray-50 cursor-pointer"
onClick={() => setDetailId(item.id)}
>
<Icon className="w-4 h-4 text-gray-400 shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900">{item.title}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${statusCfg.color}`}>{statusCfg.label}</span>
</div>
<div className="text-xs text-gray-500">
{item.employee ? `${item.employee.name} · ${item.employee.department}` : (item.formData?.employeeName || item.formData?.name || '未关联员工')}
{' · '}{new Date(item.createdAt).toLocaleDateString('zh-CN')}
</div>
</div>
{(item.status === 'COMPLETED' || item.status === 'EXECUTING') && item.documents && item.documents.length > 0 && (
<button
className="text-xs text-primary hover:underline shrink-0 flex items-center gap-0.5"
onClick={(e) => { e.stopPropagation(); setDetailId(item.id) }}
>
<Eye className="w-3.5 h-3.5" />
</button>
)}
<ChevronRight className="w-4 h-4 text-gray-300" />
</div>
)
})}
</div>
)}
<Pagination
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={() => setPage(1)}
/>
</Card>
{/* 创建/编辑弹窗 */}
<Modal open={showCreate} onClose={() => setShowCreate(false)} title={selectedType ? `发起:${PROCESS_TYPES[selectedType]?.label}` : '发起办理'} size="lg">
{!selectedType ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{Object.entries(PROCESS_TYPES).map(([key, config]) => {
const Icon = PROCESS_ICONS[key] || FileText
return (
<button
key={key}
onClick={() => setSelectedType(key)}
className="flex items-start gap-2 p-3 rounded-md border border-gray-200 hover:border-primary hover:bg-primary/5 transition-colors text-left"
>
<Icon className="w-4 h-4 text-primary mt-0.5 shrink-0" />
<div>
<div className="text-xs font-medium">{config.label}</div>
<div className="text-[10px] text-gray-500">{config.description}</div>
</div>
</button>
)
})}
</div>
) : (
<div className="space-y-3">
<div className="text-xs text-gray-500 mb-2">{PROCESS_TYPES[selectedType]?.description}</div>
{(FORM_FIELDS[selectedType] || []).map(field => (
<div key={field.key}>
<Label>{field.label}{field.required && <span className="text-danger ml-0.5">*</span>}</Label>
{field.type === 'select' ? (
<Select value={formData[field.key] || ''} onChange={(e) => handleFieldChange(field.key, e.target.value)}>
<option value=""></option>
{field.options?.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</Select>
) : field.type === 'textarea' ? (
<textarea
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary text-sm min-h-[80px]"
value={formData[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
) : field.type === 'enterprise-template' ? (
<EnterpriseTemplateSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} />
) : field.type === 'employee-select' ? (
<EmployeeSelect value={formData[field.key] || ''} onChange={(emp) => {
handleFieldChange(field.key, emp.id)
if (emp.name) handleFieldChange('employeeName', emp.name)
if (emp.idCardNumber) handleFieldChange('idCardNumber', emp.idCardNumber)
if (emp.position) handleFieldChange('position', emp.position)
if (emp.monthlySalary) handleFieldChange('monthlyIncome', String(emp.monthlySalary))
if (emp.hireDate) handleFieldChange('hireDate', emp.hireDate?.slice(0, 10))
if (emp.department) handleFieldChange('department', emp.department)
if (emp.phone) handleFieldChange('phone', emp.phone)
}} />
) : field.type === 'contract-select' ? (
<ContractSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} employeeId={formData['employeeId'] || ''} />
) : (
<Input
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
value={formData[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
)}
</div>
))}
<div className="flex items-center gap-2 pt-2">
<Button onClick={handleCreate} disabled={createMutation.isPending}>
{createMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : null}
稿
</Button>
<Button onClick={handleCreateAndSubmit} disabled={createMutation.isPending || submitMutation.isPending}>
{(createMutation.isPending || submitMutation.isPending) ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Send className="w-4 h-4 mr-1" />}
</Button>
<Button variant="secondary" onClick={() => setSelectedType('')}>
</Button>
</div>
</div>
)}
</Modal>
{/* 详情弹窗 */}
<Modal open={!!detailId} onClose={() => { setDetailId(null); setPreviewContent(null) }} title="办理详情" size="lg">
<DetailContent
id={detailId}
previewContent={previewContent}
onPreview={(id) => previewMutation.mutate(id)}
onSubmit={(id) => submitMutation.mutate(id)}
onCancel={(id) => cancelMutation.mutate(id)}
onDelete={(id) => deleteMutation.mutate(id)}
loading={submitMutation.isPending || cancelMutation.isPending}
/>
</Modal>
{/* 批量开具证明弹窗 */}
<Modal open={showBatch} onClose={() => { setShowBatch(false); setBatchEmployees([]); setBatchSearch('') }} title="批量开具证明" size="lg">
<div className="space-y-4">
<div>
<Label></Label>
<Select value={batchType} onChange={(e) => setBatchType(e.target.value)}>
<option value="INCOME_CERT"></option>
<option value="LEAVING_CERT"></option>
</Select>
</div>
<div>
<Label> {batchEmployees.length} </Label>
<div className="relative mb-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={batchSearch}
onChange={(e) => setBatchSearch(e.target.value)}
placeholder="搜索姓名或部门..."
className="pl-9"
/>
</div>
<div className="max-h-[300px] overflow-y-auto border rounded-md">
{filteredEmployees.map((emp: any) => (
<label
key={emp.id}
className="flex items-center gap-2 px-3 py-2 hover:bg-gray-50 cursor-pointer border-b last:border-0"
>
<input
type="checkbox"
checked={batchEmployees.includes(emp.id)}
onChange={() => {
setBatchEmployees(prev =>
prev.includes(emp.id) ? prev.filter(id => id !== emp.id) : [...prev, emp.id]
)
}}
/>
<span className="text-sm flex-1">{emp.name}</span>
<span className="text-xs text-gray-400">{emp.department || '—'}</span>
</label>
))}
{filteredEmployees.length === 0 && (
<div className="text-center py-4 text-xs text-gray-400"></div>
)}
</div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t">
<Button variant="secondary" size="sm" onClick={() => { setShowBatch(false); setBatchEmployees([]); setBatchSearch('') }}></Button>
<Button size="sm" onClick={handleBatchSubmit} disabled={batchEmployees.length === 0}>
<Send className="w-4 h-4 mr-1" />
{batchEmployees.length}
</Button>
</div>
</div>
</Modal>
</div>
)
}
function DetailContent({ id, previewContent, onPreview, onSubmit, onCancel, onDelete, loading }: {
id: string | null
previewContent: string | null
onPreview: (id: string) => void
onSubmit: (id: string) => void
onCancel: (id: string) => void
onDelete: (id: string) => void
loading: boolean
}) {
const { data, isLoading } = useQuery({
queryKey: ['work-process', id],
queryFn: async () => {
return await workProcessApi.detail(id!)
},
enabled: !!id,
})
if (isLoading || !data) return <div className="flex items-center justify-center py-8"><Loader2 className="w-5 h-5 animate-spin text-gray-400" /></div>
const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.DRAFT
const Icon = PROCESS_ICONS[data.type] || FileText
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Icon className="w-5 h-5 text-primary" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{data.title}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${statusCfg.color}`}>{statusCfg.label}</span>
</div>
<div className="text-xs text-gray-500">
{PROCESS_TYPES[data.type]?.label} · {data.employee ? `${data.employee.name}${data.employee.department}` : (data.formData?.employeeName || data.formData?.name || '未关联员工')}
</div>
</div>
</div>
{/* 表单数据 */}
<div>
<h4 className="text-xs font-medium text-gray-700 mb-2"></h4>
<div className="bg-gray-50 rounded-md p-3 space-y-1">
{Object.entries(data.formData || {}).map(([key, value]: [string, any]) => (
<div key={key} className="flex text-xs">
<span className="text-gray-500 w-28 shrink-0">{FIELD_LABEL_MAP[key] || key}</span>
<span className="text-gray-900">{key === 'enterpriseTemplateId' && value ? `已关联企业模板` : (value ? String(value) : '-')}</span>
</div>
))}
{Object.keys(data.formData || {}).length === 0 && <span className="text-xs text-gray-400"></span>}
</div>
</div>
{/* 文书预览 */}
{previewContent && (
<div>
<h4 className="text-xs font-medium text-gray-700 mb-2"></h4>
<pre className="bg-gray-50 rounded-md p-3 text-xs whitespace-pre-wrap max-h-[300px] overflow-y-auto">{previewContent}</pre>
</div>
)}
{/* 生成的文书 */}
{data.documents && data.documents.length > 0 && (
<div>
<h4 className="text-xs font-medium text-gray-700 mb-2"></h4>
<div className="space-y-2">
{data.documents.map((doc: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs bg-gray-50 rounded-md p-2">
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="flex-1 truncate">{doc.name}</span>
<button
type="button"
className="text-primary hover:underline shrink-0"
onClick={() => onPreview(data.id)}
>
<Eye className="w-3.5 h-3.5 inline mr-0.5" />
</button>
<button
type="button"
className="text-primary hover:underline shrink-0"
onClick={() => {
const content = previewContent || ''
if (!content) {
onPreview(data.id)
return
}
const blob = new Blob(['\ufeff' + content], { type: 'application/msword;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = doc.name.endsWith('.doc') ? doc.name : `${doc.name}.doc`
a.click()
URL.revokeObjectURL(url)
}}
>
<Download className="w-3.5 h-3.5 inline mr-0.5" />
</button>
</div>
))}
</div>
</div>
)}
{/* 操作按钮 */}
<div className="flex items-center gap-2 pt-2 border-t">
{data.status === 'DRAFT' && (
<>
<Button size="sm" onClick={() => onPreview(data.id)} variant="secondary">
<Eye className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" onClick={() => onSubmit(data.id)} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Send className="w-4 h-4 mr-1" />}
</Button>
<Button size="sm" variant="danger" onClick={() => onDelete(data.id)}>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
</>
)}
{!['COMPLETED', 'CANCELLED'].includes(data.status) && data.status !== 'DRAFT' && (
<Button size="sm" variant="secondary" onClick={() => onCancel(data.id)} disabled={loading}>
<X className="w-4 h-4 mr-1" />
</Button>
)}
</div>
</div>
)
}
function EnterpriseTemplateSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const { data, isLoading } = useQuery<any>({
queryKey: ['enterprise-templates-for-cert'],
queryFn: async () => {
const res = await templatesApi.enterpriseList({ pageSize: 100 } as any)
return res
},
})
const items = data?.items || []
return (
<div>
<Select value={value} onChange={(e) => onChange(e.target.value)} disabled={isLoading}>
<option value="">{isLoading ? '加载中...' : '使用系统默认模板'}</option>
{items.map((t: any) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</Select>
{items.length === 0 && !isLoading && (
<p className="text-xs text-gray-400 mt-1"></p>
)}
</div>
)
}
function EmployeeSelect({ value, onChange }: { value: string; onChange: (employee: any) => void }) {
const [search, setSearch] = useState('')
const [open, setOpen] = useState(false)
const { data: employees = [], isLoading } = useQuery<any[]>({
queryKey: ['employee-list-for-select'],
queryFn: async () => {
return await employeeApi.allLite()
},
})
const filtered = employees.filter((e: any) => {
if (!search) return true
return e.name.includes(search) || (e.department || '').includes(search) || (e.phone || '').includes(search)
})
const selected = employees.find((e: any) => e.id === value)
return (
<div className="relative">
<div
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary text-sm cursor-pointer flex items-center justify-between"
onClick={() => setOpen(!open)}
>
{selected ? (
<span>{selected.name} · {selected.department || '未分配部门'}</span>
) : (
<span className="text-gray-400">{isLoading ? '加载中...' : '点击选择员工'}</span>
)}
<Search className="w-3.5 h-3.5 text-gray-400" />
</div>
{open && (
<div className="absolute z-50 mt-1 w-full bg-white rounded-md border border-gray-200 shadow-lg max-h-[240px] overflow-hidden">
<div className="p-2 border-b border-gray-100">
<input
type="text"
autoFocus
placeholder="搜索姓名/部门/手机号"
value={search}
onChange={(e) => setSearch(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"
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className="overflow-y-auto max-h-[180px]">
{filtered.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-gray-400"></div>
) : (
filtered.map((e: any) => (
<div
key={e.id}
className="px-3 py-2 text-sm hover:bg-primary/5 cursor-pointer flex items-center justify-between"
onClick={() => {
onChange(e)
setOpen(false)
setSearch('')
}}
>
<span>{e.name}</span>
<span className="text-xs text-gray-400">{e.department || ''}</span>
</div>
))
)}
</div>
</div>
)}
</div>
)
}
function ContractSelect({ value, onChange, employeeId }: { value: string; onChange: (v: string) => void; employeeId: string }) {
const { data: contracts = [], isLoading } = useQuery<any[]>({
queryKey: ['employee-contracts', employeeId],
queryFn: async () => {
if (!employeeId) return []
const res = await employeeApi.detail(employeeId)
return res?.contracts || []
},
enabled: !!employeeId,
})
const activeContracts = contracts.filter((c: any) => c.status === 'ACTIVE' || c.status === 'SUSPENDED')
return (
<Select value={value} onChange={(e) => onChange(e.target.value)} disabled={!employeeId}>
<option value="">{!employeeId ? '请先选择员工' : isLoading ? '加载中...' : activeContracts.length === 0 ? '无可用合同' : '请选择合同'}</option>
{activeContracts.map((c: any) => (
<option key={c.id} value={c.id}>
{c.contractType === 'UNFIXED' ? '无固定期限' : `${c.startDate?.slice(0, 10)} ~ ${c.endDate?.slice(0, 10)}`}{c.status === 'SUSPENDED' ? '(已中止)' : ''}
</option>
))}
</Select>
)
}