Files
TurboHR/frontend/src/pages/money/BatchTab.tsx
T
freedakgmail 86e5526a83 fix: 优化文档16项问题修复
- 问题1/3: 绩效考核/培训记录员工姓名可点击跳转员工详情页
- 问题2: 离职证明模板支持自定义+员工端下载
- 问题4(P0): 修复工资填写后数据归零问题
- 问题5: 社保添加员工参保信息列表
- 问题6(P0): 商业保险支持为员工参保
- 问题7(P0): 员工福利支持为员工添加福利
- 问题8: 规章制度支持导入Word文档
- 问题9: 文本模板下载Word增加HTML格式
- 问题10: 模板下载变量替换修复(排除token参数)
- 问题11(P0): 电子签署发起时员工下拉框有选项
- 问题12: 新增绩效记录添加考评人选项
- 问题13: 违纪记录添加处罚执行细节
- 问题14: 特殊员工列表添加查看详情按钮和姓名链接
- 问题15: 员工福利汇总正确显示参保人员
- 问题16(P0): 证据链验证修复(递归排序key+自动修复历史哈希)
2026-08-11 22:02:19 +08:00

1291 lines
65 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useRef } from 'react'
import { usePageSize } from '../../hooks/usePageSize'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../../hooks/useConfirm'
import { Calculator, AlertCircle, Info, Upload, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, X, ChevronLeft, Clock, Users, TrendingDown, TrendingUp, BadgeCheck } from 'lucide-react'
import { Stepper, type Step } from '../../components/ui/Stepper'
import { InlineAlert } from '../../components/ui/InlineAlert'
import PageGuide from '../../components/ui/PageGuide'
import { payrollApi, rosterApi, employeeApi } from '../../lib/api-services'
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'
import EmptyState from '../../components/ui/EmptyState'
import Pagination from '../../components/ui/Pagination'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
// ========== 发薪批次管理 ==========
function CustomEmployeeSelector({ selectedIds, onChange }: { selectedIds: string[]; onChange: (ids: string[]) => void }) {
const [search, setSearch] = useState('')
const [filterDept, setFilterDept] = useState('')
const { data: employees } = useQuery<any>({
queryKey: ['roster-for-batch', search, filterDept],
queryFn: async () => {
const res = await rosterApi.list({ pageSize: 999, search, department: filterDept, status: 'ACTIVE' })
return res.data || []
},
})
const { data: deptList } = useQuery<string[]>({
queryKey: ['roster-departments'],
queryFn: () => rosterApi.departments(),
})
const toggle = (id: string) => {
if (selectedIds.includes(id)) {
onChange(selectedIds.filter(x => x !== id))
} else {
onChange([...selectedIds, id])
}
}
const toggleAll = () => {
if (employees && employees.every((e: any) => selectedIds.includes(e.id))) {
onChange(selectedIds.filter(id => !employees.some((e: any) => e.id === id)))
} else {
const newIds = new Set([...selectedIds, ...(employees?.map((e: any) => e.id) || [])])
onChange(Array.from(newIds))
}
}
return (
<div className="border rounded-md p-3 space-y-2">
<div className="flex items-center justify-between">
<Label></Label>
<span className="text-xs text-gray-500"> {selectedIds.length} </span>
</div>
<div className="flex gap-2">
<Input placeholder="搜索姓名" value={search} onChange={(e) => setSearch(e.target.value)} className="!w-40" />
<select value={filterDept} onChange={(e) => setFilterDept(e.target.value)} className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm">
<option value=""></option>
{deptList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
{employees && employees.length > 0 && (
<button onClick={toggleAll} className="text-xs text-primary hover:underline whitespace-nowrap">
{employees.every((e: any) => selectedIds.includes(e.id)) ? '取消全选' : '全选当前'}
</button>
)}
</div>
<div className="max-h-48 overflow-y-auto border rounded">
{employees && employees.length > 0 ? (
<table className="w-full text-xs">
<tbody>
{employees.map((e: any) => (
<tr key={e.id} className="border-b last:border-0 hover:bg-gray-50 cursor-pointer" onClick={() => toggle(e.id)}>
<td className="px-2 py-1.5 w-8">
<input type="checkbox" checked={selectedIds.includes(e.id)} onChange={() => toggle(e.id)} />
</td>
<td className="px-2 py-1.5 font-medium">{e.name}</td>
<td className="px-2 py-1.5 text-gray-500">{e.department}</td>
<td className="px-2 py-1.5 text-gray-400 text-right">¥{fmt(e.monthlySalary)}</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="py-4 text-center text-gray-400 text-xs"></div>
)}
</div>
</div>
)
}
export function BatchManager() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [monthFrom, setMonthFrom] = useState('')
const [monthTo, setMonthTo] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [filterStatus, setFilterStatus] = useState<string>('')
const [filterType, setFilterType] = useState<string>('')
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [createType, setCreateType] = useState<'REGULAR' | 'TERMINATION' | 'BONUS' | 'SEVERANCE'>('REGULAR')
const [createMode, setCreateMode] = useState<'copy_last' | 'blank_employees' | 'blank_all' | 'copy_batch' | 'custom'>('copy_last')
const [sourceBatchId, setSourceBatchId] = useState<string>('')
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<string[]>([])
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const { data: checkResult } = useQuery<any>({
queryKey: ['batch-check', month],
queryFn: async () => {
return await payrollApi.batchCheck(month)
},
})
const { data: batches, isLoading } = useQuery<any[]>({
queryKey: ['batches', month, monthFrom, monthTo, dateFrom, dateTo, filterStatus, filterType],
queryFn: async () => {
const params: any = {}
if (month && !monthFrom && !monthTo && !dateFrom && !dateTo) params.month = month
if (monthFrom) params.monthFrom = monthFrom
if (monthTo) params.monthTo = monthTo
if (dateFrom) params.dateFrom = dateFrom
if (dateTo) params.dateTo = dateTo
if (filterStatus) params.status = filterStatus
if (filterType) params.type = filterType
return await payrollApi.batches(params)
},
})
const { data: archivedBatches } = useQuery<any[]>({
queryKey: ['archived-batches'],
queryFn: async () => {
return await payrollApi.archivedBatches()
},
enabled: createMode === 'copy_batch',
})
const createMutation = useMutation({
mutationFn: (data: any) => payrollApi.createBatch(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
setShowCreateModal(false)
toast.success('发薪批次已创建')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
})
const deleteBatchMutation = useMutation({
mutationFn: (batchId: string) => payrollApi.removeBatch(batchId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
toast.success('批次已删除')
},
onError: () => toast.error('删除失败'),
})
const renameBatchMutation = useMutation({
mutationFn: ({ batchId, name }: { batchId: string; name: string }) =>
payrollApi.renameBatch(batchId, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batches'] })
toast.success('批次名称已更新')
},
onError: () => toast.error('重命名失败'),
})
const unarchiveBatchMutation = useMutation({
mutationFn: (batchId: string) => payrollApi.unarchiveBatch(batchId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
toast.success('已取消归档,批次恢复为草稿状态')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '取消归档失败'),
})
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
if (selectedBatchId) {
return <BatchDetail batchId={selectedBatchId} onBack={() => {
queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
setSelectedBatchId(null)
}} />
}
return (
<div className="space-y-3">
<PageGuide>
/// Excel
</PageGuide>
{/* 重复发薪提醒 */}
{checkResult?.hasArchivedBatch && (
<div className="flex items-center gap-2 p-2 rounded-md bg-amber-50 text-warning text-xs">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{checkResult.archivedCount} {checkResult.payslipsPublished ? ',工资条已发布' : ''}
</div>
)}
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1 shrink-0">
<span className="text-xs text-gray-500"></span>
<Input type="month" value={monthFrom} onChange={(e) => { setMonthFrom(e.target.value); setMonth('') }} className="!w-36" />
</div>
<div className="flex items-center gap-1 shrink-0">
<span className="text-xs text-gray-500"></span>
<Input type="month" value={monthTo} onChange={(e) => { setMonthTo(e.target.value); setMonth('') }} className="!w-36" />
</div>
{!monthFrom && !monthTo && !dateFrom && !dateTo && (
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-36 shrink-0" placeholder="单月" />
)}
<div className="flex items-center gap-1 shrink-0">
<span className="text-xs text-gray-500"></span>
<Input type="date" value={dateFrom} onChange={(e) => { setDateFrom(e.target.value); setMonth('') }} className="!w-36" placeholder="起始" />
<span className="text-xs text-gray-500">~</span>
<Input type="date" value={dateTo} onChange={(e) => { setDateTo(e.target.value); setMonth('') }} className="!w-36" placeholder="截止" />
</div>
<Select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="!w-28 shrink-0">
<option value=""></option>
<option value="DRAFT">稿</option>
<option value="ARCHIVED"></option>
</Select>
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="!w-28 shrink-0">
<option value=""></option>
<option value="REGULAR"></option>
<option value="TERMINATION"></option>
<option value="BONUS"></option>
<option value="SEVERANCE"></option>
</Select>
{(monthFrom || monthTo || dateFrom || dateTo || filterStatus || filterType) && (
<button onClick={() => { setMonthFrom(''); setMonthTo(''); setDateFrom(''); setDateTo(''); setFilterStatus(''); setFilterType(''); setMonth(new Date().toISOString().slice(0, 7)) }} className="text-xs text-gray-500 hover:text-primary shrink-0">
</button>
)}
{batches && batches.length > 0 && (
<span className="text-xs text-gray-500 whitespace-nowrap shrink-0">{batches.length} </span>
)}
<div className="flex-1" />
<Button onClick={() => {
setCreateType('REGULAR'); setShowCreateModal(true)
}} className="shrink-0">
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{showCreateModal && (
<Card>
<h2 className="text-xs font-medium mb-3"></h2>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Select value={createType} onChange={(e) => setCreateType(e.target.value as any)}>
<option value="REGULAR"></option>
<option value="TERMINATION"></option>
<option value="BONUS">/</option>
<option value="SEVERANCE"></option>
</Select>
</div>
<div>
<Label></Label>
<Select value={createMode} onChange={(e) => { setCreateMode(e.target.value as any); setSourceBatchId(''); setSelectedEmployeeIds([]) }}>
<option value="copy_last"></option>
<option value="blank_employees">0</option>
<option value="blank_all"></option>
<option value="copy_batch"></option>
<option value="custom"> /</option>
</Select>
</div>
</div>
{createMode === 'copy_batch' && (
<div>
<Label></Label>
<Select value={sourceBatchId} onChange={(e) => setSourceBatchId(e.target.value)}>
<option value=""></option>
{archivedBatches?.map((b: any) => (
<option key={b.id} value={b.id}>{b.name}{b.month} · {b.employeeCount} · ¥{fmt(b.totalPay)}</option>
))}
</Select>
{!archivedBatches?.length && (
<p className="text-xs text-gray-500 mt-1"></p>
)}
</div>
)}
{createMode === 'custom' && (
<CustomEmployeeSelector selectedIds={selectedEmployeeIds} onChange={setSelectedEmployeeIds} />
)}
<div className="text-xs text-gray-500 space-y-0.5">
{createMode === 'copy_last' && <p>//</p>}
{createMode === 'blank_employees' && <p>0</p>}
{createMode === 'blank_all' && <p></p>}
{createMode === 'copy_batch' && <p></p>}
{createMode === 'custom' && <p className="text-primary font-medium"></p>}
</div>
<div className="flex gap-2">
<Button
onClick={() => createMutation.mutate({ month, type: createType, mode: createMode, sourceBatchId: sourceBatchId || undefined, employeeIds: createMode === 'custom' ? selectedEmployeeIds : undefined })}
disabled={createMutation.isPending || (createMode === 'copy_batch' && !sourceBatchId) || (createMode === 'custom' && selectedEmployeeIds.length === 0)}
>
{createMutation.isPending ? '创建中...' : '确认创建'}
</Button>
<Button variant="secondary" onClick={() => setShowCreateModal(false)}></Button>
</div>
</div>
</Card>
)}
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !batches || batches.length === 0 ? (
<EmptyState title="本月暂无发薪批次" description="点击「创建发薪批次」开始" />
) : (
<div className="space-y-3">
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-500">
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3 text-right"></th>
</tr>
</thead>
<tbody>
{batches.slice((page - 1) * pageSize, page * pageSize).map((batch: any) => (
<tr key={batch.id} className="border-b hover:bg-gray-50 cursor-pointer" onClick={() => setSelectedBatchId(batch.id)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSelectedBatchId(batch.id) }}>
<td className="py-2.5 px-3">
<div className="flex items-center gap-2">
<Layers className="w-4 h-4 text-primary flex-shrink-0" />
{renamingId === batch.id ? (
<input
type="text"
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
if (renameValue.trim() && renameValue !== batch.name) {
renameBatchMutation.mutate({ batchId: batch.id, name: renameValue.trim() })
}
setRenamingId(null)
}
if (e.key === 'Escape') setRenamingId(null)
}}
onBlur={() => {
if (renameValue.trim() && renameValue !== batch.name) {
renameBatchMutation.mutate({ batchId: batch.id, name: renameValue.trim() })
}
setRenamingId(null)
}}
className="text-sm border-b border-primary bg-transparent px-1 py-0.5 focus:outline-none"
/>
) : (
<span className="text-sm font-medium">{batch.name}</span>
)}
</div>
</td>
<td className="py-2.5 px-3 text-xs text-gray-600">
{batch.type === 'REGULAR' ? '常规发薪' : batch.type === 'BONUS' ? '年终奖/奖金' : batch.type === 'TERMINATION' ? '离职结算' : batch.type === 'SEVERANCE' ? '补偿金' : batch.type}
</td>
<td className="py-2.5 px-3 text-right text-sm">{batch.employeeCount}</td>
<td className="py-2.5 px-3 text-right text-sm font-medium text-primary">¥{fmt(batch.totalPay)}</td>
<td className="py-2.5 px-3 text-right text-sm text-amber-600">¥{fmt((batch.totalSocialOrg || 0) + (batch.totalSocialEmp || 0))}</td>
<td className="py-2.5 px-3 text-right text-sm text-cyan-600">¥{fmt((batch.totalHousingOrg || 0) + (batch.totalHousingEmp || 0))}</td>
<td className="py-2.5 px-3 text-right text-sm text-danger">¥{fmt(batch.totalTax)}</td>
<td className="py-2.5 px-3 text-right text-sm font-bold text-safe">¥{fmt(batch.totalNetPay)}</td>
<td className="py-2.5 px-3 text-xs text-gray-500">{batch.createdAt ? new Date(batch.createdAt).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-'}</td>
<td className="py-2.5 px-3">
{batch.status === 'ARCHIVED' ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-green-50 text-safe">
<Archive className="w-3 h-3" />
</span>
) : (
<span className="px-2 py-0.5 rounded text-xs bg-amber-50 text-warning">稿</span>
)}
</td>
<td className="py-2.5 px-3">
<div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
{batch.status !== 'ARCHIVED' && (
<>
<button
onClick={() => { setRenamingId(batch.id); setRenameValue(batch.name) }}
className="p-1 rounded hover:bg-blue-50 text-gray-500 hover:text-primary transition-colors"
aria-label="重命名"
title="重命名"
>
<SettingsIcon className="w-3.5 h-3.5" />
</button>
<button
onClick={async () => {
if (await confirm({ title: '删除批次', message: `确认删除批次「${batch.name}」?此操作不可撤销。` })) {
deleteBatchMutation.mutate(batch.id)
}
}}
disabled={deleteBatchMutation.isPending}
className="p-1 rounded hover:bg-red-50 text-gray-500 hover:text-danger transition-colors"
aria-label="删除"
title="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
{batch.status === 'ARCHIVED' && (
<button
onClick={async () => {
if (await confirm({ title: '取消归档', message: `确认取消归档「${batch.name}」?取消后批次恢复为草稿状态,对应的工资条和个税累计将被撤销。`, variant: 'primary' })) {
unarchiveBatchMutation.mutate(batch.id)
}
}}
disabled={unarchiveBatchMutation.isPending}
className="p-1 rounded hover:bg-amber-50 text-gray-500 hover:text-warning transition-colors"
aria-label="取消归档"
title="取消归档"
>
<Archive className="w-3.5 h-3.5" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<Pagination page={page} pageSize={pageSize} total={batches.length} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</div>
)}
</div>
)
}
function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [editCell, setEditCell] = useState<{ employeeId: string; field: string } | null>(null)
const [editValue, setEditValue] = useState<string>('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [showAddEmployee, setShowAddEmployee] = useState(false)
const payrollFileRef = useRef<HTMLInputElement>(null)
const [payrollImportResult, setPayrollImportResult] = useState<any>(null)
const [taxDetailFor, setTaxDetailFor] = useState<{ employeeId: string; name: string } | null>(null)
const [taxDetailData, setTaxDetailData] = useState<any>(null)
const [taxDetailLoading, setTaxDetailLoading] = useState(false)
const { data: batch, isLoading } = useQuery<any>({
queryKey: ['batch-detail', batchId],
queryFn: async () => {
return await payrollApi.batchDetail(batchId)
},
})
const updateEntryMutation = useMutation({
mutationFn: ({ employeeId, data }: { employeeId: string; data: any }) =>
payrollApi.updateBatchEntry(batchId, employeeId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
},
onError: (err: any) => {
toast.error(err?.response?.data?.error?.message || '保存失败,请重试')
},
})
const removeEmployeeMutation = useMutation({
mutationFn: (employeeId: string) => payrollApi.removeBatchEmployee(batchId, employeeId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
},
})
const archiveMutation = useMutation({
mutationFn: () => payrollApi.archiveBatch(batchId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
toast.success('批次已归档。归档后批次锁定不可编辑。可前往「工资条管理」生成工资条。')
},
})
const unarchiveMutation = useMutation({
mutationFn: () => payrollApi.unarchiveBatch(batchId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
toast.success('已取消归档,批次恢复为草稿状态')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '取消归档失败'),
})
const publishPayslipMutation = useMutation({
mutationFn: () => payrollApi.publishPayslip(batchId),
onSuccess: (res: any) => {
toast.success(`已发布 ${res.data?.published || 0} 条工资条`)
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '发布失败'),
})
const [showScheduleModal, setShowScheduleModal] = useState(false)
const [scheduleDate, setScheduleDate] = useState('')
const schedulePayslipMutation = useMutation({
mutationFn: () => payrollApi.schedulePayslip(batchId, scheduleDate),
onSuccess: (res: any) => {
toast.success(`已设定定时发送 ${res.data?.scheduled || 0} 条工资条`)
setShowScheduleModal(false)
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '设定失败'),
})
const importOvertimeMutation = useMutation({
mutationFn: () => payrollApi.importOvertimeToBatch(batchId),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
if (res.data?.imported > 0) {
toast.success(`成功导入 ${res.data.imported} 条加班费记录`)
} else {
toast.info(res.data?.message || '没有可导入的加班记录')
}
},
onError: () => {
toast.error('导入失败,请重试')
},
})
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'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
setPayrollImportResult(data)
if (data.updated > 0) {
toast.success(`成功更新 ${data.updated} 条工资记录`)
} else {
toast.info('未更新任何记录')
}
},
onError: () => toast.error('工资表导入失败'),
})
const deleteBatchMutation = useMutation({
mutationFn: () => payrollApi.removeBatch(batchId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
onBack()
},
})
if (isLoading) return <div className="text-center py-8 text-gray-500">...</div>
if (!batch) return <div className="text-center py-8 text-gray-500"></div>
const isArchived = batch.status === 'ARCHIVED'
const isBonus = batch.type === 'BONUS'
const handleTaxDetailClick = async (employeeId: string, name: string) => {
setTaxDetailFor({ employeeId, name })
setTaxDetailData(null)
setTaxDetailLoading(true)
try {
const data = await payrollApi.taxDetail(batchId, employeeId)
setTaxDetailData(data)
} catch {
toast.error('获取个税明细失败')
} finally {
setTaxDetailLoading(false)
}
}
// 可编辑的输入项字段
const editableFields = isBonus
? ['bonus']
: ['baseSalary', 'overtimePay', 'allowance', 'deduction', 'bonus', 'socialEmp', 'housingEmp', 'socialOrg', 'housingOrg']
// 点击单元格进入编辑
const startEdit = (employeeId: string, field: string, currentValue: number) => {
if (isArchived || !editableFields.includes(field)) return
setEditCell({ employeeId, field })
setEditValue(currentValue.toFixed(2))
}
// 保存编辑
const saveEdit = () => {
if (!editCell) return
const { employeeId, field } = editCell
const numValue = Number(editValue) || 0
const entry = batch.entries.find((e: any) => e.employeeId === employeeId)
if (!entry) { setEditCell(null); return }
const data: any = {}
// 输入项字段一起提交
const inputFields = isBonus ? ['bonus'] : ['baseSalary', 'overtimePay', 'allowance', 'deduction', 'bonus']
inputFields.forEach(f => {
data[f] = f === field ? numValue : entry[f]
})
// 社保公积金字段:编辑哪个提交哪个
const socialFields = ['socialEmp', 'housingEmp', 'socialOrg', 'housingOrg']
if (socialFields.includes(field)) {
data[field] = numValue
}
updateEntryMutation.mutate({ employeeId, data })
setEditCell(null)
}
// 失焦保存
const handleBlur = () => saveEdit()
// Enter 保存,Esc 取消
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') { e.preventDefault(); saveEdit() }
if (e.key === 'Escape') setEditCell(null)
}
// 渲染可编辑单元格
const renderCell = (entry: any, field: string, className: string = '') => {
const isEditing = editCell?.employeeId === entry.employeeId && editCell?.field === field
const canEdit = !isArchived && editableFields.includes(field)
const value = entry[field] || 0
const displayValue = field === 'deduction' && value > 0 ? '-' + fmt(value) : fmt(value)
if (isEditing) {
return (
<td className="py-1 px-1 text-right" key={field}>
<input
type="text"
inputMode="decimal"
autoFocus
className="w-24 text-right border-b-2 border-primary bg-transparent px-1 py-0.5 text-xs focus:outline-none [appearance:none]"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
</td>
)
}
return (
<td
key={field}
className={`py-2 px-2 text-right ${className} ${canEdit ? 'cursor-text' : ''}`}
onClick={() => canEdit && startEdit(entry.employeeId, field, value)}
>
{canEdit ? (
<span className={`border-b border-dashed border-gray-300 hover:border-primary ${field === 'deduction' && value > 0 ? 'text-danger' : ''}`}>
{displayValue}
</span>
) : field === 'deduction' && value > 0 ? (
<span className="text-danger">{displayValue}</span>
) : (
displayValue
)}
</td>
)
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={onBack} className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm transition-colors">
<ChevronLeft className="w-4 h-4" />
</button>
<h2 className="text-sm font-medium">{batch.name}</h2>
{isArchived ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-green-50 text-safe">
<Archive className="w-3 h-3" />
</span>
) : (
<span className="px-2 py-0.5 rounded text-xs bg-amber-50 text-warning">稿</span>
)}
</div>
{!isArchived && (
<div className="flex gap-2">
<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={async () => {
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/import/payroll-template`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '工资表导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch {
toast.error('下载模板失败')
}
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => importOvertimeMutation.mutate()}
disabled={importOvertimeMutation.isPending}
>
<Calculator className="w-4 h-4 mr-1" />
{importOvertimeMutation.isPending ? '导入中...' : '导入加班费'}
</Button>
<Button
size="sm"
onClick={async () => {
if (await confirm({ title: '归档确认', message: '确认归档?归档后批次将锁定不可编辑。工资条需在「工资条管理」中单独生成。', variant: 'primary' })) {
archiveMutation.mutate()
}
}}
disabled={archiveMutation.isPending}
>
<Archive className="w-4 h-4 mr-1" />
{archiveMutation.isPending ? '归档中...' : '归档'}
</Button>
<Button
variant="danger"
size="sm"
onClick={async () => {
if (await confirm({ title: '删除批次', message: `确认删除批次「${batch.name}」?此操作不可撤销。` })) {
deleteBatchMutation.mutate()
}
}}
disabled={deleteBatchMutation.isPending}
>
<Trash2 className="w-4 h-4 mr-1" />
{deleteBatchMutation.isPending ? '删除中...' : '删除'}
</Button>
</div>
)}
{isArchived && (
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={async () => {
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/payroll2/batches/${batchId}/export?format=csv`, { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) throw new Error('导出失败')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `银行代发文件-${batch.month}-批次${batch.batchNo}.csv`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出失败') }
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={async () => {
try {
const res = await payrollApi.batchSummary(batchId) as any
const { departments, grandTotal } = res
const headers = ['部门', '人数', '应发合计', '实发合计', '个人社保', '单位社保', '个人公积金', '单位公积金', '个税合计']
const rows = departments.map((d: any) => [
d.department, d.headcount, d.totalPay.toFixed(2), d.totalNetPay.toFixed(2),
d.totalSocialEmp.toFixed(2), d.totalSocialOrg.toFixed(2),
d.totalHousingEmp.toFixed(2), d.totalHousingOrg.toFixed(2), d.totalTax.toFixed(2),
])
rows.push(['合计', grandTotal.headcount, grandTotal.totalPay.toFixed(2), grandTotal.totalNetPay.toFixed(2),
grandTotal.totalSocialEmp.toFixed(2), grandTotal.totalSocialOrg.toFixed(2),
grandTotal.totalHousingEmp.toFixed(2), grandTotal.totalHousingOrg.toFixed(2), grandTotal.totalTax.toFixed(2)])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `salary-summary-${batch.month}.csv`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出汇总表失败') }
}}
>
<FileText className="w-4 h-4 mr-1" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={async () => {
try {
const res = await payrollApi.batchDetailExport(batchId) as any
const { details } = res
const headers = ['姓名', '部门', '基本工资', '岗位工资', '绩效工资', '工龄工资', '加班费', '交通补贴', '餐补', '住房补贴', '通讯补贴', '其他津贴', '奖金', '扣款', '其他扣款', '个人社保', '个人公积金', '个税', '应发合计', '实发工资']
const rows = details.map((d: any) => [
d.name, d.department,
d.baseSalary.toFixed(2), d.positionSalary.toFixed(2), d.performanceSalary.toFixed(2),
d.senioritySalary.toFixed(2), d.overtimePay.toFixed(2),
d.transportAllowance.toFixed(2), d.mealAllowance.toFixed(2), d.housingAllowance.toFixed(2),
d.communicationAllowance.toFixed(2), d.allowance.toFixed(2), d.bonus.toFixed(2),
d.deduction.toFixed(2), d.otherDeduction.toFixed(2),
d.socialEmp.toFixed(2), d.housingEmp.toFixed(2), d.tax.toFixed(2),
d.totalPay.toFixed(2), d.netPay.toFixed(2),
])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `salary-detail-${batch.month}.csv`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出明细表失败') }
}}
>
<FileText className="w-4 h-4 mr-1" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={async () => {
if (await confirm({ title: '取消归档', message: '确认取消归档?取消后批次恢复为草稿状态,对应的工资条和个税累计将被撤销。', variant: 'primary' })) {
unarchiveMutation.mutate()
}
}}
disabled={unarchiveMutation.isPending}
>
{unarchiveMutation.isPending ? '取消中...' : '取消归档'}
</Button>
<Button
size="sm"
onClick={async () => {
if (await confirm({ title: '发布工资条', message: `确认发布 ${batch.month} 月工资条?发布后员工可在员工端查看。`, variant: 'primary' })) {
publishPayslipMutation.mutate()
}
}}
disabled={publishPayslipMutation.isPending}
>
{publishPayslipMutation.isPending ? '发布中...' : '发布工资条'}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setShowScheduleModal(true)}
>
<Clock className="w-4 h-4 mr-1" />
</Button>
</div>
)}
</div>
{/* 发薪工作流步骤条 */}
<Card className="p-4">
<Stepper
steps={[
{ key: 'edit', title: '编辑薪资', description: '填写/导入工资数据', status: isArchived ? 'complete' : 'current' },
{ key: 'review', title: '核对汇总', description: '检查应发/个税/实发', status: isArchived ? 'complete' : 'pending' },
{ key: 'archive', title: '归档锁定', description: '归档后不可编辑', status: isArchived ? 'complete' : 'pending' },
{ key: 'publish', title: '发布工资条', description: '员工端可见', status: batch.payslipPublished ? 'complete' : 'pending' },
] as Step[]}
/>
</Card>
{/* 质量门禁 — 草稿状态下检查异常 */}
{!isArchived && batch.entries.length > 0 && (
<>
{batch.entries.some((e: any) => e.totalPay > 0 && e.netPay <= 0) && (
<InlineAlert type="error" title="存在实发为负的员工">
/ 0
</InlineAlert>
)}
{batch.entries.some((e: any) => e.baseSalary === 0 && e.bonus === 0 && e.totalPay === 0) && (
<InlineAlert type="warning" title="存在全零记录">
0
</InlineAlert>
)}
{batch.entries.some((e: any) => e.riskWarnings && e.riskWarnings.length > 0) && (
<InlineAlert type="warning" title="存在风险预警">
/
</InlineAlert>
)}
</>
)}
{/* 批次汇总 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center flex-shrink-0">
<Users className="w-5 h-5 text-blue-600" />
</div>
<div>
<div className="text-lg font-bold">{batch.employeeCount}</div>
<div className="text-xs text-gray-500"></div>
</div>
</Card>
<Card className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-indigo-50 flex items-center justify-center flex-shrink-0">
<TrendingUp className="w-5 h-5 text-indigo-600" />
</div>
<div>
<div className="text-lg font-bold text-primary">¥{fmt(batch.totalPay)}</div>
<div className="text-xs text-gray-500"></div>
</div>
</Card>
<Card className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-red-50 flex items-center justify-center flex-shrink-0">
<TrendingDown className="w-5 h-5 text-red-600" />
</div>
<div>
<div className="text-lg font-bold text-danger">¥{fmt(batch.totalTax)}</div>
<div className="text-xs text-gray-500"></div>
</div>
</Card>
<Card className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-green-50 flex items-center justify-center flex-shrink-0">
<BadgeCheck className="w-5 h-5 text-green-600" />
</div>
<div>
<div className="text-lg font-bold text-safe">¥{fmt(batch.totalNetPay)}</div>
<div className="text-xs text-gray-500"></div>
</div>
</Card>
</div>
{/* 添加人员 */}
{showAddEmployee && !isArchived && (
<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">
<Info className="w-3.5 h-3.5" />
线/
</div>
)}
{/* 人员表格 */}
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-500">
<th className="py-2 px-2"></th>
<th className="py-2 px-2 text-right"></th>
<th className="py-2 px-2 text-right"></th>
<th className="py-2 px-2 text-right"></th>
{isBonus && <th className="py-2 px-2 text-right"></th>}
{!isBonus && <th className="py-2 px-2 text-right"></th>}
<th className="py-2 px-2 text-right"></th>
<th className="py-2 px-2 text-right text-gray-500"></th>
<th className="py-2 px-2 text-right">()</th>
<th className="py-2 px-2 text-right">()</th>
<th className="py-2 px-2 text-right text-gray-500"></th>
<th className="py-2 px-2 text-right text-gray-500"></th>
<th className="py-2 px-2"></th>
{!isArchived && <th className="py-2 px-2"></th>}
</tr>
</thead>
<tbody>
{batch.entries.slice((page - 1) * pageSize, page * pageSize).map((entry: any) => (
<tr key={entry.id} className="border-b last:border-0 hover:bg-gray-25">
<td className="py-2 px-2">
<div className="text-xs font-medium">{entry.employee.name}</div>
<div className="text-xs text-gray-500">{entry.employee.department}</div>
{entry.employee.status === 'RESIGNED' && (
<span className="text-xs text-danger"></span>
)}
</td>
{renderCell(entry, 'baseSalary')}
{renderCell(entry, 'overtimePay')}
{renderCell(entry, 'allowance')}
{renderCell(entry, 'bonus')}
{renderCell(entry, 'deduction')}
{/* 计算项 - 灰显 */}
<td className="py-2 px-2 text-right font-medium text-gray-600">{fmt(entry.totalPay)}</td>
{renderCell(entry, 'socialEmp')}
{renderCell(entry, 'housingEmp')}
<td className="py-2 px-2 text-right text-gray-500 cursor-pointer hover:text-primary hover:underline" onClick={() => handleTaxDetailClick(entry.employeeId, entry.employee.name)} title="点击查看个税计算明细">{fmt(entry.tax)}</td>
<td className="py-2 px-2 text-right font-bold text-safe">{fmt(entry.netPay)}</td>
<td className="py-2 px-2">
{entry.riskWarnings && entry.riskWarnings.length > 0 ? (
<span className="text-danger cursor-help" title={entry.riskWarnings.join('\n')}>
<AlertTriangle className="w-4 h-4" />
</span>
) : (
<span className="text-gray-300"></span>
)}
</td>
{!isArchived && (
<td className="py-2 px-2">
<button
onClick={() => removeEmployeeMutation.mutate(entry.employeeId)}
className="text-gray-500 hover:text-danger p-1"
title="移除"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
{batch.entries.length > 0 && (
<Pagination page={page} pageSize={pageSize} total={batch.entries.length} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
)}
</Card>
{/* 定时发送弹窗 */}
{showScheduleModal && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setShowScheduleModal(false)}>
<Card className="max-w-md w-full" >
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium"></h3>
<button onClick={() => setShowScheduleModal(false)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
</div>
<p className="text-xs text-gray-500 mb-3"></p>
<div className="space-y-3">
<div>
<Label></Label>
<Input
type="datetime-local"
value={scheduleDate}
onChange={(e) => setScheduleDate(e.target.value)}
/>
</div>
<Button
size="sm"
onClick={() => {
if (!scheduleDate) { toast.error('请选择发送时间'); return }
schedulePayslipMutation.mutate()
}}
disabled={schedulePayslipMutation.isPending}
>
{schedulePayslipMutation.isPending ? '设定中...' : '确认定时发送'}
</Button>
</div>
</div>
</Card>
</div>
)}
{/* 个税计算明细弹窗 */}
{taxDetailFor && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setTaxDetailFor(null)}>
<Card className="max-w-lg w-full max-h-[85vh] overflow-y-auto" >
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium"> {taxDetailFor.name}</h3>
<button onClick={() => setTaxDetailFor(null)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
</div>
{taxDetailLoading ? (
<div className="text-center py-6 text-gray-500">...</div>
) : taxDetailData ? (
<div className="space-y-2 text-xs">
<div className="bg-gray-50 rounded-md p-2 text-gray-600">{taxDetailData.method}</div>
{taxDetailData.method === '累计预扣法' ? (
<table className="w-full text-xs">
<tbody>
<tr className="border-b"><td className="py-1.5 text-gray-500"></td><td className="py-1.5 text-right font-medium">¥{fmt(taxDetailData.ytdIncome)}</td></tr>
<tr className="border-b"><td className="py-1.5 text-gray-500">5000×{taxDetailData.month}</td><td className="py-1.5 text-right">-¥{fmt(taxDetailData.deductionAmount)}</td></tr>
<tr className="border-b"><td className="py-1.5 text-gray-500"></td><td className="py-1.5 text-right">-¥{fmt(taxDetailData.ytdSocialEmp)}</td></tr>
<tr className="border-b"><td className="py-1.5 text-gray-500"></td><td className="py-1.5 text-right">-¥{fmt(taxDetailData.ytdHousingEmp)}</td></tr>
<tr className="border-b"><td className="py-1.5 text-gray-500"></td><td className="py-1.5 text-right">-¥{fmt(taxDetailData.ytdSpecialDeduction)}</td></tr>
<tr className="border-b bg-blue-50"><td className="py-1.5 font-medium"></td><td className="py-1.5 text-right font-medium text-primary">¥{fmt(taxDetailData.ytdTaxableIncome)}</td></tr>
<tr className="border-b"><td className="py-1.5 text-gray-500"></td><td className="py-1.5 text-right">¥{fmt(taxDetailData.ytdTaxDeducted)}</td></tr>
<tr className="bg-amber-50"><td className="py-1.5 font-medium text-danger"></td><td className="py-1.5 text-right font-bold text-danger">¥{fmt(taxDetailData.currentMonthTax)}</td></tr>
</tbody>
</table>
) : (
<table className="w-full text-xs">
<tbody>
<tr className="border-b"><td className="py-1.5 text-gray-500"></td><td className="py-1.5 text-right font-medium">¥{fmt(taxDetailData.bonus)}</td></tr>
<tr className="bg-amber-50"><td className="py-1.5 font-medium text-danger"></td><td className="py-1.5 text-right font-bold text-danger">¥{fmt(taxDetailData.tax)}</td></tr>
</tbody>
</table>
)}
{taxDetailData.archivedCount === 0 && taxDetailData.method === '累计预扣法' && (
<div className="text-xs text-gray-400 bg-gray-50 rounded p-2">
</div>
)}
{/* 社保公积金:系统计算值 vs 实际值对比 */}
{(taxDetailData.systemSocialEmp !== undefined || taxDetailData.systemHousingEmp !== undefined) && (
<div className="border-t pt-2 mt-2">
<div className="font-medium text-gray-700 mb-1"></div>
<table className="w-full text-xs">
<thead>
<tr className="border-b text-gray-500">
<th className="py-1 text-left"></th>
<th className="py-1 text-right"></th>
<th className="py-1 text-right"></th>
<th className="py-1 text-right"></th>
</tr>
</thead>
<tbody>
{taxDetailData.systemSocialEmp !== undefined && (
<tr className="border-b">
<td className="py-1 text-gray-500"></td>
<td className="py-1 text-right text-gray-500">¥{fmt(taxDetailData.systemSocialEmp)}</td>
<td className="py-1 text-right font-medium">¥{fmt(taxDetailData.actualSocialEmp)}</td>
<td className={`py-1 text-right ${Math.abs((taxDetailData.actualSocialEmp || 0) - (taxDetailData.systemSocialEmp || 0)) > 0.01 ? 'text-amber-600 font-medium' : 'text-gray-300'}`}>{Math.abs((taxDetailData.actualSocialEmp || 0) - (taxDetailData.systemSocialEmp || 0)) > 0.01 ? (taxDetailData.actualSocialEmp > taxDetailData.systemSocialEmp ? '+' : '') + '¥' + fmt((taxDetailData.actualSocialEmp || 0) - (taxDetailData.systemSocialEmp || 0)) : '—'}</td>
</tr>
)}
{taxDetailData.systemHousingEmp !== undefined && (
<tr className="border-b">
<td className="py-1 text-gray-500"></td>
<td className="py-1 text-right text-gray-500">¥{fmt(taxDetailData.systemHousingEmp)}</td>
<td className="py-1 text-right font-medium">¥{fmt(taxDetailData.actualHousingEmp)}</td>
<td className={`py-1 text-right ${Math.abs((taxDetailData.actualHousingEmp || 0) - (taxDetailData.systemHousingEmp || 0)) > 0.01 ? 'text-amber-600 font-medium' : 'text-gray-300'}`}>{Math.abs((taxDetailData.actualHousingEmp || 0) - (taxDetailData.systemHousingEmp || 0)) > 0.01 ? (taxDetailData.actualHousingEmp > taxDetailData.systemHousingEmp ? '+' : '') + '¥' + fmt((taxDetailData.actualHousingEmp || 0) - (taxDetailData.systemHousingEmp || 0)) : '—'}</td>
</tr>
)}
{taxDetailData.systemSocialOrg !== undefined && (
<tr className="border-b">
<td className="py-1 text-gray-500"></td>
<td className="py-1 text-right text-gray-500">¥{fmt(taxDetailData.systemSocialOrg)}</td>
<td className="py-1 text-right font-medium">¥{fmt(taxDetailData.actualSocialOrg)}</td>
<td className={`py-1 text-right ${Math.abs((taxDetailData.actualSocialOrg || 0) - (taxDetailData.systemSocialOrg || 0)) > 0.01 ? 'text-amber-600 font-medium' : 'text-gray-300'}`}>{Math.abs((taxDetailData.actualSocialOrg || 0) - (taxDetailData.systemSocialOrg || 0)) > 0.01 ? (taxDetailData.actualSocialOrg > taxDetailData.systemSocialOrg ? '+' : '') + '¥' + fmt((taxDetailData.actualSocialOrg || 0) - (taxDetailData.systemSocialOrg || 0)) : '—'}</td>
</tr>
)}
{taxDetailData.systemHousingOrg !== undefined && (
<tr>
<td className="py-1 text-gray-500"></td>
<td className="py-1 text-right text-gray-500">¥{fmt(taxDetailData.systemHousingOrg)}</td>
<td className="py-1 text-right font-medium">¥{fmt(taxDetailData.actualHousingOrg)}</td>
<td className={`py-1 text-right ${Math.abs((taxDetailData.actualHousingOrg || 0) - (taxDetailData.systemHousingOrg || 0)) > 0.01 ? 'text-amber-600 font-medium' : 'text-gray-300'}`}>{Math.abs((taxDetailData.actualHousingOrg || 0) - (taxDetailData.systemHousingOrg || 0)) > 0.01 ? (taxDetailData.actualHousingOrg > taxDetailData.systemHousingOrg ? '+' : '') + '¥' + fmt((taxDetailData.actualHousingOrg || 0) - (taxDetailData.systemHousingOrg || 0)) : '—'}</td>
</tr>
)}
</tbody>
</table>
<div className="text-xs text-gray-400 mt-1"></div>
</div>
)}
</div>
) : (
<div className="text-center py-6 text-gray-500"></div>
)}
</div>
</Card>
</div>
)}
</div>
)
}
function AddEmployeeToBatch({ batchId, onClose }: { batchId: string; onClose: () => void }) {
const queryClient = useQueryClient()
const [selected, setSelected] = useState<string[]>([])
const { data: employees } = useQuery<any>({
queryKey: ['employees-for-batch'],
queryFn: async () => {
return await employeeApi.paged({ pageSize: 100 })
},
})
const addMutation = useMutation({
mutationFn: (employeeIds: string[]) => payrollApi.addBatchEmployees(batchId, employeeIds),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
onClose()
},
})
return (
<Card>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-medium"></h3>
<button onClick={onClose} className="text-gray-500"></button>
</div>
<div className="max-h-60 overflow-y-auto space-y-1">
{employees?.items?.map((emp: any) => (
<label key={emp.id} className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
checked={selected.includes(emp.id)}
onChange={(e) => {
if (e.target.checked) setSelected([...selected, emp.id])
else setSelected(selected.filter(id => id !== emp.id))
}}
/>
<span className="text-xs">{emp.name} - {emp.department}</span>
</label>
))}
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" onClick={() => addMutation.mutate(selected)} disabled={selected.length === 0 || addMutation.isPending}>
{addMutation.isPending ? '添加中...' : `添加 ${selected.length}`}
</Button>
<Button size="sm" variant="secondary" onClick={onClose}></Button>
</div>
</Card>
)
}