feat: 20260815 系统优化 - 全部31项问题修复(P0×6+P1×14+P2×9+P3×2)

P0紧急修复(6项):
- 草稿保存完整恢复所有字段(含socialAvgWage)
- 补偿金批次从compensationBreakdown读取
- 违法解除风险确认UI
- 合同结束日期前后校验(前后端双保险)

P1高优先级(14项):
- 离职日期联动社保/公积金截止月(15号规则)
- 合规检查+工作交接改为软阻断(生成待办)
- 补偿月数(N/N+1/2N/自定义)+计算基数(近12月/合同/自定义)
- 解聘并入花名册操作栏(类型选择跳转向导)
- 合同续签开始日期自动推导(原合同结束日+1天)
- 年龄合规筛查(童工阻断/未成年工/退休警告)
- 编辑入职日期后状态联动(待入职↔在职)
- 转正移植到花名册操作栏+薪资回写
- 男职工无法选择三期

P2体验优化(9项):
- "劳动合同"调整为"用工关系"
- 费用结算新增剩余年假折算(300%日工资)
- 身份证号全域改为"证件号码"(前后端18个文件)
- 手机号查重
- 开具证明+合同续签移植到花名册操作栏
- 批量转正+批量开具证明
- 去掉用工办理模块

P3规划(2项):
- 组织架构+审批流(Department/Position/ApprovalFlow/ApprovalInstance)
- 客服工作台(Ticket/ChatSession+SUPPORT角色)

新增模型: Department/Position/ApprovalFlow/ApprovalInstance/Ticket/TicketMessage/ChatSession/ChatMessage
新增字段: Employee.departmentId/supervisorId
新增角色: SUPPORT

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 12:37:27 +08:00
parent 8cfdd566af
commit e1b5ae9aab
46 changed files with 3455 additions and 206 deletions
+134 -17
View File
@@ -5,8 +5,8 @@ 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 } from 'lucide-react'
import { rosterApi, employeeApi, terminationApi } from '../lib/api-services'
import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Wallet, 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'
import { useDebouncedValue } from '../hooks/useDebouncedValue'
@@ -19,7 +19,7 @@ import { fmt, terminateReasonMap } from './roster/shared'
import PageGuide from '../components/ui/PageGuide'
import EmployeeProfile from './roster/EmployeeProfile'
import { InlineAlert } from '../components/ui/InlineAlert'
import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal } from './roster/modals'
import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal, ConfirmModal } from './roster/modals'
import { ImportSettings } from './Settings'
import QueryError from '../components/ui/QueryError'
@@ -77,6 +77,8 @@ export default function Roster() {
const [salaryEmployee, setSalaryEmployee] = useState<any>(null)
const [showDeptModal, setShowDeptModal] = useState(false)
const [deptEmployee, setDeptEmployee] = useState<any>(null)
const [showConfirmModal, setShowConfirmModal] = useState(false)
const [confirmEmployee, setConfirmEmployee] = useState<any>(null)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
@@ -210,6 +212,21 @@ export default function Roster() {
},
})
// 转正 mutation
const confirmMutation = useMutation({
mutationFn: (data: { employeeId: string; confirmDate: string; regularSalary?: number }) =>
workProcessApi.create({ type: 'CONFIRM', title: '转正', employeeId: data.employeeId, formData: { employeeId: data.employeeId, confirmDate: data.confirmDate, regularSalary: data.regularSalary }, status: 'DRAFT' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setShowConfirmModal(false)
setConfirmEmployee(null)
toast.success('转正已提交')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '转正失败'),
})
const batchRenewMutation = useMutation({
mutationFn: (data: { contractIds: string[]; years: number }) => employeeApi.batchRenew(data),
onSuccess: () => {
@@ -264,6 +281,49 @@ export default function Roster() {
},
})
/** 批量转正:为选中的试用期员工创建转正草稿 */
const handleBatchConfirm = async () => {
const probationEmployees = employees.filter((e: any) => selectedIds.has(e.id) && e.probationInfo?.isProbation)
if (probationEmployees.length === 0) {
toast.error('选中的员工中没有试用期员工')
return
}
const confirmDate = new Date().toISOString().slice(0, 10)
let success = 0
let failed = 0
await Promise.all(
probationEmployees.map(async (emp: any) => {
try {
await workProcessApi.create({
type: 'CONFIRM',
title: '批量转正',
employeeId: emp.id,
formData: { employeeId: emp.id, confirmDate },
status: 'DRAFT',
})
success++
} catch {
failed++
}
})
)
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
if (success > 0) toast.success(`已为 ${success} 名员工创建转正草稿`)
if (failed > 0) toast.error(`${failed} 名员工转正失败`)
setSelectedIds(new Set())
}
/** 批量开具证明:跳转到用工办理批量开具收入证明 */
const handleBatchCert = () => {
if (selectedIds.size === 0) {
toast.error('请至少选择一名员工')
return
}
const ids = Array.from(selectedIds).join(',')
window.location.href = `/work-process?type=INCOME_CERT&employeeIds=${ids}`
}
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
@@ -450,6 +510,12 @@ export default function Roster() {
<Button variant="secondary" onClick={() => setShowBatchRenewModal(true)} className="shrink-0">
<Check className="mr-1 h-4 w-4" />
</Button>
<Button variant="secondary" onClick={handleBatchConfirm} className="shrink-0">
<CheckCircle className="mr-1 h-4 w-4" />
</Button>
<Button variant="secondary" onClick={handleBatchCert} className="shrink-0">
<FileText className="mr-1 h-4 w-4" />
</Button>
<Button variant="danger" onClick={() => setShowBatchTerminateModal(true)} className="shrink-0">
<UserX className="mr-1 h-4 w-4" />
</Button>
@@ -540,10 +606,10 @@ export default function Roster() {
</td>
<td className="px-4 py-3 font-medium">
<div>{e.name}</div>
<div className="text-gray-400 text-xs font-mono cursor-pointer hover:text-primary transition-colors" title="点击复制完整身份证号" onClick={(ev) => {
<div className="text-gray-400 text-xs font-mono cursor-pointer hover:text-primary transition-colors" title="点击复制完整证件号码" onClick={(ev) => {
ev.stopPropagation()
if (e.idCardNumber) {
copyToClipboard(e.idCardNumber, '已复制身份证号')
copyToClipboard(e.idCardNumber, '已复制证件号码')
}
}}>{e.idCardMasked || '—'}</div>
</td>
@@ -713,18 +779,35 @@ export default function Roster() {
>
<DollarSign 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>
{/* 试用期员工显示转正按钮,其他员工显示发薪按钮 */}
{e.probationInfo?.isProbation ? (
<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()
setConfirmEmployee(e)
setShowConfirmModal(true)
}}
>
<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"
title="调部门"
@@ -738,6 +821,30 @@ export default function Roster() {
>
<Building2 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()
window.location.href = `/work-process?type=INCOME_CERT&employeeId=${e.id}`
}}
>
<FileText 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()
window.location.href = `/work-process?type=RENEW&employeeId=${e.id}`
}}
>
<RotateCcw className="h-4 w-4" />
</button>
<button
type="button"
title="离职"
@@ -865,6 +972,16 @@ export default function Roster() {
/>
)}
{showConfirmModal && confirmEmployee && (
<ConfirmModal
employee={confirmEmployee}
onClose={() => { setShowConfirmModal(false); setConfirmEmployee(null) }}
onSubmit={(data) => confirmMutation.mutate(data)}
loading={confirmMutation.isPending}
error={confirmMutation.error as any}
/>
)}
{showBatchRenewModal && (
<Modal open={showBatchRenewModal} onClose={() => { setShowBatchRenewModal(false); setPreviewData(null); }} title="批量续签">
<div className="space-y-3">