Files
TurboHR/frontend/src/pages/Money.tsx
T
freedakgmail 0372cbe243 feat: 完成全部11项优化需求 + 模板必填项标注 + 归档重算个税
- 高优先级: 花名册导入模板必填项标注、性别自动识别、导入结果反馈、证据链Excel导出、身份证搜索修复、试用期区分与转正提醒、薪税批次流程优化
- 中优先级: 专项附加扣除批量导入、文本模板库完善(Word下载/复制/使用说明)、用工体检评分标准说明、考勤页面导入入口
- 所有导入模板表头标注必填项(*后缀)并含示例行
- 导入逻辑统一改用getField兼容*后缀列名
- 批次归档时强制重算所有条目个税和社保,解决多未归档批次并存时累计计算不准问题
- 更新需求梳理文档
2026-07-29 19:08:45 +08:00

1998 lines
90 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, 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 CustomEmployeeSelector({ selectedIds, onChange }: { selectedIds: string[]; onChange: (ids: string[]) => void }) {
const [search, setSearch] = useState('')
const [filterDept, setFilterDept] = useState('')
const { data: employees } = useQuery<any>({
queryKey: ['roster-for-batch', search, filterDept],
queryFn: async () => {
const params: any = { pageSize: 999 }
if (search) params.search = search
if (filterDept) params.department = filterDept
params.status = 'ACTIVE'
const res = await api.get('/roster', { params }) as any
return res.data || []
},
})
const { data: deptList } = useQuery<string[]>({
queryKey: ['roster-departments'],
queryFn: async () => {
const res = await api.get('/roster/departments') as any
return res.data || []
},
})
const toggle = (id: string) => {
if (selectedIds.includes(id)) {
onChange(selectedIds.filter(x => x !== id))
} else {
onChange([...selectedIds, id])
}
}
const toggleAll = () => {
if (employees && employees.every((e: any) => selectedIds.includes(e.id))) {
onChange(selectedIds.filter(id => !employees.some((e: any) => e.id === id)))
} else {
const newIds = new Set([...selectedIds, ...(employees?.map((e: any) => e.id) || [])])
onChange(Array.from(newIds))
}
}
return (
<div className="border rounded-md p-3 space-y-2">
<div className="flex items-center justify-between">
<Label></Label>
<span className="text-xs text-gray-500"> {selectedIds.length} </span>
</div>
<div className="flex gap-2">
<Input placeholder="搜索姓名" value={search} onChange={(e) => setSearch(e.target.value)} className="!w-40" />
<select value={filterDept} onChange={(e) => setFilterDept(e.target.value)} className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm">
<option value=""></option>
{deptList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
{employees && employees.length > 0 && (
<button onClick={toggleAll} className="text-xs text-primary hover:underline whitespace-nowrap">
{employees.every((e: any) => selectedIds.includes(e.id)) ? '取消全选' : '全选当前'}
</button>
)}
</div>
<div className="max-h-48 overflow-y-auto border rounded">
{employees && employees.length > 0 ? (
<table className="w-full text-xs">
<tbody>
{employees.map((e: any) => (
<tr key={e.id} className="border-b last:border-0 hover:bg-gray-50 cursor-pointer" onClick={() => toggle(e.id)}>
<td className="px-2 py-1.5 w-8">
<input type="checkbox" checked={selectedIds.includes(e.id)} onChange={() => toggle(e.id)} />
</td>
<td className="px-2 py-1.5 font-medium">{e.name}</td>
<td className="px-2 py-1.5 text-gray-500">{e.department}</td>
<td className="px-2 py-1.5 text-gray-400 text-right">¥{fmt(e.monthlySalary)}</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="py-4 text-center text-gray-400 text-xs"></div>
)}
</div>
</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' | 'custom'>('copy_last')
const [sourceBatchId, setSourceBatchId] = useState<string>('')
const [selectedEmployeeIds, setSelectedEmployeeIds] = 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: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
})
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 unarchiveBatchMutation = useMutation({
mutationFn: (batchId: string) => api.post(`/payroll2/batches/${batchId}/unarchive`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
toast.success('已取消归档,批次恢复为草稿状态')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '取消归档失败'),
})
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
if (selectedBatchId) {
return <BatchDetail batchId={selectedBatchId} onBack={() => {
queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
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(''); setSelectedEmployeeIds([]) }}>
<option value="copy_last"></option>
<option value="blank_employees">0</option>
<option value="blank_all"></option>
<option value="copy_batch"></option>
<option value="custom"></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>
)}
{createMode === 'custom' && (
<CustomEmployeeSelector selectedIds={selectedEmployeeIds} onChange={setSelectedEmployeeIds} />
)}
<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>}
{createMode === 'custom' && <p></p>}
</div>
<div className="flex gap-2">
<Button
onClick={() => createMutation.mutate({ month, type: createType, mode: createMode, sourceBatchId: sourceBatchId || undefined, employeeIds: createMode === 'custom' ? selectedEmployeeIds : undefined })}
disabled={createMutation.isPending || (createMode === 'copy_batch' && !sourceBatchId) || (createMode === 'custom' && selectedEmployeeIds.length === 0)}
>
{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>
</>
)}
{batch.status === 'ARCHIVED' && (
<button
onClick={async () => {
if (await confirm({ title: '取消归档', message: `确认取消归档「${batch.name}」?取消后批次恢复为草稿状态,对应的工资条和个税累计将被撤销。`, variant: 'primary' })) {
unarchiveBatchMutation.mutate(batch.id)
}
}}
disabled={unarchiveBatchMutation.isPending}
className="p-1 rounded hover:bg-amber-50 text-gray-500 hover:text-warning transition-colors"
aria-label="取消归档"
title="取消归档"
>
<Archive 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'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
},
})
const removeEmployeeMutation = useMutation({
mutationFn: (employeeId: string) => api.delete(`/payroll2/batches/${batchId}/employees/${employeeId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
},
})
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 unarchiveMutation = useMutation({
mutationFn: () => api.post(`/payroll2/batches/${batchId}/unarchive`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
toast.success('已取消归档,批次恢复为草稿状态')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '取消归档失败'),
})
const importOvertimeMutation = useMutation({
mutationFn: () => api.post(`/payroll/overtime/import-to-batch/${batchId}`),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
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'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
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={async () => {
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/import/payroll-template`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '工资表导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch {
toast.error('下载模板失败')
}
}}
>
<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 && (
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={async () => {
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/payroll2/batches/${batchId}/export?format=csv`, { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) throw new Error('导出失败')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `银行代发文件-${batch.month}-批次${batch.batchNo}.csv`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出失败') }
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={async () => {
try {
const res = await api.get(`/payroll/batch/${batchId}/summary`) as any
const { departments, grandTotal } = res.data
const headers = ['部门', '人数', '应发合计', '实发合计', '个人社保', '单位社保', '个人公积金', '单位公积金', '个税合计']
const rows = departments.map((d: any) => [
d.department, d.headcount, d.totalPay.toFixed(2), d.totalNetPay.toFixed(2),
d.totalSocialEmp.toFixed(2), d.totalSocialOrg.toFixed(2),
d.totalHousingEmp.toFixed(2), d.totalHousingOrg.toFixed(2), d.totalTax.toFixed(2),
])
rows.push(['合计', grandTotal.headcount, grandTotal.totalPay.toFixed(2), grandTotal.totalNetPay.toFixed(2),
grandTotal.totalSocialEmp.toFixed(2), grandTotal.totalSocialOrg.toFixed(2),
grandTotal.totalHousingEmp.toFixed(2), grandTotal.totalHousingOrg.toFixed(2), grandTotal.totalTax.toFixed(2)])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `salary-summary-${batch.month}.csv`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出汇总表失败') }
}}
>
<FileText className="w-4 h-4 mr-1" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={async () => {
try {
const res = await api.get(`/payroll/batch/${batchId}/detail`) as any
const { details } = res.data
const headers = ['姓名', '部门', '基本工资', '岗位工资', '绩效工资', '工龄工资', '加班费', '交通补贴', '餐补', '住房补贴', '通讯补贴', '其他津贴', '奖金', '扣款', '其他扣款', '个人社保', '个人公积金', '个税', '应发合计', '实发工资']
const rows = details.map((d: any) => [
d.name, d.department,
d.baseSalary.toFixed(2), d.positionSalary.toFixed(2), d.performanceSalary.toFixed(2),
d.senioritySalary.toFixed(2), d.overtimePay.toFixed(2),
d.transportAllowance.toFixed(2), d.mealAllowance.toFixed(2), d.housingAllowance.toFixed(2),
d.communicationAllowance.toFixed(2), d.allowance.toFixed(2), d.bonus.toFixed(2),
d.deduction.toFixed(2), d.otherDeduction.toFixed(2),
d.socialEmp.toFixed(2), d.housingEmp.toFixed(2), d.tax.toFixed(2),
d.totalPay.toFixed(2), d.netPay.toFixed(2),
])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `salary-detail-${batch.month}.csv`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出明细表失败') }
}}
>
<FileText className="w-4 h-4 mr-1" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={async () => {
if (await confirm({ title: '取消归档', message: '确认取消归档?取消后批次恢复为草稿状态,对应的工资条和个税累计将被撤销。', variant: 'primary' })) {
unarchiveMutation.mutate()
}
}}
disabled={unarchiveMutation.isPending}
>
{unarchiveMutation.isPending ? '取消中...' : '取消归档'}
</Button>
</div>
)}
</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'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
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>
)
}