255af519d2
Phase 1 紧急修复(8项): - 社保城市选择改为可输入 - 社保上下限拆分(三险/医保独立基数) - 公积金试算结果展示修复 - 花名册合同保存修复(日期ISO格式) - 薪酬批次创建失败修复(城市过滤+错误处理) - 证据链查看修复 - 个税计算修复(blank_employees读取基本工资) - 加班费倍率读取配置 Phase 2 功能完善(3项): - 批量导入per-row异常捕获+导入按钮 - 单人发薪UI入口优化 - 解除协议模板补充(员工提出离职版) Phase 3 后期规划(4项): - 工资表导入功能(POST /import/payroll + 前端入口) - 大病险/长护险附加险种(extraInsurances JSON + 计算适配) - 专项附加扣除按月录入(SpecialDeductionRecord模型 + 前端Tab) - 预置河北省社保政策(seed数据)
1751 lines
77 KiB
TypeScript
1751 lines
77 KiB
TypeScript
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<Tab>('batch')
|
||
|
||
const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [
|
||
{ key: 'batch', label: '发薪批次', icon: <Layers className="w-4 h-4" /> },
|
||
{ key: 'template', label: '薪酬模版', icon: <LayoutTemplate className="w-4 h-4" /> },
|
||
{ key: 'overtime', label: '加班费计算', icon: <Clock className="w-4 h-4" /> },
|
||
{ key: 'payslip', label: '工资条管理', icon: <Receipt className="w-4 h-4" /> },
|
||
]
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center gap-2">
|
||
<Wallet className="w-5 h-5 text-primary" />
|
||
<div>
|
||
<h1 className="text-base font-semibold">薪税管理</h1>
|
||
<p className="mt-1 text-sm text-gray-500">统一管理发薪批次、工资条及加班薪酬</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-1 border-b overflow-x-auto">
|
||
{tabs.map((t) => (
|
||
<button
|
||
key={t.key}
|
||
onClick={() => setTab(t.key)}
|
||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||
}`}
|
||
>
|
||
{t.icon}
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{tab === 'batch' && <BatchManager />}
|
||
{tab === 'template' && <TemplateManager />}
|
||
{tab === 'overtime' && <OvertimeCalculator />}
|
||
{tab === 'payslip' && <PayslipManager />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ========== 发薪批次管理 ==========
|
||
|
||
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<string>('')
|
||
const [filterType, setFilterType] = useState<string>('')
|
||
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(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<string>('')
|
||
const [page, setPage] = useState(1)
|
||
const [pageSize, setPageSize] = useState(10)
|
||
|
||
const { data: checkResult } = useQuery<any>({
|
||
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<any[]>({
|
||
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<any[]>({
|
||
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<string | null>(null)
|
||
const [renameValue, setRenameValue] = useState('')
|
||
|
||
if (selectedBatchId) {
|
||
return <BatchDetail batchId={selectedBatchId} onBack={() => setSelectedBatchId(null)} />
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{/* 重复发薪提醒 */}
|
||
{checkResult?.hasArchivedBatch && (
|
||
<div className="flex items-center gap-2 p-2 rounded-md bg-amber-50 text-warning text-xs">
|
||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||
本月已有 {checkResult.archivedCount} 个已归档批次{checkResult.payslipsPublished ? ',工资条已发布' : ''}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<div className="flex items-center gap-1 shrink-0">
|
||
<span className="text-xs text-gray-500">从</span>
|
||
<Input type="month" value={monthFrom} onChange={(e) => { setMonthFrom(e.target.value); setMonth('') }} className="!w-36" />
|
||
</div>
|
||
<div className="flex items-center gap-1 shrink-0">
|
||
<span className="text-xs text-gray-500">至</span>
|
||
<Input type="month" value={monthTo} onChange={(e) => { setMonthTo(e.target.value); setMonth('') }} className="!w-36" />
|
||
</div>
|
||
{!monthFrom && !monthTo && (
|
||
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-36 shrink-0" placeholder="单月" />
|
||
)}
|
||
<Select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="!w-28 shrink-0">
|
||
<option value="">全部状态</option>
|
||
<option value="DRAFT">草稿</option>
|
||
<option value="ARCHIVED">已归档</option>
|
||
</Select>
|
||
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="!w-28 shrink-0">
|
||
<option value="">全部类型</option>
|
||
<option value="REGULAR">常规发薪</option>
|
||
<option value="TERMINATION">离职结算</option>
|
||
<option value="BONUS">年终奖</option>
|
||
<option value="SEVERANCE">补偿金</option>
|
||
</Select>
|
||
{(monthFrom || monthTo || filterStatus || filterType) && (
|
||
<button onClick={() => { setMonthFrom(''); setMonthTo(''); setFilterStatus(''); setFilterType(''); setMonth(new Date().toISOString().slice(0, 7)) }} className="text-xs text-gray-500 hover:text-primary shrink-0">
|
||
清除筛选
|
||
</button>
|
||
)}
|
||
{batches && batches.length > 0 && (
|
||
<span className="text-xs text-gray-500 whitespace-nowrap shrink-0">{batches.length} 个批次</span>
|
||
)}
|
||
<div className="flex-1" />
|
||
<Button onClick={() => { setCreateType('REGULAR'); setShowCreateModal(true) }} className="shrink-0">
|
||
<Plus className="w-4 h-4 mr-1" />创建发薪批次
|
||
</Button>
|
||
</div>
|
||
|
||
{showCreateModal && (
|
||
<Card>
|
||
<h2 className="text-xs font-medium mb-3">创建发薪批次</h2>
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label>批次类型</Label>
|
||
<Select value={createType} onChange={(e) => setCreateType(e.target.value as any)}>
|
||
<option value="REGULAR">常规发薪</option>
|
||
<option value="TERMINATION">离职结算</option>
|
||
<option value="BONUS">年终奖/奖金</option>
|
||
<option value="SEVERANCE">补偿金批次</option>
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<Label>数据初始化模式</Label>
|
||
<Select value={createMode} onChange={(e) => { setCreateMode(e.target.value as any); setSourceBatchId('') }}>
|
||
<option value="copy_last">复制上月数据</option>
|
||
<option value="blank_employees">本月空白(拉入员工,金额为0)</option>
|
||
<option value="blank_all">全空白(不拉入员工)</option>
|
||
<option value="copy_batch">复制指定批次</option>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
{createMode === 'copy_batch' && (
|
||
<div>
|
||
<Label>选择源批次</Label>
|
||
<Select value={sourceBatchId} onChange={(e) => setSourceBatchId(e.target.value)}>
|
||
<option value="">请选择已归档批次</option>
|
||
{archivedBatches?.map((b: any) => (
|
||
<option key={b.id} value={b.id}>{b.name}({b.month} · {b.employeeCount}人 · 应发¥{fmt(b.totalPay)})</option>
|
||
))}
|
||
</Select>
|
||
{!archivedBatches?.length && (
|
||
<p className="text-xs text-gray-500 mt-1">暂无可复制的已归档批次</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className="text-xs text-gray-500 space-y-0.5">
|
||
{createMode === 'copy_last' && <p>拉入在职员工,从上月工资条复制基本工资/津贴/扣款,自动计算社保个税</p>}
|
||
{createMode === 'blank_employees' && <p>拉入在职员工,所有金额为0,需手动填写</p>}
|
||
{createMode === 'blank_all' && <p>创建空批次,不拉入员工,后续手动添加人员和填写数据</p>}
|
||
{createMode === 'copy_batch' && <p>从指定的已归档批次复制员工和薪资数据</p>}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
onClick={() => createMutation.mutate({ month, type: createType, mode: createMode, sourceBatchId: sourceBatchId || undefined })}
|
||
disabled={createMutation.isPending || (createMode === 'copy_batch' && !sourceBatchId)}
|
||
>
|
||
{createMutation.isPending ? '创建中...' : '确认创建'}
|
||
</Button>
|
||
<Button variant="secondary" onClick={() => setShowCreateModal(false)}>取消</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{isLoading ? (
|
||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||
) : !batches || batches.length === 0 ? (
|
||
<EmptyState title="本月暂无发薪批次" description="点击「创建发薪批次」开始" />
|
||
) : (
|
||
<div className="space-y-3">
|
||
<Pagination page={page} pageSize={pageSize} total={batches.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
|
||
<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-3">批次名称</th>
|
||
<th className="py-2 px-3">类型</th>
|
||
<th className="py-2 px-3 text-right">人数</th>
|
||
<th className="py-2 px-3 text-right">应发合计</th>
|
||
<th className="py-2 px-3 text-right">个税合计</th>
|
||
<th className="py-2 px-3 text-right">实发合计</th>
|
||
<th className="py-2 px-3">状态</th>
|
||
<th className="py-2 px-3 text-right">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{batches.slice((page - 1) * pageSize, page * pageSize).map((batch: any) => (
|
||
<tr key={batch.id} className="border-b hover:bg-gray-50 cursor-pointer" onClick={() => setSelectedBatchId(batch.id)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSelectedBatchId(batch.id) }}>
|
||
<td className="py-2.5 px-3">
|
||
<div className="flex items-center gap-2">
|
||
<Layers className="w-4 h-4 text-primary flex-shrink-0" />
|
||
{renamingId === batch.id ? (
|
||
<input
|
||
type="text"
|
||
autoFocus
|
||
value={renameValue}
|
||
onChange={(e) => 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"
|
||
/>
|
||
) : (
|
||
<span className="text-sm font-medium">{batch.name}</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="py-2.5 px-3 text-xs text-gray-600">
|
||
{batch.type === 'REGULAR' ? '常规发薪' : batch.type === 'BONUS' ? '年终奖/奖金' : batch.type === 'TERMINATION' ? '离职结算' : batch.type === 'SEVERANCE' ? '补偿金' : batch.type}
|
||
</td>
|
||
<td className="py-2.5 px-3 text-right text-sm">{batch.employeeCount}</td>
|
||
<td className="py-2.5 px-3 text-right text-sm font-medium text-primary">¥{fmt(batch.totalPay)}</td>
|
||
<td className="py-2.5 px-3 text-right text-sm text-danger">¥{fmt(batch.totalTax)}</td>
|
||
<td className="py-2.5 px-3 text-right text-sm font-bold text-safe">¥{fmt(batch.totalNetPay)}</td>
|
||
<td className="py-2.5 px-3">
|
||
{batch.status === 'ARCHIVED' ? (
|
||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-green-50 text-safe">
|
||
<Archive className="w-3 h-3" />已归档
|
||
</span>
|
||
) : (
|
||
<span className="px-2 py-0.5 rounded text-xs bg-amber-50 text-warning">草稿</span>
|
||
)}
|
||
</td>
|
||
<td className="py-2.5 px-3">
|
||
<div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
|
||
{batch.status !== 'ARCHIVED' && (
|
||
<>
|
||
<button
|
||
onClick={() => { setRenamingId(batch.id); setRenameValue(batch.name) }}
|
||
className="p-1 rounded hover:bg-blue-50 text-gray-500 hover:text-primary transition-colors"
|
||
aria-label="重命名"
|
||
title="重命名"
|
||
>
|
||
<SettingsIcon className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={async () => {
|
||
if (await confirm({ title: '删除批次', message: `确认删除批次「${batch.name}」?此操作不可撤销。` })) {
|
||
deleteBatchMutation.mutate(batch.id)
|
||
}
|
||
}}
|
||
disabled={deleteBatchMutation.isPending}
|
||
className="p-1 rounded hover:bg-red-50 text-gray-500 hover:text-danger transition-colors"
|
||
aria-label="删除"
|
||
title="删除"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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<string>('')
|
||
const [page, setPage] = useState(1)
|
||
const [pageSize, setPageSize] = useState(10)
|
||
const [showAddEmployee, setShowAddEmployee] = useState(false)
|
||
const payrollFileRef = useRef<HTMLInputElement>(null)
|
||
const [payrollImportResult, setPayrollImportResult] = useState<any>(null)
|
||
|
||
const { data: batch, isLoading } = useQuery<any>({
|
||
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 <div className="text-center py-8 text-gray-500">加载中...</div>
|
||
if (!batch) return <div className="text-center py-8 text-gray-500">批次不存在</div>
|
||
|
||
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 (
|
||
<td className="py-1 px-1 text-right" key={field}>
|
||
<input
|
||
type="text"
|
||
inputMode="decimal"
|
||
autoFocus
|
||
className="w-24 text-right border-b-2 border-primary bg-transparent px-1 py-0.5 text-xs focus:outline-none [appearance:none]"
|
||
value={editValue}
|
||
onChange={(e) => setEditValue(e.target.value)}
|
||
onBlur={handleBlur}
|
||
onKeyDown={handleKeyDown}
|
||
/>
|
||
</td>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<td
|
||
key={field}
|
||
className={`py-2 px-2 text-right ${className} ${canEdit ? 'cursor-text' : ''}`}
|
||
onClick={() => canEdit && startEdit(entry.employeeId, field, value)}
|
||
>
|
||
{canEdit ? (
|
||
<span className={`border-b border-dashed border-gray-300 hover:border-primary ${field === 'deduction' && value > 0 ? 'text-danger' : ''}`}>
|
||
{displayValue}
|
||
</span>
|
||
) : field === 'deduction' && value > 0 ? (
|
||
<span className="text-danger">{displayValue}</span>
|
||
) : (
|
||
displayValue
|
||
)}
|
||
</td>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<button onClick={onBack} className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm transition-colors">
|
||
<ChevronLeft className="w-4 h-4" />返回
|
||
</button>
|
||
<h2 className="text-sm font-medium">{batch.name}</h2>
|
||
{isArchived ? (
|
||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-green-50 text-safe">
|
||
<Archive className="w-3 h-3" />已归档
|
||
</span>
|
||
) : (
|
||
<span className="px-2 py-0.5 rounded text-xs bg-amber-50 text-warning">草稿</span>
|
||
)}
|
||
</div>
|
||
{!isArchived && (
|
||
<div className="flex gap-2">
|
||
<Button variant="secondary" size="sm" onClick={() => setShowAddEmployee(!showAddEmployee)}>
|
||
<Plus className="w-4 h-4 mr-1" />添加人员
|
||
</Button>
|
||
<input
|
||
ref={payrollFileRef}
|
||
type="file"
|
||
accept=".xlsx,.xls"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0]
|
||
if (file) importPayrollMutation.mutate(file)
|
||
e.target.value = ''
|
||
}}
|
||
/>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => payrollFileRef.current?.click()}
|
||
disabled={importPayrollMutation.isPending}
|
||
>
|
||
<Upload className="w-4 h-4 mr-1" />
|
||
{importPayrollMutation.isPending ? '导入中...' : '导入工资表'}
|
||
</Button>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => window.open('/api/v1/import/payroll-template', '_blank')}
|
||
>
|
||
<Download className="w-4 h-4 mr-1" />下载模板
|
||
</Button>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => importOvertimeMutation.mutate()}
|
||
disabled={importOvertimeMutation.isPending}
|
||
>
|
||
<Calculator className="w-4 h-4 mr-1" />
|
||
{importOvertimeMutation.isPending ? '导入中...' : '导入加班费'}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
onClick={async () => {
|
||
if (await confirm({ title: '归档确认', message: '确认归档?归档后批次将锁定不可编辑。工资条需在「工资条管理」中单独生成。', variant: 'primary' })) {
|
||
archiveMutation.mutate()
|
||
}
|
||
}}
|
||
disabled={archiveMutation.isPending}
|
||
>
|
||
<Archive className="w-4 h-4 mr-1" />
|
||
{archiveMutation.isPending ? '归档中...' : '归档'}
|
||
</Button>
|
||
<Button
|
||
variant="danger"
|
||
size="sm"
|
||
onClick={async () => {
|
||
if (await confirm({ title: '删除批次', message: `确认删除批次「${batch.name}」?此操作不可撤销。` })) {
|
||
deleteBatchMutation.mutate()
|
||
}
|
||
}}
|
||
disabled={deleteBatchMutation.isPending}
|
||
>
|
||
<Trash2 className="w-4 h-4 mr-1" />
|
||
{deleteBatchMutation.isPending ? '删除中...' : '删除'}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
{isArchived && (
|
||
<a href={`/api/v1/payroll2/batches/${batchId}/export?format=csv`} download>
|
||
<Button variant="secondary" size="sm">
|
||
<Download className="w-4 h-4 mr-1" />银行代发文件
|
||
</Button>
|
||
</a>
|
||
)}
|
||
</div>
|
||
|
||
{/* 批次汇总 */}
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||
<Card className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center flex-shrink-0">
|
||
<Users className="w-5 h-5 text-blue-600" />
|
||
</div>
|
||
<div>
|
||
<div className="text-lg font-bold">{batch.employeeCount}</div>
|
||
<div className="text-xs text-gray-500">人数</div>
|
||
</div>
|
||
</Card>
|
||
<Card className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-lg bg-indigo-50 flex items-center justify-center flex-shrink-0">
|
||
<TrendingUp className="w-5 h-5 text-indigo-600" />
|
||
</div>
|
||
<div>
|
||
<div className="text-lg font-bold text-primary">¥{fmt(batch.totalPay)}</div>
|
||
<div className="text-xs text-gray-500">应发合计</div>
|
||
</div>
|
||
</Card>
|
||
<Card className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-lg bg-red-50 flex items-center justify-center flex-shrink-0">
|
||
<TrendingDown className="w-5 h-5 text-red-600" />
|
||
</div>
|
||
<div>
|
||
<div className="text-lg font-bold text-danger">¥{fmt(batch.totalTax)}</div>
|
||
<div className="text-xs text-gray-500">个税合计</div>
|
||
</div>
|
||
</Card>
|
||
<Card className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-lg bg-green-50 flex items-center justify-center flex-shrink-0">
|
||
<BadgeCheck className="w-5 h-5 text-green-600" />
|
||
</div>
|
||
<div>
|
||
<div className="text-lg font-bold text-safe">¥{fmt(batch.totalNetPay)}</div>
|
||
<div className="text-xs text-gray-500">实发合计</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* 添加人员 */}
|
||
{showAddEmployee && !isArchived && (
|
||
<AddEmployeeToBatch batchId={batchId} onClose={() => setShowAddEmployee(false)} />
|
||
)}
|
||
|
||
{/* 工资表导入结果 */}
|
||
{payrollImportResult && (
|
||
<Card>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<h3 className="text-xs font-medium">工资表导入结果</h3>
|
||
<button onClick={() => setPayrollImportResult(null)} className="text-gray-500"><X className="w-4 h-4" /></button>
|
||
</div>
|
||
<div className="text-sm space-y-1">
|
||
<div className="text-gray-600">总计 {payrollImportResult.total} 行,成功更新 {payrollImportResult.updated} 条</div>
|
||
{payrollImportResult.errors?.length > 0 && (
|
||
<div className="mt-2">
|
||
<div className="text-warning text-xs font-medium">错误详情({payrollImportResult.errors.length}条):</div>
|
||
<ul className="mt-1 space-y-0.5 text-xs text-danger max-h-40 overflow-y-auto">
|
||
{payrollImportResult.errors.map((err: string, i: number) => (
|
||
<li key={i}>{err}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* 提示 */}
|
||
{!isArchived && (
|
||
<div className="text-xs text-gray-500 flex items-center gap-1">
|
||
<Info className="w-3.5 h-3.5" />
|
||
带下划线的单元格可直接点击编辑,失焦自动保存。社保/公积金可手动覆盖,个税和实发自动计算。
|
||
</div>
|
||
)}
|
||
|
||
{/* 人员表格 */}
|
||
<Card>
|
||
{batch.entries.length > 0 && (
|
||
<Pagination page={page} pageSize={pageSize} total={batch.entries.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 text-right">基本工资</th>
|
||
<th className="py-2 px-2 text-right">加班费</th>
|
||
<th className="py-2 px-2 text-right">津贴</th>
|
||
{isBonus && <th className="py-2 px-2 text-right">奖金</th>}
|
||
{!isBonus && <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 text-gray-500">应发</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 text-gray-500">个税</th>
|
||
<th className="py-2 px-2 text-right text-gray-500">实发</th>
|
||
<th className="py-2 px-2">风险</th>
|
||
{!isArchived && <th className="py-2 px-2">操作</th>}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{batch.entries.slice((page - 1) * pageSize, page * pageSize).map((entry: any) => (
|
||
<tr key={entry.id} className="border-b last:border-0 hover:bg-gray-25">
|
||
<td className="py-2 px-2">
|
||
<div className="text-xs font-medium">{entry.employee.name}</div>
|
||
<div className="text-xs text-gray-500">{entry.employee.department}</div>
|
||
{entry.employee.status === 'RESIGNED' && (
|
||
<span className="text-xs text-danger">已离职</span>
|
||
)}
|
||
</td>
|
||
{renderCell(entry, 'baseSalary')}
|
||
{renderCell(entry, 'overtimePay')}
|
||
{renderCell(entry, 'allowance')}
|
||
{renderCell(entry, 'bonus')}
|
||
{renderCell(entry, 'deduction')}
|
||
{/* 计算项 - 灰显 */}
|
||
<td className="py-2 px-2 text-right font-medium text-gray-600">{fmt(entry.totalPay)}</td>
|
||
{renderCell(entry, 'socialEmp')}
|
||
{renderCell(entry, 'housingEmp')}
|
||
<td className="py-2 px-2 text-right text-gray-500">{fmt(entry.tax)}</td>
|
||
<td className="py-2 px-2 text-right font-bold text-safe">{fmt(entry.netPay)}</td>
|
||
<td className="py-2 px-2">
|
||
{entry.riskWarnings && entry.riskWarnings.length > 0 ? (
|
||
<span className="text-danger cursor-help" title={entry.riskWarnings.join('\n')}>
|
||
<AlertTriangle className="w-4 h-4" />
|
||
</span>
|
||
) : (
|
||
<span className="text-gray-300">—</span>
|
||
)}
|
||
</td>
|
||
{!isArchived && (
|
||
<td className="py-2 px-2">
|
||
<button
|
||
onClick={() => removeEmployeeMutation.mutate(entry.employeeId)}
|
||
className="text-gray-500 hover:text-danger p-1"
|
||
title="移除"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</td>
|
||
)}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function AddEmployeeToBatch({ batchId, onClose }: { batchId: string; onClose: () => void }) {
|
||
const queryClient = useQueryClient()
|
||
const [selected, setSelected] = useState<string[]>([])
|
||
|
||
const { data: employees } = useQuery<any>({
|
||
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 (
|
||
<Card>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h3 className="text-xs font-medium">添加人员到批次</h3>
|
||
<button onClick={onClose} className="text-gray-500">✕</button>
|
||
</div>
|
||
<div className="max-h-60 overflow-y-auto space-y-1">
|
||
{employees?.items?.map((emp: any) => (
|
||
<label key={emp.id} className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={selected.includes(emp.id)}
|
||
onChange={(e) => {
|
||
if (e.target.checked) setSelected([...selected, emp.id])
|
||
else setSelected(selected.filter(id => id !== emp.id))
|
||
}}
|
||
/>
|
||
<span className="text-xs">{emp.name} - {emp.department}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
<div className="mt-3 flex gap-2">
|
||
<Button size="sm" onClick={() => addMutation.mutate(selected)} disabled={selected.length === 0 || addMutation.isPending}>
|
||
{addMutation.isPending ? '添加中...' : `添加 ${selected.length} 人`}
|
||
</Button>
|
||
<Button size="sm" variant="secondary" onClick={onClose}>取消</Button>
|
||
</div>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
// ========== 薪酬模版管理 ==========
|
||
|
||
function TemplateManager() {
|
||
const queryClient = useQueryClient()
|
||
const confirm = useConfirm()
|
||
const [showForm, setShowForm] = useState(false)
|
||
const [editingItem, setEditingItem] = useState<any>(null)
|
||
const [form, setForm] = useState({
|
||
name: '',
|
||
code: '',
|
||
type: 'INPUT' as 'INPUT' | 'CALCULATED',
|
||
formula: '',
|
||
order: 99,
|
||
isEditable: true,
|
||
})
|
||
|
||
const { data: items, isLoading } = useQuery<any[]>({
|
||
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 (
|
||
<div className="space-y-3">
|
||
<Card>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div>
|
||
<h2 className="text-sm font-medium">薪酬结构模版</h2>
|
||
<span className="text-xs text-gray-500">定义薪酬项和计算关系</span>
|
||
</div>
|
||
<Button size="sm" onClick={handleAdd}>
|
||
<Plus className="w-4 h-4 mr-1" />新增薪酬项
|
||
</Button>
|
||
</div>
|
||
{isLoading ? (
|
||
<div className="text-center py-4 text-gray-500">加载中...</div>
|
||
) : (
|
||
<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">字段代码</th>
|
||
<th className="py-2 px-2">类型</th>
|
||
<th className="py-2 px-2">计算公式</th>
|
||
<th className="py-2 px-2">可编辑</th>
|
||
<th className="py-2 px-2 text-right">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{items?.map((item: any) => (
|
||
<tr key={item.id} className="border-b last:border-0 hover:bg-gray-50">
|
||
<td className="py-2 px-2 text-gray-500">{item.order}</td>
|
||
<td className="py-2 px-2 text-sm font-medium">{item.name}</td>
|
||
<td className="py-2 px-2 text-gray-500 font-mono">{item.code}</td>
|
||
<td className="py-2 px-2">
|
||
<span className={`px-2 py-0.5 rounded text-xs ${item.type === 'INPUT' ? 'bg-blue-50 text-blue-600' : 'bg-purple-50 text-purple-600'}`}>
|
||
{item.type === 'INPUT' ? '输入项' : '计算项'}
|
||
</span>
|
||
</td>
|
||
<td className="py-2 px-2 text-gray-500 font-mono">{item.formula || '—'}</td>
|
||
<td className="py-2 px-2">
|
||
<span className={`text-xs ${item.isEditable ? 'text-safe' : 'text-gray-500'}`}>
|
||
{item.isEditable ? '可编辑' : '不可编辑'}
|
||
</span>
|
||
</td>
|
||
<td className="py-2 px-2">
|
||
<div className="flex items-center justify-end gap-2">
|
||
<button
|
||
onClick={() => handleEdit(item)}
|
||
className="text-xs text-gray-500 hover:text-primary"
|
||
>
|
||
编辑
|
||
</button>
|
||
{!item.isDefault && (
|
||
<button
|
||
onClick={async () => {
|
||
if (await confirm({ title: '删除薪酬项', message: `确认删除薪酬项「${item.name}」?` })) {
|
||
deleteMutation.mutate(item.id)
|
||
}
|
||
}}
|
||
className="text-xs text-gray-500 hover:text-danger"
|
||
>
|
||
删除
|
||
</button>
|
||
)}
|
||
{item.isDefault && <span className="text-xs text-gray-300">预置</span>}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
<p className="text-xs text-gray-500 mt-3">
|
||
预置项为系统默认薪酬结构,不可删除。计算项的公式支持引用其他字段进行自动计算。
|
||
</p>
|
||
</Card>
|
||
|
||
{/* 新增/编辑弹窗 */}
|
||
{showForm && (
|
||
<Modal open onClose={() => { setShowForm(false); setEditingItem(null) }}>
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="font-medium">{editingItem ? '编辑薪酬项' : '新增薪酬项'}</h3>
|
||
<button onClick={() => { setShowForm(false); setEditingItem(null) }} className="text-gray-500 hover:text-gray-700">
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label>名称</Label>
|
||
<Input
|
||
type="text"
|
||
value={form.name}
|
||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||
placeholder="如:交通补贴"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label>字段代码</Label>
|
||
<Input
|
||
type="text"
|
||
value={form.code}
|
||
onChange={(e) => setForm({ ...form, code: e.target.value })}
|
||
placeholder="如:transportAllowance"
|
||
disabled={!!editingItem}
|
||
/>
|
||
{editingItem && (
|
||
<p className="text-xs text-gray-500 mt-1">字段代码创建后不可修改</p>
|
||
)}
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label>类型</Label>
|
||
<Select
|
||
value={form.type}
|
||
onChange={(e) => setForm({ ...form, type: e.target.value as 'INPUT' | 'CALCULATED' })}
|
||
disabled={!!editingItem}
|
||
>
|
||
<option value="INPUT">输入项(手动填写)</option>
|
||
<option value="CALCULATED">计算项(公式自动计算)</option>
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<Label>排序</Label>
|
||
<Input
|
||
type="number"
|
||
value={form.order}
|
||
onChange={(e) => setForm({ ...form, order: Number(e.target.value) })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{form.type === 'CALCULATED' && (
|
||
<div>
|
||
<Label>计算公式</Label>
|
||
<Input
|
||
type="text"
|
||
value={form.formula}
|
||
onChange={(e) => setForm({ ...form, formula: e.target.value })}
|
||
placeholder="如:baseSalary + overtimePay + allowance - deduction"
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-1">可引用其他字段代码进行加减乘除运算</p>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={form.isEditable}
|
||
onChange={(e) => setForm({ ...form, isEditable: e.target.checked })}
|
||
/>
|
||
<span className="text-sm">发薪批次中可手动编辑</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-2">
|
||
<Button
|
||
onClick={handleSubmit}
|
||
disabled={createMutation.isPending || updateMutation.isPending}
|
||
className="flex-1"
|
||
>
|
||
{createMutation.isPending || updateMutation.isPending ? '保存中...' : editingItem ? '保存修改' : '确认新增'}
|
||
</Button>
|
||
<Button variant="secondary" onClick={() => { setShowForm(false); setEditingItem(null) }}>
|
||
取消
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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<HTMLInputElement>(null)
|
||
const [previewData, setPreviewData] = useState<any[]>([])
|
||
const [editingId, setEditingId] = useState<string | null>(null)
|
||
const [editForm, setEditForm] = useState({ weekdayHours: 0, weekendHours: 0, holidayHours: 0 })
|
||
|
||
// 加班费规则配置
|
||
const { data: config, isLoading: configLoading } = useQuery<any>({
|
||
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<any[]>({
|
||
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<HTMLInputElement>) => {
|
||
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 (
|
||
<div className="space-y-4">
|
||
{/* 步骤指示器 */}
|
||
<div className="flex items-center gap-2">
|
||
{[
|
||
{ n: 1, label: '设定计算规则' },
|
||
{ n: 2, label: '导入考勤数据' },
|
||
{ n: 3, label: '查看加班记录' },
|
||
].map((s) => (
|
||
<div key={s.n} className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setStep(s.n as 1 | 2 | 3)}
|
||
className={`px-3 py-1.5 rounded text-xs flex items-center gap-1.5 transition-colors ${
|
||
step === s.n ? 'bg-primary text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'
|
||
}`}
|
||
>
|
||
<span className={`w-4 h-4 rounded-full flex items-center justify-center text-xs ${
|
||
step === s.n ? 'bg-white/20' : step > s.n ? 'bg-safe text-white' : 'bg-gray-300 text-white'
|
||
}`}>{step > s.n ? '✓' : s.n}</span>
|
||
{s.label}
|
||
</button>
|
||
{s.n < 3 && <div className="w-4 h-px bg-gray-300" />}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Step 1: 设定计算规则 */}
|
||
{step === 1 && (
|
||
<Card>
|
||
<h2 className="text-xs font-medium mb-3 flex items-center gap-2"><SettingsIcon className="w-4 h-4" />加班费计算规则</h2>
|
||
{configLoading ? (
|
||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
<div className="grid md:grid-cols-3 gap-4">
|
||
<div>
|
||
<Label>工作日加班倍率</Label>
|
||
<Input type="number" step="0.1" defaultValue={cfg.weekdayRate} onBlur={(e) => saveConfigMutation.mutate({ ...cfg, weekdayRate: Number(e.target.value) })} />
|
||
<p className="text-xs text-gray-500 mt-1">平时加班按小时工资的倍率</p>
|
||
</div>
|
||
<div>
|
||
<Label>休息日加班倍率</Label>
|
||
<Input type="number" step="0.1" defaultValue={cfg.weekendRate} onBlur={(e) => saveConfigMutation.mutate({ ...cfg, weekendRate: Number(e.target.value) })} />
|
||
<p className="text-xs text-gray-500 mt-1">周末加班按小时工资的倍率</p>
|
||
</div>
|
||
<div>
|
||
<Label>法定节假日加班倍率</Label>
|
||
<Input type="number" step="0.1" defaultValue={cfg.holidayRate} onBlur={(e) => saveConfigMutation.mutate({ ...cfg, holidayRate: Number(e.target.value) })} />
|
||
<p className="text-xs text-gray-500 mt-1">法定节假日加班按小时工资的倍率</p>
|
||
</div>
|
||
</div>
|
||
<div className="grid md:grid-cols-2 gap-4">
|
||
<div>
|
||
<Label>月计薪天数</Label>
|
||
<Input type="number" step="0.01" defaultValue={cfg.monthlyDays} onBlur={(e) => saveConfigMutation.mutate({ ...cfg, monthlyDays: Number(e.target.value) })} />
|
||
<p className="text-xs text-gray-500 mt-1">用于折算日工资/小时工资(默认21.75天)</p>
|
||
</div>
|
||
<div>
|
||
<Label>每日工时(小时)</Label>
|
||
<Input type="number" step="0.5" defaultValue={cfg.dailyHours} onBlur={(e) => saveConfigMutation.mutate({ ...cfg, dailyHours: Number(e.target.value) })} />
|
||
<p className="text-xs text-gray-500 mt-1">用于折算小时工资(默认8小时)</p>
|
||
</div>
|
||
</div>
|
||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2">
|
||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||
<div>
|
||
<p>计算公式:小时工资 = 月工资 ÷ 月计薪天数 ÷ 每日工时</p>
|
||
<p>加班费 = 小时工资 × 倍率 × 加班工时</p>
|
||
<p className="mt-1 text-gray-500">实际金额在发薪批次中根据员工月工资自动计算,此处只配置倍率规则。</p>
|
||
</div>
|
||
</div>
|
||
{saveConfigMutation.isSuccess && (
|
||
<div className="text-xs text-safe flex items-center gap-1"><Check className="w-3.5 h-3.5" />规则已保存</div>
|
||
)}
|
||
<div className="flex justify-end">
|
||
<Button onClick={() => setStep(2)}>下一步:导入考勤数据 →</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
)}
|
||
|
||
{/* Step 2: 导入考勤数据 */}
|
||
{step === 2 && (
|
||
<Card>
|
||
<h2 className="text-xs font-medium mb-3 flex items-center gap-2"><Upload className="w-4 h-4" />导入加班工时</h2>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<Label>月份</Label>
|
||
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-48" />
|
||
</div>
|
||
<div className="border-t pt-3">
|
||
<input ref={fileInputRef} type="file" accept=".csv" className="hidden" onChange={handleFileUpload} />
|
||
<Button variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={batchImportMutation.isPending}>
|
||
<Upload className="w-4 h-4 mr-1" />
|
||
{batchImportMutation.isPending ? '导入中...' : '选择CSV文件'}
|
||
</Button>
|
||
<div className="text-xs text-gray-500 mt-2">
|
||
CSV格式:姓名,工作日加班(h),休息日加班(h),节假日加班(h),月份(可选)
|
||
</div>
|
||
</div>
|
||
|
||
{previewData.length > 0 && (
|
||
<div className="border-t pt-3 space-y-3">
|
||
<div className="text-xs font-medium text-gray-700">预览({previewData.length}条)</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b text-xs text-gray-500">
|
||
<th className="py-2 text-left">员工</th>
|
||
<th className="py-2 text-left">部门</th>
|
||
<th className="py-2 text-right">工作日(h)</th>
|
||
<th className="py-2 text-right">休息日(h)</th>
|
||
<th className="py-2 text-right">节假日(h)</th>
|
||
<th className="py-2 text-left">月份</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{previewData.map((d, i) => (
|
||
<tr key={i} className="border-b last:border-0">
|
||
<td className="py-2">{d.employeeName}</td>
|
||
<td className="py-2 text-gray-500">{d.department}</td>
|
||
<td className="py-2 text-right">{d.weekdayHours}</td>
|
||
<td className="py-2 text-right">{d.weekendHours}</td>
|
||
<td className="py-2 text-right">{d.holidayHours}</td>
|
||
<td className="py-2 text-gray-500">{d.month}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button onClick={confirmImport} disabled={batchImportMutation.isPending}>
|
||
{batchImportMutation.isPending ? '保存中...' : '确认导入'}
|
||
</Button>
|
||
<Button variant="secondary" onClick={() => setPreviewData([])}>取消</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-between border-t pt-3">
|
||
<Button variant="secondary" onClick={() => setStep(1)}>← 上一步</Button>
|
||
<Button onClick={() => setStep(3)}>下一步:查看加班记录 →</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Step 3: 查看加班记录 */}
|
||
{step === 3 && (
|
||
<Card>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h2 className="text-xs font-medium flex items-center gap-2"><FileText className="w-4 h-4" />加班记录({month})</h2>
|
||
<div className="flex items-center gap-2">
|
||
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-32" />
|
||
<Button variant="secondary" size="sm" onClick={() => refetch()}>刷新</Button>
|
||
</div>
|
||
</div>
|
||
{!overtimeRecords || overtimeRecords.length === 0 ? (
|
||
<div className="text-center py-8 text-gray-500">该月暂无加班记录,请先导入考勤数据</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b text-xs text-gray-500">
|
||
<th className="py-2 text-left">员工</th>
|
||
<th className="py-2 text-left">部门</th>
|
||
<th className="py-2 text-right">工作日(h)</th>
|
||
<th className="py-2 text-right">休息日(h)</th>
|
||
<th className="py-2 text-right">节假日(h)</th>
|
||
<th className="py-2 text-right">加班费</th>
|
||
<th className="py-2 text-center">状态</th>
|
||
<th className="py-2 text-center">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{overtimeRecords.map((r: any) => (
|
||
<tr key={r.id} className="border-b last:border-0 hover:bg-gray-50">
|
||
<td className="py-2">{r.employee?.name}</td>
|
||
<td className="py-2 text-gray-500">{r.employee?.department}</td>
|
||
{editingId === r.id ? (
|
||
<>
|
||
<td className="py-1 text-right">
|
||
<input
|
||
type="number"
|
||
className="w-16 text-right border rounded px-1 py-0.5 text-xs"
|
||
value={editForm.weekdayHours}
|
||
onChange={(e) => setEditForm({ ...editForm, weekdayHours: Number(e.target.value) })}
|
||
min="0"
|
||
step="0.5"
|
||
/>
|
||
</td>
|
||
<td className="py-1 text-right">
|
||
<input
|
||
type="number"
|
||
className="w-16 text-right border rounded px-1 py-0.5 text-xs"
|
||
value={editForm.weekendHours}
|
||
onChange={(e) => setEditForm({ ...editForm, weekendHours: Number(e.target.value) })}
|
||
min="0"
|
||
step="0.5"
|
||
/>
|
||
</td>
|
||
<td className="py-1 text-right">
|
||
<input
|
||
type="number"
|
||
className="w-16 text-right border rounded px-1 py-0.5 text-xs"
|
||
value={editForm.holidayHours}
|
||
onChange={(e) => setEditForm({ ...editForm, holidayHours: Number(e.target.value) })}
|
||
min="0"
|
||
step="0.5"
|
||
/>
|
||
</td>
|
||
</>
|
||
) : (
|
||
<>
|
||
<td className="py-2 text-right cursor-pointer hover:text-blue-600" onClick={() => startEdit(r)}>{r.weekdayHours || '-'}</td>
|
||
<td className="py-2 text-right cursor-pointer hover:text-blue-600" onClick={() => startEdit(r)}>{r.weekendHours || '-'}</td>
|
||
<td className="py-2 text-right cursor-pointer hover:text-blue-600" onClick={() => startEdit(r)}>{r.holidayHours || '-'}</td>
|
||
</>
|
||
)}
|
||
<td className="py-2 text-right font-medium text-gray-700">
|
||
{r.totalPay > 0 ? `¥${fmt(r.totalPay)}` : <span className="text-gray-500">待计算</span>}
|
||
</td>
|
||
<td className="py-2 text-center">
|
||
{r.batchId ? (
|
||
<span className="px-2 py-0.5 rounded bg-green-50 text-safe text-xs">已入批次</span>
|
||
) : (
|
||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-600 text-xs">未入批次</span>
|
||
)}
|
||
</td>
|
||
<td className="py-2 text-center">
|
||
{editingId === r.id ? (
|
||
<div className="flex items-center justify-center gap-1">
|
||
<button
|
||
onClick={saveEdit}
|
||
disabled={updateOvertimeMutation.isPending}
|
||
className="text-safe hover:text-green-700 disabled:opacity-50"
|
||
title="保存"
|
||
>
|
||
<Check className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => setEditingId(null)}
|
||
className="text-gray-500 hover:text-gray-600"
|
||
title="取消"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
) : (
|
||
!r.batchId && (
|
||
<button
|
||
onClick={() => startEdit(r)}
|
||
className="text-gray-500 hover:text-blue-600"
|
||
title="编辑"
|
||
>
|
||
<FileText className="w-4 h-4" />
|
||
</button>
|
||
)
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
<div className="flex justify-between border-t pt-3 mt-3">
|
||
<Button variant="secondary" onClick={() => setStep(2)}>← 上一步</Button>
|
||
<div className="text-xs text-gray-500 flex items-center gap-1">
|
||
<Info className="w-3.5 h-3.5" />
|
||
加班费金额在发薪批次中「导入加班费」时自动计算
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 () => {
|
||
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<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">
|
||
<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>
|
||
)
|
||
}
|