import { useState, useMemo, useRef } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Calculator, AlertCircle, Info, Save, Check, Upload, Bell, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, History } from 'lucide-react' import api from '../lib/api' 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 }) type Tab = 'batch' | 'template' | 'overtime' | 'payslip' export default function Money() { const [tab, setTab] = useState('batch') const tabs: { key: Tab; label: string }[] = [ { key: 'batch', label: '发薪批次' }, { key: 'template', label: '薪酬模版' }, { key: 'overtime', label: '加班费计算' }, { key: 'payslip', label: '工资条管理' }, ] return (

薪税计算

{tabs.map((t) => ( ))}
{tab === 'batch' && } {tab === 'template' && } {tab === 'overtime' && } {tab === 'payslip' && }
) } // ========== 发薪批次管理 ========== function BatchManager() { const queryClient = useQueryClient() const [month, setMonth] = useState(new Date().toISOString().slice(0, 7)) const [selectedBatchId, setSelectedBatchId] = useState(null) const [showCreateModal, setShowCreateModal] = useState(false) const [createType, setCreateType] = useState<'REGULAR' | 'TERMINATION' | 'BONUS'>('REGULAR') const [createMode, setCreateMode] = useState<'copy_last' | 'blank_employees' | 'blank_all' | 'copy_batch'>('copy_last') const [sourceBatchId, setSourceBatchId] = useState('') const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(10) const { data: checkResult } = useQuery({ queryKey: ['batch-check', month], queryFn: async () => { const res = await api.get('/payroll2/batches/check', { params: { month } }) as any return res.data }, }) const { data: batches, isLoading } = useQuery({ queryKey: ['batches', month], queryFn: async () => { const res = await api.get('/payroll2/batches', { params: { month } }) as any return res.data }, }) const { data: archivedBatches } = useQuery({ queryKey: ['archived-batches'], queryFn: async () => { const res = await api.get('/payroll2/batches/archived/list') as any return res.data }, enabled: createMode === 'copy_batch', }) const createMutation = useMutation({ mutationFn: (data: any) => api.post('/payroll2/batches', data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['batches'] }) queryClient.invalidateQueries({ queryKey: ['batch-check'] }) setShowCreateModal(false) }, }) const deleteBatchMutation = useMutation({ mutationFn: (batchId: string) => api.delete(`/payroll2/batches/${batchId}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['batches'] }) queryClient.invalidateQueries({ queryKey: ['batch-check'] }) }, }) if (selectedBatchId) { return setSelectedBatchId(null)} /> } return (
{/* 重复发薪提醒 */} {checkResult?.hasArchivedBatch && (
本月已有 {checkResult.archivedCount} 个已归档批次{checkResult.payslipsPublished ? ',工资条已发布' : ''}
)}
setMonth(e.target.value)} className="w-48" /> {batches && batches.length > 0 && ( {batches.length} 个批次 )}
{showCreateModal && (

创建发薪批次

{createMode === 'copy_batch' && (
{!archivedBatches?.length && (

暂无可复制的已归档批次

)}
)}
{createMode === 'copy_last' &&

拉入在职员工,从上月工资条复制基本工资/津贴/扣款,自动计算社保个税

} {createMode === 'blank_employees' &&

拉入在职员工,所有金额为0,需手动填写

} {createMode === 'blank_all' &&

创建空批次,不拉入员工,后续手动添加人员和填写数据

} {createMode === 'copy_batch' &&

从指定的已归档批次复制员工和薪资数据

}
)} {isLoading ? (
加载中...
) : !batches || batches.length === 0 ? ( ) : (
{ setPageSize(s); setPage(1) }} /> {batches.slice((page - 1) * pageSize, page * pageSize).map((batch: any) => (
setSelectedBatchId(batch.id)}>
{batch.name}
{batch.employeeCount} 人 · 应发 ¥{fmt(batch.totalPay)} · 实发 ¥{fmt(batch.totalNetPay)} {batch.type === 'BONUS' && ' · 单独计税'} {batch.type === 'TERMINATION' && ' · 离职结算'}
{batch.status === 'ARCHIVED' ? ( 已归档 ) : ( <> 草稿 )}
))}
)}
) } function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void }) { const queryClient = useQueryClient() const [editCell, setEditCell] = useState<{ employeeId: string; field: string } | null>(null) const [editValue, setEditValue] = useState('') const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(10) const [showAddEmployee, setShowAddEmployee] = useState(false) const { data: batch, isLoading } = useQuery({ queryKey: ['batch-detail', batchId], queryFn: async () => { const res = await api.get(`/payroll2/batches/${batchId}`) as any return res.data }, }) const updateEntryMutation = useMutation({ mutationFn: ({ employeeId, data }: { employeeId: string; data: any }) => api.put(`/payroll2/batches/${batchId}/entries/${employeeId}`, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) }, }) const removeEmployeeMutation = useMutation({ mutationFn: (employeeId: string) => api.delete(`/payroll2/batches/${batchId}/employees/${employeeId}`), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['batch-detail'] }), }) const archiveMutation = useMutation({ mutationFn: () => api.post(`/payroll2/batches/${batchId}/archive`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) queryClient.invalidateQueries({ queryKey: ['batches'] }) queryClient.invalidateQueries({ queryKey: ['batch-check'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] }) alert('批次已归档。归档后批次锁定不可编辑。可前往「工资条管理」生成工资条。') }, }) const importOvertimeMutation = useMutation({ mutationFn: () => api.post(`/payroll/overtime/import-to-batch/${batchId}`), onSuccess: (res: any) => { queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) queryClient.invalidateQueries({ queryKey: ['overtime-records'] }) if (res.data?.imported > 0) { alert(`成功导入 ${res.data.imported} 条加班费记录`) } else { alert(res.data?.message || '没有可导入的加班记录') } }, onError: () => { alert('导入失败,请重试') }, }) const deleteBatchMutation = useMutation({ mutationFn: () => api.delete(`/payroll2/batches/${batchId}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['batches'] }) queryClient.invalidateQueries({ queryKey: ['batch-check'] }) onBack() }, }) if (isLoading) return
加载中...
if (!batch) return
批次不存在
const isArchived = batch.status === 'ARCHIVED' const isBonus = batch.type === 'BONUS' // 可编辑的输入项字段 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 ( setEditValue(e.target.value)} onBlur={handleBlur} onKeyDown={handleKeyDown} /> ) } return ( canEdit && startEdit(entry.employeeId, field, value)} > {canEdit ? ( 0 ? 'text-danger' : ''}`}> {displayValue} ) : field === 'deduction' && value > 0 ? ( {displayValue} ) : ( displayValue )} ) } return (

{batch.name}

{isArchived ? ( 已归档 ) : ( 草稿 )}
{!isArchived && (
)} {isArchived && ( )}
{/* 批次汇总 */}
{batch.employeeCount}
人数
¥{fmt(batch.totalPay)}
应发合计
¥{fmt(batch.totalTax)}
个税合计
¥{fmt(batch.totalNetPay)}
实发合计
{/* 添加人员 */} {showAddEmployee && !isArchived && ( setShowAddEmployee(false)} /> )} {/* 提示 */} {!isArchived && (
带下划线的单元格可直接点击编辑,失焦自动保存。社保/公积金可手动覆盖,个税和实发自动计算。
)} {/* 人员表格 */} {batch.entries.length > 0 && ( { setPageSize(s); setPage(1) }} /> )}
{isBonus && } {!isBonus && } {!isArchived && } {batch.entries.slice((page - 1) * pageSize, page * pageSize).map((entry: any) => ( {renderCell(entry, 'baseSalary')} {renderCell(entry, 'overtimePay')} {renderCell(entry, 'allowance')} {renderCell(entry, 'bonus')} {renderCell(entry, 'deduction')} {/* 计算项 - 灰显 */} {renderCell(entry, 'socialEmp')} {renderCell(entry, 'housingEmp')} {!isArchived && ( )} ))}
员工 基本工资 加班费 津贴奖金奖金扣款 应发 社保(个人) 公积金(个人) 个税 实发 风险操作
{entry.employee.name}
{entry.employee.department}
{entry.employee.status === 'RESIGNED' && ( 已离职 )}
{fmt(entry.totalPay)}{fmt(entry.tax)} {fmt(entry.netPay)} {entry.riskWarnings && entry.riskWarnings.length > 0 ? ( ) : ( )}
) } function AddEmployeeToBatch({ batchId, onClose }: { batchId: string; onClose: () => void }) { const queryClient = useQueryClient() const [selected, setSelected] = useState([]) const { data: employees } = useQuery({ queryKey: ['employees-for-batch'], queryFn: async () => { const res = await api.get('/employees', { params: { pageSize: 100 } }) as any return res.data }, }) const addMutation = useMutation({ mutationFn: (employeeIds: string[]) => api.post(`/payroll2/batches/${batchId}/employees`, { employeeIds }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) onClose() }, }) return (

添加人员到批次

{employees?.items?.map((emp: any) => ( ))}
) } // ========== 薪酬模版管理 ========== function TemplateManager() { const queryClient = useQueryClient() const { data: items, isLoading } = useQuery({ queryKey: ['payslip-template'], queryFn: async () => { const res = await api.get('/payroll2/template') as any return res.data }, }) const updateMutation = useMutation({ mutationFn: ({ id, data }: { id: string; data: any }) => api.put(`/payroll2/template/${id}`, data), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslip-template'] }), }) const deleteMutation = useMutation({ mutationFn: (id: string) => api.delete(`/payroll2/template/${id}`), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslip-template'] }), }) return (

薪酬结构模版

定义薪酬项和计算关系
{isLoading ? (
加载中...
) : (
{items?.map((item: any) => ( ))}
序号 名称 字段代码 类型 计算公式 可编辑 操作
{item.order} {item.name} {item.code} {item.type === 'INPUT' ? '输入项' : '计算项'} {item.formula || '—'} {item.isEditable ? '可编辑' : '不可编辑'} {!item.isDefault && ( )} {item.isDefault && 预置}
)}

预置项为系统默认薪酬结构,不可删除。计算项的公式支持引用其他字段进行自动计算。

) } function OvertimeCalculator() { const queryClient = useQueryClient() const [step, setStep] = useState<1 | 2 | 3>(1) const [month, setMonth] = useState(new Date().toISOString().slice(0, 7)) const fileInputRef = useRef(null) const [previewData, setPreviewData] = useState([]) // 加班费规则配置 const { data: config, isLoading: configLoading } = useQuery({ queryKey: ['overtime-config'], queryFn: async () => { const res = await api.get('/payroll/overtime/config') as any return res.data }, }) const saveConfigMutation = useMutation({ mutationFn: (data: any) => api.post('/payroll/overtime/config', data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['overtime-config'] }) }, }) // 员工列表 const { data: employees } = useQuery<{ items: { id: string; name: string; department: string }[] }>({ queryKey: ['employees-for-overtime'], queryFn: async () => { const res = await api.get('/employees', { params: { pageSize: 100 } }) as any return res.data }, }) // 加班记录 const { data: overtimeRecords, refetch } = useQuery({ queryKey: ['overtime-records', month], queryFn: async () => { const res = await api.get('/payroll/overtime', { params: { month } }) as any return res.data }, enabled: step === 3, }) const batchImportMutation = useMutation({ mutationFn: (data: any[]) => api.post('/payroll/overtime/batch', data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['overtime-records'] }) setPreviewData([]) setStep(3) }, }) const handleFileUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return const reader = new FileReader() reader.onload = (event) => { const text = event.target?.result as string const lines = text.split('\n').filter(l => l.trim()) const items: any[] = [] const empList = employees?.items || [] for (let i = 1; i < lines.length; i++) { const cols = lines[i].split(',').map(c => c.trim()) const empName = cols[0] const emp = empList.find(e => e.name === empName) if (!emp) continue items.push({ employeeId: emp.id, employeeName: emp.name, department: emp.department, month: cols[4] || month, weekdayHours: Number(cols[1]) || 0, weekendHours: Number(cols[2]) || 0, holidayHours: Number(cols[3]) || 0, }) } if (items.length > 0) { setPreviewData(items) } else { alert('未匹配到员工,请确保CSV第一列为员工姓名') } } reader.readAsText(file) } const confirmImport = () => { const payload = previewData.map(d => ({ employeeId: d.employeeId, month: d.month, weekdayHours: d.weekdayHours, weekendHours: d.weekendHours, holidayHours: d.holidayHours, })) batchImportMutation.mutate(payload) } const cfg = config || { weekdayRate: 1.5, weekendRate: 2.0, holidayRate: 3.0, monthlyDays: 21.75, dailyHours: 8 } return (
{/* 步骤指示器 */}
{[ { n: 1, label: '设定计算规则' }, { n: 2, label: '导入考勤数据' }, { n: 3, label: '查看加班记录' }, ].map((s) => (
{s.n < 3 &&
}
))}
{/* Step 1: 设定计算规则 */} {step === 1 && (

加班费计算规则

{configLoading ? (
加载中...
) : (
saveConfigMutation.mutate({ ...cfg, weekdayRate: Number(e.target.value) })} />

平时加班按小时工资的倍率

saveConfigMutation.mutate({ ...cfg, weekendRate: Number(e.target.value) })} />

周末加班按小时工资的倍率

saveConfigMutation.mutate({ ...cfg, holidayRate: Number(e.target.value) })} />

法定节假日加班按小时工资的倍率

saveConfigMutation.mutate({ ...cfg, monthlyDays: Number(e.target.value) })} />

用于折算日工资/小时工资(默认21.75天)

saveConfigMutation.mutate({ ...cfg, dailyHours: Number(e.target.value) })} />

用于折算小时工资(默认8小时)

计算公式:小时工资 = 月工资 ÷ 月计薪天数 ÷ 每日工时

加班费 = 小时工资 × 倍率 × 加班工时

实际金额在发薪批次中根据员工月工资自动计算,此处只配置倍率规则。

{saveConfigMutation.isSuccess && (
规则已保存
)}
)}
)} {/* Step 2: 导入考勤数据 */} {step === 2 && (

导入加班工时

setMonth(e.target.value)} className="w-48" />
CSV格式:姓名,工作日加班(h),休息日加班(h),节假日加班(h),月份(可选)
{previewData.length > 0 && (
预览({previewData.length}条)
{previewData.map((d, i) => ( ))}
员工 部门 工作日(h) 休息日(h) 节假日(h) 月份
{d.employeeName} {d.department} {d.weekdayHours} {d.weekendHours} {d.holidayHours} {d.month}
)}
)} {/* Step 3: 查看加班记录 */} {step === 3 && (

加班记录({month})

setMonth(e.target.value)} className="w-32" />
{!overtimeRecords || overtimeRecords.length === 0 ? (
该月暂无加班记录,请先导入考勤数据
) : (
{overtimeRecords.map((r: any) => ( ))}
员工 部门 工作日(h) 休息日(h) 节假日(h) 加班费 状态
{r.employee?.name} {r.employee?.department} {r.weekdayHours || '-'} {r.weekendHours || '-'} {r.holidayHours || '-'} {r.totalPay > 0 ? `¥${fmt(r.totalPay)}` : 待计算} {r.batchId ? ( 已入批次 ) : ( 未入批次 )}
)}
加班费金额在发薪批次中「导入加班费」时自动计算
)}
) } function ResultRow({ label, value }: { label: string; value: number }) { return (
{label} ¥{fmt(value)}
) } function PayslipManager() { const queryClient = useQueryClient() const [month, setMonth] = useState(new Date().toISOString().slice(0, 7)) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(10) const { data: payslips, isLoading } = useQuery({ queryKey: ['payslips', month], queryFn: async () => { const res = await api.get('/payroll/payslip', { params: { month } }) as any return res.data }, }) const deleteMutation = useMutation({ mutationFn: (id: string) => api.delete(`/payroll/payslip/${id}`), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslips'] }), }) const generateFromBatchMutation = useMutation({ mutationFn: (data: any) => api.post('/payroll2/payslips/generate', data), onSuccess: (res: any) => { queryClient.invalidateQueries({ queryKey: ['payslips'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] }) const n = res?.data?.generated || 0 alert(`已从归档批次汇总生成 ${n} 条工资条并发布。`) }, }) const confirmedCount = payslips?.filter((p: any) => p.confirmedAt).length || 0 const unconfirmedCount = payslips ? payslips.length - confirmedCount : 0 return (
setMonth(e.target.value)} className="w-48" /> {payslips && payslips.length > 0 && (
共 {payslips.length} 条 已确认 {confirmedCount} 未确认 {unconfirmedCount}
)}
{isLoading ? (
加载中...
) : !payslips || payslips.length === 0 ? (
该月份暂无工资条记录
) : ( { setPageSize(s); setPage(1) }} />
{payslips.slice((page - 1) * pageSize, page * pageSize).map((p: any) => ( ))}
员工 部门 基本工资 加班费 津贴 扣款 应发合计 确认状态
{p.employee?.name} {p.employee?.department} ¥{fmt(p.baseSalary)} ¥{fmt(p.overtimePay)} ¥{fmt(p.allowance)} {p.deduction > 0 ? '-¥' + fmt(p.deduction) : '¥0'} ¥{fmt(p.totalPay)} {p.confirmedAt ? ( 已确认 ) : ( 未确认 )}
)}
) }