a2e9ba55c2
P0: 福利批量参保/离职证明下载防乱码/考勤模板合并Sheet/补卡修改/附件在线查看删除 P1: 分页pageSize修复/离职导出筛选/撤回删除草稿/加班费自动计算/考勤加班汇总/证据链异常详情/制度催办/模板导入Word/社保封顶保底/校验字段提示/职务字段/社保费用明细/弹窗防误关/身份证查重/证明员工下拉/培训批量 P2: 离职流程去重/社保基数覆盖输入/薪税入口改名/添加员工引导/绩效模板清理
315 lines
15 KiB
TypeScript
315 lines
15 KiB
TypeScript
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>
|
||
)
|
||
}
|
||
|