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:
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user