Files
TurboHR/frontend/src/pages/money/PayslipTab.tsx
T
freedakgmail a2e9ba55c2 feat: 20260809 系统优化 - 全部28项问题修复(P0×6+P1×16+P2×6)
P0: 福利批量参保/离职证明下载防乱码/考勤模板合并Sheet/补卡修改/附件在线查看删除
P1: 分页pageSize修复/离职导出筛选/撤回删除草稿/加班费自动计算/考勤加班汇总/证据链异常详情/制度催办/模板导入Word/社保封顶保底/校验字段提示/职务字段/社保费用明细/弹窗防误关/身份证查重/证明员工下拉/培训批量
P2: 离职流程去重/社保基数覆盖输入/薪税入口改名/添加员工引导/绩效模板清理
2026-08-09 11:59:02 +08:00

315 lines
15 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 } 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, Check, Layers, Settings as X, Download, Eye } from 'lucide-react'
import PageGuide from '../../components/ui/PageGuide'
import { payrollApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { Input, Label } from '../../components/ui/Input'
import Modal from '../../components/ui/Modal'
import Pagination from '../../components/ui/Pagination'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
export function PayslipManager({ filterEmployeeId }: { filterEmployeeId?: string }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [showTaxPreview, setShowTaxPreview] = useState(false)
const [previewData, setPreviewData] = useState({
baseSalary: 0,
overtimePay: 0,
allowance: 0,
deduction: 0,
bonus: 0,
specialDeduction: 0,
})
const { data: payslips, isLoading } = useQuery<any[]>({
queryKey: ['payslips', month, filterEmployeeId],
queryFn: async () => {
const all = await payrollApi.payslips({ month })
if (!filterEmployeeId) return all
return all.filter((p: any) => p.employeeId === filterEmployeeId)
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => payrollApi.removePayslip(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslips'] }),
})
const generateFromBatchMutation = useMutation({
mutationFn: (data: any) => payrollApi.generatePayslips(data?.month || data),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['payslips'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
const n = res?.data?.generated || 0
toast.success(`已从归档批次汇总生成 ${n} 条工资条并发布。`)
},
})
const taxPreviewMutation = useMutation({
mutationFn: (data: any) => payrollApi.taxPreview(data),
onSuccess: (res: any) => {
setTaxResult(res.data)
setShowTaxPreview(true)
},
})
const [taxResult, setTaxResult] = useState<any>(null)
const confirmedCount = payslips?.filter((p: any) => p.confirmedAt).length || 0
const unconfirmedCount = payslips ? payslips.length - confirmedCount : 0
return (
<div className="space-y-3">
<PageGuide>
PDF
</PageGuide>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-48" />
{payslips && payslips.length > 0 && (
<div className="flex items-center gap-2 text-xs flex-nowrap">
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 whitespace-nowrap"> {payslips.length} </span>
<span className="px-2 py-0.5 rounded bg-green-50 text-safe whitespace-nowrap"> {confirmedCount}</span>
<span className="px-2 py-0.5 rounded bg-amber-50 text-warning whitespace-nowrap"> {unconfirmedCount}</span>
</div>
)}
</div>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => {
if (!payslips || payslips.length === 0) {
toast.error('暂无工资条可导出')
return
}
const headers = ['员工', '部门', '基本工资', '加班费', '津贴', '奖金', '扣款', '应发合计', '个税', '实发', '确认状态']
const rows = payslips.map((p: any) => [
p.employee?.name || '',
p.employee?.department || '',
p.baseSalary || 0,
p.overtimePay || 0,
p.allowance || 0,
p.bonus || 0,
p.deduction || 0,
p.totalPay || 0,
p.tax || 0,
p.netPay || 0,
p.confirmedAt ? '已确认' : '未确认',
])
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 = `工资表-${month}.csv`
a.click()
URL.revokeObjectURL(url)
toast.success('已导出工资表')
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
<Button
variant="secondary"
onClick={() => {
if (!payslips || payslips.length === 0) {
toast.error('暂无工资条可导出')
return
}
const headers = ['序号', '收款人姓名', '收款账号', '开户行', '金额', '用途', '备注']
const rows = payslips.map((p: any, i: number) => [
i + 1,
p.employee?.name || '',
p.employee?.bankAccount || '',
p.employee?.bankName || '',
(p.netPay || 0).toFixed(2),
`${month}月工资`,
'',
])
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 = `工资流水-${month}.csv`
a.click()
URL.revokeObjectURL(url)
toast.success('已导出工资流水')
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
<Button
onClick={() => setShowTaxPreview(true)}
>
<Calculator className="w-4 h-4 mr-1" />
</Button>
<Button
onClick={async () => {
if (await confirm({ title: '生成工资条', message: `确认从 ${month} 已归档批次汇总生成工资条?这将覆盖已有的工资条数据。`, variant: 'primary' })) {
generateFromBatchMutation.mutate({ month })
}
}}
disabled={generateFromBatchMutation.isPending}
>
<Layers className="w-4 h-4 mr-1" />
{generateFromBatchMutation.isPending ? '生成中...' : '从批次汇总生成'}
</Button>
</div>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !payslips || payslips.length === 0 ? (
<Card><div className="text-center py-8 text-gray-500"></div></Card>
) : (
<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"></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>
<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>
<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-center"></th>
<th className="py-2 px-2"></th>
</tr>
</thead>
<tbody>
{payslips.slice((page - 1) * pageSize, page * pageSize).map((p: any) => (
<tr key={p.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 px-2 text-sm font-medium">{p.employee?.name}</td>
<td className="py-2 px-2 text-gray-500">{p.employee?.department}</td>
<td className="py-2 px-2 text-right">¥{fmt(p.baseSalary)}</td>
<td className="py-2 px-2 text-right">¥{fmt(p.overtimePay)}</td>
<td className="py-2 px-2 text-right">¥{fmt(p.allowance)}</td>
<td className="py-2 px-2 text-right">¥{fmt(p.bonus)}</td>
<td className="py-2 px-2 text-right text-danger">{p.deduction > 0 ? '-¥' + fmt(p.deduction) : '¥0'}</td>
<td className="py-2 px-2 text-right font-medium text-primary">¥{fmt(p.totalPay)}</td>
<td className="py-2 px-2 text-right text-danger">¥{fmt(p.tax)}</td>
<td className="py-2 px-2 text-right font-bold text-safe">¥{fmt(p.netPay)}</td>
<td className="py-2 px-2 text-center">
{p.confirmedAt ? (
<span className="inline-flex items-center gap-1 text-safe text-xs">
<Check className="w-3 h-3" />
</span>
) : p.viewedAt ? (
<span className="inline-flex items-center gap-1 text-blue-600 text-xs" title={`查看于 ${new Date(p.viewedAt).toLocaleString('zh-CN')}`}>
<Eye className="w-3 h-3" />
</span>
) : p.publishedAt ? (
<span className="inline-flex items-center gap-1 text-gray-400 text-xs">
<Check className="w-3 h-3" />
</span>
) : (
<span className="text-gray-400 text-xs"></span>
)}
</td>
<td className="py-2 px-2">
<button
onClick={() => deleteMutation.mutate(p.id)}
className="text-xs text-gray-500 hover:text-danger"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={page} pageSize={pageSize} total={payslips.length} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</Card>
)}
{/* 税率试算 Modal */}
{showTaxPreview && (
<Modal open onClose={() => { setShowTaxPreview(false); setTaxResult(null) }}>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium"></h3>
<button onClick={() => { setShowTaxPreview(false); setTaxResult(null) }} className="text-gray-500 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" value={previewData.baseSalary || ''} onChange={(e) => setPreviewData({ ...previewData, baseSalary: Number(e.target.value) })} placeholder="请输入" />
</div>
<div>
<Label></Label>
<Input type="number" value={previewData.overtimePay || ''} onChange={(e) => setPreviewData({ ...previewData, overtimePay: Number(e.target.value) })} placeholder="请输入" />
</div>
<div>
<Label></Label>
<Input type="number" value={previewData.allowance || ''} onChange={(e) => setPreviewData({ ...previewData, allowance: Number(e.target.value) })} placeholder="请输入" />
</div>
<div>
<Label></Label>
<Input type="number" value={previewData.bonus || ''} onChange={(e) => setPreviewData({ ...previewData, bonus: Number(e.target.value) })} placeholder="请输入" />
</div>
<div>
<Label></Label>
<Input type="number" value={previewData.deduction || ''} onChange={(e) => setPreviewData({ ...previewData, deduction: Number(e.target.value) })} placeholder="请输入" />
</div>
<div>
<Label></Label>
<Input type="number" value={previewData.specialDeduction || ''} onChange={(e) => setPreviewData({ ...previewData, specialDeduction: Number(e.target.value) })} placeholder="请输入" />
</div>
</div>
<div className="flex gap-2">
<Button onClick={() => taxPreviewMutation.mutate({ month, ...previewData })} disabled={taxPreviewMutation.isPending} className="flex-1">
{taxPreviewMutation.isPending ? '计算中...' : '计算'}
</Button>
<Button variant="secondary" onClick={() => {
setPreviewData({ baseSalary: 0, overtimePay: 0, allowance: 0, deduction: 0, bonus: 0, specialDeduction: 0 })
setTaxResult(null)
}}>
</Button>
</div>
{taxResult && (
<div className="border rounded-md p-3 space-y-2">
<div className="text-xs font-medium text-gray-600 mb-2"></div>
{taxResult.breakdown.map((item: any, i: number) => (
<div key={i} className={`flex justify-between text-xs ${i === taxResult.breakdown.length - 1 ? 'font-bold border-t pt-2 mt-2' : ''} ${item.value < 0 ? 'text-danger' : item.value > 0 && i < taxResult.breakdown.length - 1 ? 'text-gray-500' : ''}`}>
<span>{item.label}</span>
<span>{item.value < 0 ? `${fmt(Math.abs(item.value))}` : `¥${fmt(item.value)}`}</span>
</div>
))}
{taxResult.ytdPayslipCount > 0 && (
<div className="text-xs text-gray-500 mt-2">{taxResult.ytdPayslipCount}</div>
)}
</div>
)}
</div>
</Modal>
)}
</div>
)
}