feat: 分页组件、Dashboard待办图标、归档与工资条解耦、总览数据优化
- 新增公用 Pagination 组件,Roster/Money/Dashboard 列表加分页 - Dashboard 待办按类型显示不同图标(合同/薪资/解聘/月度) - 待办分为「风险提醒」「月度任务」两个顶层 tab - 归档与工资条生成解耦:归档只锁定批次,工资条单独生成 - 工资条管理新增「从批次汇总生成」按钮 - Dashboard 总览优先从已归档批次 BatchEntry 汇总数据 - 新增工资条生成待办提醒,生成后自动标记完成 - 修复高风险统计只含 CONTRACT/TERMINATION 类型 - 修复月度任务去重逻辑覆盖 SALARY 类型
This commit is contained in:
+602
-98
@@ -1,17 +1,24 @@
|
||||
import { useState, useMemo, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Calculator, AlertCircle, Info, Save, Check, Upload, Zap, Bell } from 'lucide-react'
|
||||
import { Calculator, AlertCircle, Info, Save, Check, Upload, Bell, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText } 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'
|
||||
|
||||
type Tab = 'overtime' | 'social' | 'payslip'
|
||||
// 金额格式化:保留两位小数 + 千分位
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
type Tab = 'batch' | 'template' | 'overtime' | 'social' | 'payslip'
|
||||
|
||||
export default function Money() {
|
||||
const [tab, setTab] = useState<Tab>('overtime')
|
||||
const [tab, setTab] = useState<Tab>('batch')
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: 'batch', label: '发薪批次' },
|
||||
{ key: 'template', label: '薪酬模版' },
|
||||
{ key: 'overtime', label: '加班费计算' },
|
||||
{ key: 'social', label: '社保公积金' },
|
||||
{ key: 'payslip', label: '工资条管理' },
|
||||
@@ -21,12 +28,12 @@ export default function Money() {
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold">薪税计算</h1>
|
||||
|
||||
<div className="flex gap-1 border-b">
|
||||
<div className="flex gap-1 border-b overflow-x-auto">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
className={`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'
|
||||
}`}
|
||||
>
|
||||
@@ -35,6 +42,8 @@ export default function Money() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'batch' && <BatchManager />}
|
||||
{tab === 'template' && <TemplateManager />}
|
||||
{tab === 'overtime' && <OvertimeCalculator />}
|
||||
{tab === 'social' && <SocialInsuranceCalculator />}
|
||||
{tab === 'payslip' && <PayslipManager />}
|
||||
@@ -42,6 +51,561 @@ export default function Money() {
|
||||
)
|
||||
}
|
||||
|
||||
// ========== 发薪批次管理 ==========
|
||||
|
||||
function BatchManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [createType, setCreateType] = useState<'REGULAR' | 'TERMINATION' | 'BONUS'>('REGULAR')
|
||||
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],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/payroll2/batches', { params: { month } }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/payroll2/batches', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
|
||||
setShowCreateModal(false)
|
||||
},
|
||||
})
|
||||
|
||||
if (selectedBatchId) {
|
||||
return <BatchDetail batchId={selectedBatchId} onBack={() => setSelectedBatchId(null)} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 重复发薪提醒 */}
|
||||
{checkResult?.hasArchivedBatch && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-md bg-amber-50 text-warning text-sm">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
本月已有 {checkResult.archivedCount} 个已归档批次{checkResult.payslipsPublished ? ',工资条已发布' : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-40" />
|
||||
{batches && batches.length > 0 && (
|
||||
<span className="text-sm text-gray-500">{batches.length} 个批次</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => { setCreateType('REGULAR'); setShowCreateModal(true) }}>
|
||||
<Plus className="w-4 h-4 mr-1" />创建发薪批次
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreateModal && (
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">创建发薪批次</h2>
|
||||
<div className="space-y-4 max-w-md">
|
||||
<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>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<p>• 常规发薪:自动拉入在职员工和本月离职员工,带入上次发薪数据</p>
|
||||
<p>• 离职结算:拉入本月离职员工,关联解聘记录</p>
|
||||
<p>• 年终奖/奖金:单独计税,不并入当月工资</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => createMutation.mutate({ month, type: createType })}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? '创建中...' : '确认创建'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowCreateModal(false)}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</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) }} />
|
||||
{batches.slice((page - 1) * pageSize, page * pageSize).map((batch: any) => (
|
||||
<Card key={batch.id} className="cursor-pointer hover:shadow-md transition-shadow" >
|
||||
<div className="flex items-center justify-between" onClick={() => setSelectedBatchId(batch.id)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Layers className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">{batch.name}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{batch.employeeCount} 人 · 应发 ¥{fmt(batch.totalPay)} · 实发 ¥{fmt(batch.totalNetPay)}
|
||||
{batch.type === 'BONUS' && ' · 单独计税'}
|
||||
{batch.type === 'TERMINATION' && ' · 离职结算'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{batch.status === 'ARCHIVED' ? (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe flex items-center gap-1">
|
||||
<Archive className="w-3 h-3" />已归档
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-amber-50 text-warning">草稿</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<string>('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [showAddEmployee, setShowAddEmployee] = useState(false)
|
||||
|
||||
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'] })
|
||||
alert('批次已归档。归档后批次锁定不可编辑。可前往「工资条管理」生成工资条。')
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
if (!batch) return <div className="text-center py-8 text-gray-400">批次不存在</div>
|
||||
|
||||
const isArchived = batch.status === 'ARCHIVED'
|
||||
const isBonus = batch.type === 'BONUS'
|
||||
|
||||
// 可编辑的输入项字段
|
||||
const editableFields = isBonus
|
||||
? ['bonus']
|
||||
: ['baseSalary', 'overtimePay', 'allowance', 'deduction', 'bonus']
|
||||
|
||||
// 点击单元格进入编辑
|
||||
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
|
||||
// 找到当前 entry 的其他字段值一起提交
|
||||
const entry = batch.entries.find((e: any) => e.employeeId === employeeId)
|
||||
if (!entry) { setEditCell(null); return }
|
||||
const data: any = {}
|
||||
editableFields.forEach(f => {
|
||||
data[f] = f === field ? numValue : entry[f]
|
||||
})
|
||||
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-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onBack} className="text-gray-400 hover:text-gray-600 text-sm">← 返回</button>
|
||||
<h2 className="font-semibold">{batch.name}</h2>
|
||||
{isArchived ? (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe">已归档</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>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('确认归档?归档后批次将锁定不可编辑。工资条需在「工资条管理」中单独生成。')) {
|
||||
archiveMutation.mutate()
|
||||
}
|
||||
}}
|
||||
disabled={archiveMutation.isPending}
|
||||
>
|
||||
<Archive className="w-4 h-4 mr-1" />
|
||||
{archiveMutation.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>
|
||||
<div className="text-xl font-bold">{batch.employeeCount}</div>
|
||||
<div className="text-xs text-gray-500">人数</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="text-xl 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>
|
||||
<div className="text-xl 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>
|
||||
<div className="text-xl 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)} />
|
||||
)}
|
||||
|
||||
{/* 提示 */}
|
||||
{!isArchived && (
|
||||
<div className="text-xs text-gray-400 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-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-400">应发</th>
|
||||
<th className="py-2 px-2 text-right text-gray-400">社保</th>
|
||||
<th className="py-2 px-2 text-right text-gray-400">公积金</th>
|
||||
<th className="py-2 px-2 text-right text-gray-400">个税</th>
|
||||
<th className="py-2 px-2 text-right text-gray-400">实发</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="font-medium">{entry.employee.name}</div>
|
||||
<div className="text-xs text-gray-400">{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>
|
||||
<td className="py-2 px-2 text-right text-gray-400">{fmt(entry.socialEmp)}</td>
|
||||
<td className="py-2 px-2 text-right text-gray-400">{fmt(entry.housingEmp)}</td>
|
||||
<td className="py-2 px-2 text-right text-gray-400">{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-400 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="font-medium">添加人员到批次</h3>
|
||||
<button onClick={onClose} className="text-gray-400">✕</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-sm">{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 { data: items, isLoading } = useQuery<any[]>({
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-medium">薪酬结构模版</h2>
|
||||
<span className="text-sm text-gray-400">定义薪酬项和计算关系</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-4 text-gray-400">加载中...</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left 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">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items?.map((item: any) => (
|
||||
<tr key={item.id} className="border-b last:border-0">
|
||||
<td className="py-2 px-2 text-gray-400">{item.order}</td>
|
||||
<td className="py-2 px-2 font-medium">{item.name}</td>
|
||||
<td className="py-2 px-2 text-gray-500 font-mono text-xs">{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 text-xs font-mono">{item.formula || '—'}</td>
|
||||
<td className="py-2 px-2">
|
||||
<span className={`text-xs ${item.isEditable ? 'text-safe' : 'text-gray-400'}`}>
|
||||
{item.isEditable ? '可编辑' : '不可编辑'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
{!item.isDefault && (
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(item.id)}
|
||||
className="text-xs text-gray-400 hover:text-danger"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
{item.isDefault && <span className="text-xs text-gray-300">预置</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
预置项为系统默认薪酬结构,不可删除。计算项的公式支持引用其他字段进行自动计算。
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OvertimeCalculator() {
|
||||
const queryClient = useQueryClient()
|
||||
const [monthlyWage, setMonthlyWage] = useState(8000)
|
||||
@@ -166,7 +730,7 @@ function OvertimeCalculator() {
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4 flex items-center gap-2"><Calculator className="w-5 h-5" />计算结果</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">小时工资:<span className="text-gray-900 font-medium">¥{result.hourlyWage.toFixed(2)}</span></div>
|
||||
<div className="text-sm text-gray-500">小时工资:<span className="text-gray-900 font-medium">¥{fmt(result.hourlyWage)}</span></div>
|
||||
<div className="space-y-2">
|
||||
<ResultRow label={`工作日 ${weekdayHours}h × 1.5`} value={result.weekdayPay} />
|
||||
<ResultRow label={`休息日 ${weekendHours}h × 2.0`} value={result.weekendPay} />
|
||||
@@ -175,7 +739,7 @@ function OvertimeCalculator() {
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">合计</span>
|
||||
<span className="text-xl font-bold text-primary">¥{result.total.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||
<span className="text-xl font-bold text-primary">¥{fmt(result.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{result.totalHours > 36 && (
|
||||
@@ -235,7 +799,7 @@ function ResultRow({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">{label}</span>
|
||||
<span className="font-medium">¥{value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||
<span className="font-medium">¥{fmt(value)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -243,21 +807,8 @@ function ResultRow({ label, value }: { label: string; value: number }) {
|
||||
function PayslipManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({
|
||||
employeeId: '',
|
||||
baseSalary: 8000,
|
||||
allowance: 0,
|
||||
deduction: 0,
|
||||
})
|
||||
|
||||
const { data: employees } = useQuery<{ items: { id: string; name: string; department: string }[] }>({
|
||||
queryKey: ['employees-for-payslip'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/employees', { params: { pageSize: 100 } }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
const { data: payslips, isLoading } = useQuery<any[]>({
|
||||
queryKey: ['payslips', month],
|
||||
@@ -267,24 +818,18 @@ function PayslipManager() {
|
||||
},
|
||||
})
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/payroll/payslip/generate', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['payslips'] })
|
||||
setShowCreate(false)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/payroll/payslip/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslips'] }),
|
||||
})
|
||||
|
||||
const batchGenerateMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/payroll/payslip/batch-generate', data),
|
||||
onSuccess: () => {
|
||||
const generateFromBatchMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/payroll2/payslips/generate', data),
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['payslips'] })
|
||||
alert('批量生成完成')
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
const n = res?.data?.generated || 0
|
||||
alert(`已从归档批次汇总生成 ${n} 条工资条并发布。`)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -305,68 +850,27 @@ function PayslipManager() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setShowCreate(!showCreate)}>生成工资条</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => batchGenerateMutation.mutate({ month })}
|
||||
disabled={batchGenerateMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(`确认从 ${month} 已归档批次汇总生成工资条?这将覆盖已有的工资条数据。`)) {
|
||||
generateFromBatchMutation.mutate({ month })
|
||||
}
|
||||
}}
|
||||
disabled={generateFromBatchMutation.isPending}
|
||||
>
|
||||
<Zap className="w-4 h-4 mr-1" />
|
||||
{batchGenerateMutation.isPending ? '生成中...' : '一键全员生成'}
|
||||
<Layers className="w-4 h-4 mr-1" />
|
||||
{generateFromBatchMutation.isPending ? '生成中...' : '从批次汇总生成'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">生成工资条</h2>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>选择员工</Label>
|
||||
<Select value={createForm.employeeId} onChange={(e) => setCreateForm({ ...createForm, employeeId: e.target.value })}>
|
||||
<option value="">请选择</option>
|
||||
{employees?.items.map((emp) => (
|
||||
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>基本工资</Label>
|
||||
<Input type="number" value={createForm.baseSalary} onChange={(e) => setCreateForm({ ...createForm, baseSalary: Number(e.target.value) || 0 })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>津贴</Label>
|
||||
<Input type="number" value={createForm.allowance} onChange={(e) => setCreateForm({ ...createForm, allowance: Number(e.target.value) || 0 })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>扣款</Label>
|
||||
<Input type="number" value={createForm.deduction} onChange={(e) => setCreateForm({ ...createForm, deduction: Number(e.target.value) || 0 })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button
|
||||
onClick={() => generateMutation.mutate({
|
||||
employeeId: createForm.employeeId,
|
||||
month,
|
||||
baseSalary: createForm.baseSalary,
|
||||
allowance: createForm.allowance,
|
||||
deduction: createForm.deduction,
|
||||
})}
|
||||
disabled={!createForm.employeeId || generateMutation.isPending}
|
||||
>
|
||||
{generateMutation.isPending ? '生成中...' : '确认生成(自动关联加班费)'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowCreate(false)}>取消</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : !payslips || payslips.length === 0 ? (
|
||||
<Card><div className="text-center py-8 text-gray-400">该月份暂无工资条记录</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>
|
||||
@@ -383,15 +887,15 @@ function PayslipManager() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payslips.map((p: any) => (
|
||||
{payslips.slice((page - 1) * pageSize, page * pageSize).map((p: any) => (
|
||||
<tr key={p.id} className="border-b last:border-0">
|
||||
<td className="py-2">{p.employee?.name}</td>
|
||||
<td className="py-2 text-gray-500">{p.employee?.department}</td>
|
||||
<td className="py-2 text-right">¥{p.baseSalary.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right">¥{p.overtimePay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right">¥{p.allowance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right text-danger">{p.deduction > 0 ? '-¥' + p.deduction.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '¥0'}</td>
|
||||
<td className="py-2 text-right font-bold">¥{p.totalPay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right">¥{fmt(p.baseSalary)}</td>
|
||||
<td className="py-2 text-right">¥{fmt(p.overtimePay)}</td>
|
||||
<td className="py-2 text-right">¥{fmt(p.allowance)}</td>
|
||||
<td className="py-2 text-right text-danger">{p.deduction > 0 ? '-¥' + fmt(p.deduction) : '¥0'}</td>
|
||||
<td className="py-2 text-right font-bold">¥{fmt(p.totalPay)}</td>
|
||||
<td className="py-2 text-center">
|
||||
{p.confirmedAt ? (
|
||||
<span className="inline-flex items-center gap-1 text-safe text-xs">
|
||||
@@ -549,7 +1053,7 @@ function SocialInsuranceCalculator() {
|
||||
{result ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
缴费基数:<span className="text-gray-900 font-medium">¥{result.actualBase.toLocaleString()}</span>
|
||||
缴费基数:<span className="text-gray-900 font-medium">¥{fmt(result.actualBase)}</span>
|
||||
{result.capped && <span className="text-warning ml-2">(已封顶)</span>}
|
||||
{result.floored && <span className="text-warning ml-2">(已保底)</span>}
|
||||
</div>
|
||||
@@ -570,16 +1074,16 @@ function SocialInsuranceCalculator() {
|
||||
<td className="py-1.5">{item.name}</td>
|
||||
<td className="py-1.5 text-right text-gray-500">{item.orgRate}%</td>
|
||||
<td className="py-1.5 text-right text-gray-500">{item.empRate}%</td>
|
||||
<td className="py-1.5 text-right">¥{item.orgAmount.toFixed(2)}</td>
|
||||
<td className="py-1.5 text-right">¥{item.empAmount.toFixed(2)}</td>
|
||||
<td className="py-1.5 text-right">¥{fmt(item.orgAmount)}</td>
|
||||
<td className="py-1.5 text-right">¥{fmt(item.empAmount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 font-bold">
|
||||
<td className="py-2" colSpan={3}>合计</td>
|
||||
<td className="py-2 text-right text-danger">¥{result.totalOrg.toFixed(2)}</td>
|
||||
<td className="py-2 text-right text-warning">¥{result.totalEmp.toFixed(2)}</td>
|
||||
<td className="py-2 text-right text-danger">¥{fmt(result.totalOrg)}</td>
|
||||
<td className="py-2 text-right text-warning">¥{fmt(result.totalEmp)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
@@ -587,10 +1091,10 @@ function SocialInsuranceCalculator() {
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">总费用</span>
|
||||
<span className="text-xl font-bold text-primary">¥{result.total.toFixed(2)}</span>
|
||||
<span className="text-xl font-bold text-primary">¥{fmt(result.total)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
企业承担 ¥{result.totalOrg.toFixed(2)} + 个人承担 ¥{result.totalEmp.toFixed(2)}
|
||||
企业承担 ¥{fmt(result.totalOrg)} + 个人承担 ¥{fmt(result.totalEmp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user