优化: 大文件拆分+代码分割+按需加载+console清理+any类型替换
- Money.tsx (2260行→54行): 拆分为 money/ 子目录4个组件, React.lazy二级分割 - AIAssistant.tsx (2038行→63行): 拆分为 ai-assistant/ 子目录6个组件, React.lazy二级分割 - xlsx改为动态导入, OvertimeTab从345KB降至12.7KB - api-services.ts: 请求参数 any→Record<string,unknown> - 移除前端3处console.log残留 - 后端console替换为pino logger - 前后端未使用import/变量清理 - Zod schema验证: termination/platform/special-status/work-process - 新增 leave.routes.ts, acceptance-test.routes.ts - UI组件: PageGuide, QueryError, Stepper
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import { Calculator, Check, Layers, Settings as X } 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() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
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],
|
||||
queryFn: async () => {
|
||||
return await payrollApi.payslips({ month })
|
||||
},
|
||||
})
|
||||
|
||||
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 items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-48" />
|
||||
{payslips && payslips.length > 0 && (
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600">共 {payslips.length} 条</span>
|
||||
<span className="px-2 py-0.5 rounded bg-green-50 text-safe">已确认 {confirmedCount}</span>
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-warning">未确认 {unconfirmedCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<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>
|
||||
<Pagination page={page} pageSize={pageSize} total={payslips.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
|
||||
<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>
|
||||
) : (
|
||||
<span className="text-warning 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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user