Files
TurboHR/frontend/src/pages/money/BatchTab.tsx
T
selfrelease 63a0a6934b fix: 优化最低工资保护tooltip文案,明确免扣社保/公积金与额外补齐的区分
minWageApplied 包含免扣社保+免扣公积金+额外补齐三部分,
原tooltip统称"补齐"容易误解为额外补了这么多现金。
改为分项显示:免扣社保 ¥X + 免扣公积金 ¥Y + 额外补齐 ¥Z

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 14:35:25 +08:00

1354 lines
70 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, DollarSign } 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 fetchBonusMutation = useMutation({
mutationFn: () => payrollApi.fetchBonusToBatch(batchId),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
if (res.data?.filled > 0) {
toast.success(res.data.message)
} 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
variant="secondary"
size="sm"
onClick={() => fetchBonusMutation.mutate()}
disabled={fetchBonusMutation.isPending || isArchived}
title="从提成奖金模块按月拉取填充奖金字段"
>
<DollarSign className="w-4 h-4 mr-1" />
{fetchBonusMutation.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: '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.minWage > 0 && e.netPay < e.minWage && (e.minWageApplied || 0) === 0) && (
<InlineAlert type="error" title="存在实发低于最低工资的员工">
¥{Math.min(...batch.entries.filter((e: any) => e.minWage > 0 && e.netPay < e.minWage).map((e: any) => e.minWage))}
</InlineAlert>
)}
{batch.entries.some((e: any) => (e.minWageApplied || 0) > 0) && (
<InlineAlert type="warning" title="最低工资保护已触发">
{batch.entries.filter((e: any) => (e.minWageApplied || 0) > 0).length} /
</InlineAlert>
)}
{batch.entries.some((e: any) => (e.prevDeferredSocialEmp || 0) + (e.prevDeferredHousingEmp || 0) + (e.prevDeferredMinWage || 0) > 0) && (
<InlineAlert type="warning" title="本月补扣上月递延">
{batch.entries.filter((e: any) => (e.prevDeferredSocialEmp || 0) + (e.prevDeferredHousingEmp || 0) + (e.prevDeferredMinWage || 0) > 0).length} //
</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 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 ${(entry.minWageApplied || 0) > 0 ? 'text-amber-600' : 'text-safe'}`} title={(entry.minWageApplied || 0) > 0 ? `最低工资保护已触发\n应发 ¥${fmt(entry.totalPay)} → 实发补齐到 ¥${fmt(entry.minWage)}\n免扣社保 ¥${fmt(entry.deferredSocialEmp || 0)} + 免扣公积金 ¥${fmt(entry.deferredHousingEmp || 0)} + 额外补齐 ¥${fmt(entry.deferredMinWage || 0)} = 递延 ¥${fmt((entry.deferredSocialEmp || 0) + (entry.deferredHousingEmp || 0) + (entry.deferredMinWage || 0))}(次月补扣)` : ''}>
{fmt(entry.netPay)}
{(entry.minWageApplied || 0) > 0 && <span className="text-xs text-amber-500 ml-1"></span>}
</td>
{/* 递延扣款明细 */}
<td className="py-2 px-2 text-right text-xs">
{(() => {
const deferred = (entry.deferredSocialEmp || 0) + (entry.deferredHousingEmp || 0) + (entry.deferredMinWage || 0)
const prevDeferred = (entry.prevDeferredSocialEmp || 0) + (entry.prevDeferredHousingEmp || 0) + (entry.prevDeferredMinWage || 0)
if (deferred > 0) {
return <span className="text-amber-600 cursor-help" title={`递延到次月补扣:\n免扣社保 ¥${fmt(entry.deferredSocialEmp || 0)}\n免扣公积金 ¥${fmt(entry.deferredHousingEmp || 0)}\n额外补齐 ¥${fmt(entry.deferredMinWage || 0)}\n合计 ¥${fmt(deferred)}`}> ¥{fmt(deferred)}</span>
}
if (prevDeferred > 0) {
return <span className="text-blue-600 cursor-help" title={`本月补扣上月递延:\n社保 ¥${fmt(entry.prevDeferredSocialEmp || 0)}\n公积金 ¥${fmt(entry.prevDeferredHousingEmp || 0)}\n最低工资补齐 ¥${fmt(entry.prevDeferredMinWage || 0)}\n合计 ¥${fmt(prevDeferred)}`}> ¥{fmt(prevDeferred)}</span>
}
return <span className="text-gray-300"></span>
})()}
</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>
) : (entry.minWageApplied || 0) > 0 ? (
<span className="text-amber-500 cursor-help" title={`最低工资保护已触发\n应发 ¥${fmt(entry.totalPay)} → 实发 ¥${fmt(entry.minWage)}\n递延 ¥${fmt((entry.deferredSocialEmp || 0) + (entry.deferredHousingEmp || 0) + (entry.deferredMinWage || 0))} 次月补扣`}>
<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>
)
}