Files
TurboHR/frontend/src/pages/Money.tsx
T
freedakgmail fa7aa01c48 refactor: 社保公积金从薪税tab移至顶层导航,放在薪税和解聘之间
- 新建 SocialInsurance.tsx 独立页面
- App.tsx 添加 /social 路由
- TopNav/MobileTabBar 添加社保公积金导航项
- Money.tsx 移除 social tab 和 SocialInsuranceCalculator 函数
2026-07-23 16:48:10 +08:00

1156 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Calculator, AlertCircle, Info, Save, Check, Upload, Bell, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, History } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
type Tab = 'batch' | 'template' | 'overtime' | 'payslip'
export default function Money() {
const [tab, setTab] = useState<Tab>('batch')
const tabs: { key: Tab; label: string }[] = [
{ key: 'batch', label: '发薪批次' },
{ key: 'template', label: '薪酬模版' },
{ key: 'overtime', label: '加班费计算' },
{ key: 'payslip', label: '工资条管理' },
]
return (
<div className="space-y-3">
<h1 className="text-xs font-medium"></h1>
<div className="flex gap-1 border-b overflow-x-auto">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-3 py-1.5 text-xs 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.label}
</button>
))}
</div>
{tab === 'batch' && <BatchManager />}
{tab === 'template' && <TemplateManager />}
{tab === 'overtime' && <OvertimeCalculator />}
{tab === 'payslip' && <PayslipManager />}
</div>
)
}
// ========== 发薪批次管理 ==========
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 [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],
queryFn: async () => {
const res = await api.get('/payroll2/batches', { params: { month } }) 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)
},
})
const deleteBatchMutation = useMutation({
mutationFn: (batchId: string) => api.delete(`/payroll2/batches/${batchId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
},
})
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 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" />
{batches && batches.length > 0 && (
<span className="text-xs text-gray-500 whitespace-nowrap flex-shrink-0">{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="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>
</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-400 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-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-2">
<Layers className="w-4 h-4 text-primary flex-shrink-0" />
<div className="min-w-0">
<div className="text-xs 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 flex-shrink-0">
{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>
<button
onClick={(e) => {
e.stopPropagation()
if (confirm(`确认删除批次「${batch.name}」?此操作不可撤销。`)) {
deleteBatchMutation.mutate(batch.id)
}
}}
disabled={deleteBatchMutation.isPending}
className="p-1 rounded hover:bg-red-50 text-gray-400 hover:text-danger transition-colors"
title="删除批次"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</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('批次已归档。归档后批次锁定不可编辑。可前往「工资条管理」生成工资条。')
},
})
const importOvertimeMutation = useMutation({
mutationFn: () => api.post(`/payroll/overtime/import-to-batch/${batchId}`),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
if (res.data?.imported > 0) {
alert(`成功导入 ${res.data.imported} 条加班费记录`)
} else {
alert(res.data?.message || '没有可导入的加班记录')
}
},
onError: () => {
alert('导入失败,请重试')
},
})
const deleteBatchMutation = useMutation({
mutationFn: () => api.delete(`/payroll2/batches/${batchId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
onBack()
},
})
if (isLoading) return <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', '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="text-gray-400 hover:text-gray-600 text-xs"> </button>
<h2 className="text-xs font-medium">{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
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={() => {
if (confirm('确认归档?归档后批次将锁定不可编辑。工资条需在「工资条管理」中单独生成。')) {
archiveMutation.mutate()
}
}}
disabled={archiveMutation.isPending}
>
<Archive className="w-4 h-4 mr-1" />
{archiveMutation.isPending ? '归档中...' : '归档'}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => {
if (confirm(`确认删除批次「${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>
<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>
<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>
<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>
<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)} />
)}
{/* 提示 */}
{!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-xs">
<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">()</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"></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-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>
{renderCell(entry, 'socialEmp')}
{renderCell(entry, 'housingEmp')}
<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="text-xs 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-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 { 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-3">
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xs font-medium"></h2>
<span className="text-xs 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-xs">
<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 text-xs 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 [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 { 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 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 {
alert('未匹配到员工,请确保CSV第一列为员工姓名')
}
}
reader.readAsText(file)
}
const confirmImport = () => {
const payload = previewData.map(d => ({
employeeId: d.employeeId,
month: d.month,
weekdayHours: d.weekdayHours,
weekendHours: d.weekendHours,
holidayHours: d.holidayHours,
}))
batchImportMutation.mutate(payload)
}
const cfg = config || { weekdayRate: 1.5, weekendRate: 2.0, holidayRate: 3.0, monthlyDays: 21.75, dailyHours: 8 }
return (
<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-400">...</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-400 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-400 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-400 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-400 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-400 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-400 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-xs">
<thead>
<tr className="border-b 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-400"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b 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>
</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>
<td className="py-2 text-right">{r.weekdayHours || '-'}</td>
<td className="py-2 text-right">{r.weekendHours || '-'}</td>
<td className="py-2 text-right">{r.holidayHours || '-'}</td>
<td className="py-2 text-right font-medium text-gray-700">
{r.totalPay > 0 ? `¥${fmt(r.totalPay)}` : <span className="text-gray-400"></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>
</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-400 flex items-center gap-1">
<Info className="w-3.5 h-3.5" />
</div>
</div>
</Card>
)}
</div>
)
}
function ResultRow({ label, value }: { label: string; value: number }) {
return (
<div className="flex items-center justify-between text-xs">
<span className="text-gray-600">{label}</span>
<span className="font-medium">¥{fmt(value)}</span>
</div>
)
}
function PayslipManager() {
const queryClient = useQueryClient()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const { data: payslips, isLoading } = useQuery<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
alert(`已从归档批次汇总生成 ${n} 条工资条并发布。`)
},
})
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={() => {
if (confirm(`确认从 ${month} 已归档批次汇总生成工资条?这将覆盖已有的工资条数据。`)) {
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-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-xs">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-center"></th>
<th className="py-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">
<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">¥{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">
<Check className="w-3 h-3" />
</span>
) : (
<span className="text-warning text-xs"></span>
)}
</td>
<td className="py-2">
<button
onClick={() => deleteMutation.mutate(p.id)}
className="text-xs text-gray-400 hover:text-danger"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
)
}