import { useState, useRef } from 'react' import { toast } from 'sonner' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useConfirm } from '../hooks/useConfirm' import { Calculator, AlertCircle, Info, Check, Upload, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, X, ChevronLeft, Wallet, LayoutTemplate, Clock, Receipt, Users, TrendingDown, TrendingUp, BadgeCheck } from 'lucide-react' import api from '../lib/api' 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 Modal from '../components/ui/Modal' 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; icon: React.ReactNode }[] = [ { key: 'batch', label: '发薪批次', icon: }, { key: 'template', label: '薪酬模版', icon: }, { key: 'overtime', label: '加班费计算', icon: }, { key: 'payslip', label: '工资条管理', icon: }, ] return (

薪税管理

统一管理发薪批次、工资条及加班薪酬

{tabs.map((t) => ( ))}
{tab === 'batch' && } {tab === 'template' && } {tab === 'overtime' && } {tab === 'payslip' && }
) } // ========== 发薪批次管理 ========== 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 [filterStatus, setFilterStatus] = useState('') const [filterType, setFilterType] = useState('') const [selectedBatchId, setSelectedBatchId] = useState(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'>('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, monthFrom, monthTo, filterStatus, filterType], queryFn: async () => { const params: any = {} if (month && !monthFrom && !monthTo) params.month = month if (monthFrom) params.monthFrom = monthFrom if (monthTo) params.monthTo = monthTo if (filterStatus) params.status = filterStatus if (filterType) params.type = filterType const res = await api.get('/payroll2/batches', { params }) 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) toast.success('发薪批次已创建') }, onError: () => toast.error('创建失败'), }) const deleteBatchMutation = useMutation({ mutationFn: (batchId: string) => api.delete(`/payroll2/batches/${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 }) => api.put(`/payroll2/batches/${batchId}/name`, { name }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['batches'] }) toast.success('批次名称已更新') }, onError: () => toast.error('重命名失败'), }) const [renamingId, setRenamingId] = useState(null) const [renameValue, setRenameValue] = useState('') if (selectedBatchId) { return setSelectedBatchId(null)} /> } return (
{/* 重复发薪提醒 */} {checkResult?.hasArchivedBatch && (
本月已有 {checkResult.archivedCount} 个已归档批次{checkResult.payslipsPublished ? ',工资条已发布' : ''}
)}
{ setMonthFrom(e.target.value); setMonth('') }} className="!w-36" />
{ setMonthTo(e.target.value); setMonth('') }} className="!w-36" />
{!monthFrom && !monthTo && ( setMonth(e.target.value)} className="!w-36 shrink-0" placeholder="单月" /> )} {(monthFrom || monthTo || filterStatus || filterType) && ( )} {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)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSelectedBatchId(batch.id) }}> ))}
批次名称 类型 人数 应发合计 个税合计 实发合计 状态 操作
{renamingId === batch.id ? ( 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" /> ) : ( {batch.name} )}
{batch.type === 'REGULAR' ? '常规发薪' : batch.type === 'BONUS' ? '年终奖/奖金' : batch.type === 'TERMINATION' ? '离职结算' : batch.type === 'SEVERANCE' ? '补偿金' : batch.type} {batch.employeeCount} ¥{fmt(batch.totalPay)} ¥{fmt(batch.totalTax)} ¥{fmt(batch.totalNetPay)} {batch.status === 'ARCHIVED' ? ( 已归档 ) : ( 草稿 )}
e.stopPropagation()}> {batch.status !== 'ARCHIVED' && ( <> )}
)}
) } 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('') const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(10) const [showAddEmployee, setShowAddEmployee] = useState(false) const payrollFileRef = useRef(null) const [payrollImportResult, setPayrollImportResult] = useState(null) 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'] }) toast.success('批次已归档。归档后批次锁定不可编辑。可前往「工资条管理」生成工资条。') }, }) 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) { toast.success(`成功导入 ${res.data.imported} 条加班费记录`) } 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'] }) setPayrollImportResult(data) if (data.updated > 0) { toast.success(`成功更新 ${data.updated} 条工资记录`) } else { toast.info('未更新任何记录') } }, onError: () => toast.error('工资表导入失败'), }) 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 && (
{ const file = e.target.files?.[0] if (file) importPayrollMutation.mutate(file) e.target.value = '' }} />
)} {isArchived && ( )}
{/* 批次汇总 */}
{batch.employeeCount}
人数
¥{fmt(batch.totalPay)}
应发合计
¥{fmt(batch.totalTax)}
个税合计
¥{fmt(batch.totalNetPay)}
实发合计
{/* 添加人员 */} {showAddEmployee && !isArchived && ( setShowAddEmployee(false)} /> )} {/* 工资表导入结果 */} {payrollImportResult && (

工资表导入结果

总计 {payrollImportResult.total} 行,成功更新 {payrollImportResult.updated} 条
{payrollImportResult.errors?.length > 0 && (
错误详情({payrollImportResult.errors.length}条):
    {payrollImportResult.errors.map((err: string, i: number) => (
  • {err}
  • ))}
)}
)} {/* 提示 */} {!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 confirm = useConfirm() const [showForm, setShowForm] = useState(false) const [editingItem, setEditingItem] = useState(null) const [form, setForm] = useState({ name: '', code: '', type: 'INPUT' as 'INPUT' | 'CALCULATED', formula: '', order: 99, isEditable: true, }) const { data: items, isLoading } = useQuery({ queryKey: ['payslip-template'], queryFn: async () => { const res = await api.get('/payroll2/template') as any return res.data }, }) const deleteMutation = useMutation({ mutationFn: (id: string) => api.delete(`/payroll2/template/${id}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['payslip-template'] }) toast.success('已删除') }, onError: () => toast.error('删除失败'), }) const createMutation = useMutation({ mutationFn: (data: any) => api.post('/payroll2/template', data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['payslip-template'] }) setShowForm(false) toast.success('已新增薪酬项') }, onError: () => toast.error('新增失败'), }) const updateMutation = useMutation({ mutationFn: ({ id, data }: { id: string; data: any }) => api.put(`/payroll2/template/${id}`, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['payslip-template'] }) setShowForm(false) setEditingItem(null) toast.success('已更新') }, onError: () => toast.error('更新失败'), }) /** 打开新增表单 */ const handleAdd = () => { setEditingItem(null) setForm({ name: '', code: '', type: 'INPUT', formula: '', order: items?.length ? items.length + 1 : 99, isEditable: true }) setShowForm(true) } /** 打开编辑表单 */ const handleEdit = (item: any) => { setEditingItem(item) setForm({ name: item.name, code: item.code, type: item.type, formula: item.formula || '', order: item.order, isEditable: item.isEditable, }) setShowForm(true) } /** 提交表单 */ const handleSubmit = () => { if (!form.name.trim() || !form.code.trim()) { toast.error('名称和字段代码不能为空') return } const payload = { name: form.name.trim(), type: form.type, formula: form.type === 'CALCULATED' ? form.formula.trim() || null : null, order: Number(form.order), isEditable: form.isEditable, } if (editingItem) { updateMutation.mutate({ id: editingItem.id, data: payload }) } else { createMutation.mutate({ ...payload, code: form.code.trim() }) } } return (

薪酬结构模版

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

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

{/* 新增/编辑弹窗 */} {showForm && ( { setShowForm(false); setEditingItem(null) }}>

{editingItem ? '编辑薪酬项' : '新增薪酬项'}

setForm({ ...form, name: e.target.value })} placeholder="如:交通补贴" />
setForm({ ...form, code: e.target.value })} placeholder="如:transportAllowance" disabled={!!editingItem} /> {editingItem && (

字段代码创建后不可修改

)}
setForm({ ...form, order: Number(e.target.value) })} />
{form.type === 'CALCULATED' && (
setForm({ ...form, formula: e.target.value })} placeholder="如:baseSalary + overtimePay + allowance - deduction" />

可引用其他字段代码进行加减乘除运算

)}
)}
) } 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 [editingId, setEditingId] = useState(null) const [editForm, setEditForm] = useState({ weekdayHours: 0, weekendHours: 0, holidayHours: 0 }) // 加班费规则配置 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 updateOvertimeMutation = useMutation({ mutationFn: ({ id, data }: { id: string; data: any }) => api.put(`/payroll/overtime/${id}`, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['overtime-records'] }) setEditingId(null) }, }) // 开始编辑 const startEdit = (record: any) => { setEditingId(record.id) setEditForm({ weekdayHours: record.weekdayHours || 0, weekendHours: record.weekendHours || 0, holidayHours: record.holidayHours || 0, }) } // 保存编辑 const saveEdit = () => { if (editingId) { updateOvertimeMutation.mutate({ id: editingId, data: editForm }) } } 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 { toast.error('未匹配到员工,请确保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) => ( {editingId === r.id ? ( <> ) : ( <> )} ))}
员工 部门 工作日(h) 休息日(h) 节假日(h) 加班费 状态 操作
{r.employee?.name} {r.employee?.department} setEditForm({ ...editForm, weekdayHours: Number(e.target.value) })} min="0" step="0.5" /> setEditForm({ ...editForm, weekendHours: Number(e.target.value) })} min="0" step="0.5" /> setEditForm({ ...editForm, holidayHours: Number(e.target.value) })} min="0" step="0.5" /> startEdit(r)}>{r.weekdayHours || '-'} startEdit(r)}>{r.weekendHours || '-'} startEdit(r)}>{r.holidayHours || '-'} {r.totalPay > 0 ? `¥${fmt(r.totalPay)}` : 待计算} {r.batchId ? ( 已入批次 ) : ( 未入批次 )} {editingId === r.id ? (
) : ( !r.batchId && ( ) )}
)}
加班费金额在发薪批次中「导入加班费」时自动计算
)}
) } 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({ 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 toast.success(`已从归档批次汇总生成 ${n} 条工资条并发布。`) }, }) const taxPreviewMutation = useMutation({ mutationFn: (data: any) => api.post('/payroll/tax-preview', data), onSuccess: (res: any) => { setTaxResult(res.data) setShowTaxPreview(true) }, }) const [taxResult, setTaxResult] = useState(null) 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)} ¥{fmt(p.bonus)} {p.deduction > 0 ? '-¥' + fmt(p.deduction) : '¥0'} ¥{fmt(p.totalPay)} ¥{fmt(p.tax)} ¥{fmt(p.netPay)} {p.confirmedAt ? ( 已确认 ) : ( 未确认 )}
)} {/* 税率试算 Modal */} {showTaxPreview && ( { setShowTaxPreview(false); setTaxResult(null) }}>

工资条税率试算

setPreviewData({ ...previewData, baseSalary: Number(e.target.value) })} placeholder="请输入" />
setPreviewData({ ...previewData, overtimePay: Number(e.target.value) })} placeholder="请输入" />
setPreviewData({ ...previewData, allowance: Number(e.target.value) })} placeholder="请输入" />
setPreviewData({ ...previewData, bonus: Number(e.target.value) })} placeholder="请输入" />
setPreviewData({ ...previewData, deduction: Number(e.target.value) })} placeholder="请输入" />
setPreviewData({ ...previewData, specialDeduction: Number(e.target.value) })} placeholder="请输入" />
{taxResult && (
计算结果
{taxResult.breakdown.map((item: any, i: number) => (
0 && i < taxResult.breakdown.length - 1 ? 'text-gray-500' : ''}`}> {item.label} {item.value < 0 ? `-¥${fmt(Math.abs(item.value))}` : `¥${fmt(item.value)}`}
))} {taxResult.ytdPayslipCount > 0 && (
注:已累计{taxResult.ytdPayslipCount}条工资条计算个税
)}
)}
)}
) }