feat: Phase 1-3 优化全部完成

Phase 1 紧急修复(8项):
- 社保城市选择改为可输入
- 社保上下限拆分(三险/医保独立基数)
- 公积金试算结果展示修复
- 花名册合同保存修复(日期ISO格式)
- 薪酬批次创建失败修复(城市过滤+错误处理)
- 证据链查看修复
- 个税计算修复(blank_employees读取基本工资)
- 加班费倍率读取配置

Phase 2 功能完善(3项):
- 批量导入per-row异常捕获+导入按钮
- 单人发薪UI入口优化
- 解除协议模板补充(员工提出离职版)

Phase 3 后期规划(4项):
- 工资表导入功能(POST /import/payroll + 前端入口)
- 大病险/长护险附加险种(extraInsurances JSON + 计算适配)
- 专项附加扣除按月录入(SpecialDeductionRecord模型 + 前端Tab)
- 预置河北省社保政策(seed数据)
This commit is contained in:
selfrelease
2026-07-27 18:55:08 +08:00
parent 034fcc4111
commit 255af519d2
16 changed files with 1464 additions and 77 deletions
+80
View File
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Calculator, AlertCircle, Info, Check, Upload, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, X, ChevronLeft, Wallet, LayoutTemplate, Clock, Receipt, Users, TrendingDown, TrendingUp, BadgeCheck } from 'lucide-react'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
@@ -374,6 +375,8 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [showAddEmployee, setShowAddEmployee] = useState(false)
const payrollFileRef = useRef<HTMLInputElement>(null)
const [payrollImportResult, setPayrollImportResult] = useState<any>(null)
const { data: batch, isLoading } = useQuery<any>({
queryKey: ['batch-detail', batchId],
@@ -423,6 +426,33 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
},
})
const importPayrollMutation = useMutation({
mutationFn: async (file: File) => {
const token = useAuthStore.getState().accessToken
const formData = new FormData()
formData.append('file', file)
formData.append('batchId', batchId)
const res = await fetch('/api/v1/import/payroll', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
})
const data = await res.json()
if (!data.success) throw new Error(data.message || '导入失败')
return data.data
},
onSuccess: (data: any) => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
setPayrollImportResult(data)
if (data.updated > 0) {
toast.success(`成功更新 ${data.updated} 条工资记录`)
} else {
toast.info('未更新任何记录')
}
},
onError: () => toast.error('工资表导入失败'),
})
const deleteBatchMutation = useMutation({
mutationFn: () => api.delete(`/payroll2/batches/${batchId}`),
onSuccess: () => {
@@ -546,6 +576,33 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
<Button variant="secondary" size="sm" onClick={() => setShowAddEmployee(!showAddEmployee)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
<input
ref={payrollFileRef}
type="file"
accept=".xlsx,.xls"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) importPayrollMutation.mutate(file)
e.target.value = ''
}}
/>
<Button
variant="secondary"
size="sm"
onClick={() => payrollFileRef.current?.click()}
disabled={importPayrollMutation.isPending}
>
<Upload className="w-4 h-4 mr-1" />
{importPayrollMutation.isPending ? '导入中...' : '导入工资表'}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => window.open('/api/v1/import/payroll-template', '_blank')}
>
<Download className="w-4 h-4 mr-1" />
</Button>
<Button
variant="secondary"
size="sm"
@@ -636,6 +693,29 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
<AddEmployeeToBatch batchId={batchId} onClose={() => setShowAddEmployee(false)} />
)}
{/* 工资表导入结果 */}
{payrollImportResult && (
<Card>
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-medium"></h3>
<button onClick={() => setPayrollImportResult(null)} className="text-gray-500"><X className="w-4 h-4" /></button>
</div>
<div className="text-sm space-y-1">
<div className="text-gray-600"> {payrollImportResult.total} {payrollImportResult.updated} </div>
{payrollImportResult.errors?.length > 0 && (
<div className="mt-2">
<div className="text-warning text-xs font-medium">{payrollImportResult.errors.length}</div>
<ul className="mt-1 space-y-0.5 text-xs text-danger max-h-40 overflow-y-auto">
{payrollImportResult.errors.map((err: string, i: number) => (
<li key={i}>{err}</li>
))}
</ul>
</div>
)}
</div>
</Card>
)}
{/* 提示 */}
{!isArchived && (
<div className="text-xs text-gray-500 flex items-center gap-1">