63a0a6934b
minWageApplied 包含免扣社保+免扣公积金+额外补齐三部分, 原tooltip统称"补齐"容易误解为额外补了这么多现金。 改为分项显示:免扣社保 ¥X + 免扣公积金 ¥Y + 额外补齐 ¥Z Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1354 lines
70 KiB
TypeScript
1354 lines
70 KiB
TypeScript
import { useState, useRef } from 'react'
|
||
import { usePageSize } from '../../hooks/usePageSize'
|
||
import { toast } from 'sonner'
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import { useConfirm } from '../../hooks/useConfirm'
|
||
import { Calculator, AlertCircle, Info, Upload, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, X, ChevronLeft, Clock, Users, TrendingDown, TrendingUp, BadgeCheck, DollarSign } from 'lucide-react'
|
||
import { Stepper, type Step } from '../../components/ui/Stepper'
|
||
import { InlineAlert } from '../../components/ui/InlineAlert'
|
||
import PageGuide from '../../components/ui/PageGuide'
|
||
import { payrollApi, rosterApi, employeeApi } from '../../lib/api-services'
|
||
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 EmptyState from '../../components/ui/EmptyState'
|
||
import Pagination from '../../components/ui/Pagination'
|
||
// 金额格式化:保留两位小数 + 千分位
|
||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||
|
||
// ========== 发薪批次管理 ==========
|
||
|
||
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 res = await rosterApi.list({ pageSize: 999, search, department: filterDept, status: 'ACTIVE' })
|
||
return res.data || []
|
||
},
|
||
})
|
||
|
||
const { data: deptList } = useQuery<string[]>({
|
||
queryKey: ['roster-departments'],
|
||
queryFn: () => rosterApi.departments(),
|
||
})
|
||
|
||
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>
|
||
)
|
||
}
|
||
|
||
export 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 [dateFrom, setDateFrom] = useState('')
|
||
const [dateTo, setDateTo] = 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 pageSize = usePageSize()
|
||
const [page, setPage] = useState(1)
|
||
|
||
const { data: checkResult } = useQuery<any>({
|
||
queryKey: ['batch-check', month],
|
||
queryFn: async () => {
|
||
return await payrollApi.batchCheck(month)
|
||
},
|
||
})
|
||
|
||
const { data: batches, isLoading } = useQuery<any[]>({
|
||
queryKey: ['batches', month, monthFrom, monthTo, dateFrom, dateTo, filterStatus, filterType],
|
||
queryFn: async () => {
|
||
const params: any = {}
|
||
if (month && !monthFrom && !monthTo && !dateFrom && !dateTo) params.month = month
|
||
if (monthFrom) params.monthFrom = monthFrom
|
||
if (monthTo) params.monthTo = monthTo
|
||
if (dateFrom) params.dateFrom = dateFrom
|
||
if (dateTo) params.dateTo = dateTo
|
||
if (filterStatus) params.status = filterStatus
|
||
if (filterType) params.type = filterType
|
||
return await payrollApi.batches(params)
|
||
},
|
||
})
|
||
|
||
const { data: archivedBatches } = useQuery<any[]>({
|
||
queryKey: ['archived-batches'],
|
||
queryFn: async () => {
|
||
return await payrollApi.archivedBatches()
|
||
},
|
||
enabled: createMode === 'copy_batch',
|
||
})
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: (data: any) => payrollApi.createBatch(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) => payrollApi.removeBatch(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 }) =>
|
||
payrollApi.renameBatch(batchId, name),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||
toast.success('批次名称已更新')
|
||
},
|
||
onError: () => toast.error('重命名失败'),
|
||
})
|
||
|
||
const unarchiveBatchMutation = useMutation({
|
||
mutationFn: (batchId: string) => payrollApi.unarchiveBatch(batchId),
|
||
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">
|
||
<PageGuide>
|
||
发薪批次用于按月生成工资数据。流程:①选择月份和发薪类型(常规/解聘/奖金/补偿)→ ②选择员工范围或复制上月 → ③系统自动计算社保、公积金、个税 → ④确认并发布工资条。支持批量编辑、导出Excel。
|
||
</PageGuide>
|
||
{/* 重复发薪提醒 */}
|
||
{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 && !dateFrom && !dateTo && (
|
||
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-36 shrink-0" placeholder="单月" />
|
||
)}
|
||
<div className="flex items-center gap-1 shrink-0">
|
||
<span className="text-xs text-gray-500">创建日期</span>
|
||
<Input type="date" value={dateFrom} onChange={(e) => { setDateFrom(e.target.value); setMonth('') }} className="!w-36" placeholder="起始" />
|
||
<span className="text-xs text-gray-500">~</span>
|
||
<Input type="date" value={dateTo} onChange={(e) => { setDateTo(e.target.value); setMonth('') }} className="!w-36" placeholder="截止" />
|
||
</div>
|
||
<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 || dateFrom || dateTo || filterStatus || filterType) && (
|
||
<button onClick={() => { setMonthFrom(''); setMonthTo(''); setDateFrom(''); setDateTo(''); 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 className="text-primary font-medium">按部门筛选、搜索、勾选指定员工创建批次。适合为部分人员单独发薪或离职结算</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">
|
||
<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 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">状态</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-amber-600">¥{fmt((batch.totalSocialOrg || 0) + (batch.totalSocialEmp || 0))}</td>
|
||
<td className="py-2.5 px-3 text-right text-sm text-cyan-600">¥{fmt((batch.totalHousingOrg || 0) + (batch.totalHousingEmp || 0))}</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 text-xs text-gray-500">{batch.createdAt ? new Date(batch.createdAt).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-'}</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>
|
||
<Pagination page={page} pageSize={pageSize} total={batches.length} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
|
||
</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 pageSize = usePageSize()
|
||
const [page, setPage] = useState(1)
|
||
const [showAddEmployee, setShowAddEmployee] = useState(false)
|
||
const payrollFileRef = useRef<HTMLInputElement>(null)
|
||
const [payrollImportResult, setPayrollImportResult] = useState<any>(null)
|
||
const [taxDetailFor, setTaxDetailFor] = useState<{ employeeId: string; name: string } | null>(null)
|
||
const [taxDetailData, setTaxDetailData] = useState<any>(null)
|
||
const [taxDetailLoading, setTaxDetailLoading] = useState(false)
|
||
|
||
const { data: batch, isLoading } = useQuery<any>({
|
||
queryKey: ['batch-detail', batchId],
|
||
queryFn: async () => {
|
||
return await payrollApi.batchDetail(batchId)
|
||
},
|
||
})
|
||
|
||
const updateEntryMutation = useMutation({
|
||
mutationFn: ({ employeeId, data }: { employeeId: string; data: any }) =>
|
||
payrollApi.updateBatchEntry(batchId, employeeId, data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||
},
|
||
onError: (err: any) => {
|
||
toast.error(err?.response?.data?.error?.message || '保存失败,请重试')
|
||
},
|
||
})
|
||
|
||
const removeEmployeeMutation = useMutation({
|
||
mutationFn: (employeeId: string) => payrollApi.removeBatchEmployee(batchId, employeeId),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||
},
|
||
})
|
||
|
||
const archiveMutation = useMutation({
|
||
mutationFn: () => payrollApi.archiveBatch(batchId),
|
||
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: () => payrollApi.unarchiveBatch(batchId),
|
||
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 publishPayslipMutation = useMutation({
|
||
mutationFn: () => payrollApi.publishPayslip(batchId),
|
||
onSuccess: (res: any) => {
|
||
toast.success(`已发布 ${res.data?.published || 0} 条工资条`)
|
||
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||
},
|
||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '发布失败'),
|
||
})
|
||
|
||
const [showScheduleModal, setShowScheduleModal] = useState(false)
|
||
const [scheduleDate, setScheduleDate] = useState('')
|
||
const schedulePayslipMutation = useMutation({
|
||
mutationFn: () => payrollApi.schedulePayslip(batchId, scheduleDate),
|
||
onSuccess: (res: any) => {
|
||
toast.success(`已设定定时发送 ${res.data?.scheduled || 0} 条工资条`)
|
||
setShowScheduleModal(false)
|
||
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||
},
|
||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '设定失败'),
|
||
})
|
||
|
||
const importOvertimeMutation = useMutation({
|
||
mutationFn: () => payrollApi.importOvertimeToBatch(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 fetchBonusMutation = useMutation({
|
||
mutationFn: () => payrollApi.fetchBonusToBatch(batchId),
|
||
onSuccess: (res: any) => {
|
||
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||
if (res.data?.filled > 0) {
|
||
toast.success(res.data.message)
|
||
} 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: () => payrollApi.removeBatch(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 handleTaxDetailClick = async (employeeId: string, name: string) => {
|
||
setTaxDetailFor({ employeeId, name })
|
||
setTaxDetailData(null)
|
||
setTaxDetailLoading(true)
|
||
try {
|
||
const data = await payrollApi.taxDetail(batchId, employeeId)
|
||
setTaxDetailData(data)
|
||
} catch {
|
||
toast.error('获取个税明细失败')
|
||
} finally {
|
||
setTaxDetailLoading(false)
|
||
}
|
||
}
|
||
|
||
// 可编辑的输入项字段
|
||
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
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => fetchBonusMutation.mutate()}
|
||
disabled={fetchBonusMutation.isPending || isArchived}
|
||
title="从提成奖金模块按月拉取填充奖金字段"
|
||
>
|
||
<DollarSign className="w-4 h-4 mr-1" />
|
||
{fetchBonusMutation.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 payrollApi.batchSummary(batchId) as any
|
||
const { departments, grandTotal } = res
|
||
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 payrollApi.batchDetailExport(batchId) as any
|
||
const { details } = res
|
||
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>
|
||
<Button
|
||
size="sm"
|
||
onClick={async () => {
|
||
if (await confirm({ title: '发布工资条', message: `确认发布 ${batch.month} 月工资条?发布后员工可在员工端查看。`, variant: 'primary' })) {
|
||
publishPayslipMutation.mutate()
|
||
}
|
||
}}
|
||
disabled={publishPayslipMutation.isPending}
|
||
>
|
||
{publishPayslipMutation.isPending ? '发布中...' : '发布工资条'}
|
||
</Button>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => setShowScheduleModal(true)}
|
||
>
|
||
<Clock className="w-4 h-4 mr-1" />定时发送
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 发薪工作流步骤条 */}
|
||
<Card className="p-4">
|
||
<Stepper
|
||
steps={[
|
||
{ key: 'edit', title: '编辑薪资', description: '填写/导入/核对工资数据', status: isArchived ? 'complete' : 'current' },
|
||
{ key: 'archive', title: '归档锁定', description: '归档后不可编辑', status: isArchived ? 'complete' : 'pending' },
|
||
{ key: 'publish', title: '发布工资条', description: '员工端可见', status: batch.payslipPublished ? 'complete' : 'pending' },
|
||
] as Step[]}
|
||
/>
|
||
</Card>
|
||
|
||
{/* 质量门禁 — 草稿状态下检查异常 */}
|
||
{!isArchived && batch.entries.length > 0 && (
|
||
<>
|
||
{batch.entries.some((e: any) => e.totalPay > 0 && e.netPay <= 0) && (
|
||
<InlineAlert type="error" title="存在实发为负的员工">
|
||
请检查社保/公积金基数是否过大,导致实发工资 ≤ 0 的记录需修正后再归档。
|
||
</InlineAlert>
|
||
)}
|
||
{batch.entries.some((e: any) => e.minWage > 0 && e.netPay < e.minWage && (e.minWageApplied || 0) === 0) && (
|
||
<InlineAlert type="error" title="存在实发低于最低工资的员工">
|
||
部分员工实发工资低于当地最低工资标准 ¥{Math.min(...batch.entries.filter((e: any) => e.minWage > 0 && e.netPay < e.minWage).map((e: any) => e.minWage))},请检查社保基数或在社保配置中设置最低工资标准。
|
||
</InlineAlert>
|
||
)}
|
||
{batch.entries.some((e: any) => (e.minWageApplied || 0) > 0) && (
|
||
<InlineAlert type="warning" title="最低工资保护已触发">
|
||
{batch.entries.filter((e: any) => (e.minWageApplied || 0) > 0).length} 名员工实发已补齐到最低工资标准。免扣的社保/公积金及额外补齐差额已递延,将在次月工资中补扣,请在「递延扣款」列查看明细。
|
||
</InlineAlert>
|
||
)}
|
||
{batch.entries.some((e: any) => (e.prevDeferredSocialEmp || 0) + (e.prevDeferredHousingEmp || 0) + (e.prevDeferredMinWage || 0) > 0) && (
|
||
<InlineAlert type="warning" title="本月补扣上月递延">
|
||
{batch.entries.filter((e: any) => (e.prevDeferredSocialEmp || 0) + (e.prevDeferredHousingEmp || 0) + (e.prevDeferredMinWage || 0) > 0).length} 名员工本月补扣了上月递延的社保/公积金/最低工资补齐差额。
|
||
</InlineAlert>
|
||
)}
|
||
{batch.entries.some((e: any) => e.baseSalary === 0 && e.bonus === 0 && e.totalPay === 0) && (
|
||
<InlineAlert type="warning" title="存在全零记录">
|
||
部分员工所有金额为 0,请确认是否需要填写或移除这些人员。
|
||
</InlineAlert>
|
||
)}
|
||
{batch.entries.some((e: any) => e.riskWarnings && e.riskWarnings.length > 0) && (
|
||
<InlineAlert type="warning" title="存在风险预警">
|
||
部分员工有薪资风险提示(如社保基数偏低/偏高),请在「风险」列查看详情。
|
||
</InlineAlert>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* 批次汇总 */}
|
||
<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>
|
||
<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 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 cursor-pointer hover:text-primary hover:underline" onClick={() => handleTaxDetailClick(entry.employeeId, entry.employee.name)} title="点击查看个税计算明细">{fmt(entry.tax)}</td>
|
||
{/* 实发:最低工资保护触发时高亮显示 */}
|
||
<td className={`py-2 px-2 text-right font-bold ${(entry.minWageApplied || 0) > 0 ? 'text-amber-600' : 'text-safe'}`} title={(entry.minWageApplied || 0) > 0 ? `最低工资保护已触发\n应发 ¥${fmt(entry.totalPay)} → 实发补齐到 ¥${fmt(entry.minWage)}\n免扣社保 ¥${fmt(entry.deferredSocialEmp || 0)} + 免扣公积金 ¥${fmt(entry.deferredHousingEmp || 0)} + 额外补齐 ¥${fmt(entry.deferredMinWage || 0)} = 递延 ¥${fmt((entry.deferredSocialEmp || 0) + (entry.deferredHousingEmp || 0) + (entry.deferredMinWage || 0))}(次月补扣)` : ''}>
|
||
{fmt(entry.netPay)}
|
||
{(entry.minWageApplied || 0) > 0 && <span className="text-xs text-amber-500 ml-1">★</span>}
|
||
</td>
|
||
{/* 递延扣款明细 */}
|
||
<td className="py-2 px-2 text-right text-xs">
|
||
{(() => {
|
||
const deferred = (entry.deferredSocialEmp || 0) + (entry.deferredHousingEmp || 0) + (entry.deferredMinWage || 0)
|
||
const prevDeferred = (entry.prevDeferredSocialEmp || 0) + (entry.prevDeferredHousingEmp || 0) + (entry.prevDeferredMinWage || 0)
|
||
if (deferred > 0) {
|
||
return <span className="text-amber-600 cursor-help" title={`递延到次月补扣:\n免扣社保 ¥${fmt(entry.deferredSocialEmp || 0)}\n免扣公积金 ¥${fmt(entry.deferredHousingEmp || 0)}\n额外补齐 ¥${fmt(entry.deferredMinWage || 0)}\n合计 ¥${fmt(deferred)}`}>递延 ¥{fmt(deferred)}</span>
|
||
}
|
||
if (prevDeferred > 0) {
|
||
return <span className="text-blue-600 cursor-help" title={`本月补扣上月递延:\n社保 ¥${fmt(entry.prevDeferredSocialEmp || 0)}\n公积金 ¥${fmt(entry.prevDeferredHousingEmp || 0)}\n最低工资补齐 ¥${fmt(entry.prevDeferredMinWage || 0)}\n合计 ¥${fmt(prevDeferred)}`}>补扣 ¥{fmt(prevDeferred)}</span>
|
||
}
|
||
return <span className="text-gray-300">—</span>
|
||
})()}
|
||
</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>
|
||
) : (entry.minWageApplied || 0) > 0 ? (
|
||
<span className="text-amber-500 cursor-help" title={`最低工资保护已触发\n应发 ¥${fmt(entry.totalPay)} → 实发 ¥${fmt(entry.minWage)}\n递延 ¥${fmt((entry.deferredSocialEmp || 0) + (entry.deferredHousingEmp || 0) + (entry.deferredMinWage || 0))} 次月补扣`}>
|
||
<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>
|
||
{batch.entries.length > 0 && (
|
||
<Pagination page={page} pageSize={pageSize} total={batch.entries.length} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
|
||
)}
|
||
</Card>
|
||
|
||
{/* 定时发送弹窗 */}
|
||
{showScheduleModal && (
|
||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setShowScheduleModal(false)}>
|
||
<Card className="max-w-md w-full" >
|
||
<div onClick={(e) => e.stopPropagation()}>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h3 className="text-sm font-medium">定时发送工资条</h3>
|
||
<button onClick={() => setShowScheduleModal(false)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
|
||
</div>
|
||
<p className="text-xs text-gray-500 mb-3">设定发送时间后,工资条将在指定时间自动发布到员工端</p>
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label>发送时间</Label>
|
||
<Input
|
||
type="datetime-local"
|
||
value={scheduleDate}
|
||
onChange={(e) => setScheduleDate(e.target.value)}
|
||
/>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => {
|
||
if (!scheduleDate) { toast.error('请选择发送时间'); return }
|
||
schedulePayslipMutation.mutate()
|
||
}}
|
||
disabled={schedulePayslipMutation.isPending}
|
||
>
|
||
{schedulePayslipMutation.isPending ? '设定中...' : '确认定时发送'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
|
||
{/* 个税计算明细弹窗 */}
|
||
{taxDetailFor && (
|
||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setTaxDetailFor(null)}>
|
||
<Card className="max-w-lg w-full max-h-[85vh] overflow-y-auto" >
|
||
<div onClick={(e) => e.stopPropagation()}>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h3 className="text-sm font-medium">个税计算明细 — {taxDetailFor.name}</h3>
|
||
<button onClick={() => setTaxDetailFor(null)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
|
||
</div>
|
||
{taxDetailLoading ? (
|
||
<div className="text-center py-6 text-gray-500">计算中...</div>
|
||
) : taxDetailData ? (
|
||
<div className="space-y-2 text-xs">
|
||
<div className="bg-gray-50 rounded-md p-2 text-gray-600">计税方式:{taxDetailData.method}</div>
|
||
{taxDetailData.method === '累计预扣法' ? (
|
||
<table className="w-full text-xs">
|
||
<tbody>
|
||
<tr className="border-b"><td className="py-1.5 text-gray-500">当年累计收入</td><td className="py-1.5 text-right font-medium">¥{fmt(taxDetailData.ytdIncome)}</td></tr>
|
||
<tr className="border-b"><td className="py-1.5 text-gray-500">累计减除费用(5000×{taxDetailData.month}月)</td><td className="py-1.5 text-right">-¥{fmt(taxDetailData.deductionAmount)}</td></tr>
|
||
<tr className="border-b"><td className="py-1.5 text-gray-500">累计个人社保</td><td className="py-1.5 text-right">-¥{fmt(taxDetailData.ytdSocialEmp)}</td></tr>
|
||
<tr className="border-b"><td className="py-1.5 text-gray-500">累计个人公积金</td><td className="py-1.5 text-right">-¥{fmt(taxDetailData.ytdHousingEmp)}</td></tr>
|
||
<tr className="border-b"><td className="py-1.5 text-gray-500">累计专项附加扣除</td><td className="py-1.5 text-right">-¥{fmt(taxDetailData.ytdSpecialDeduction)}</td></tr>
|
||
<tr className="border-b bg-blue-50"><td className="py-1.5 font-medium">累计应纳税所得额</td><td className="py-1.5 text-right font-medium text-primary">¥{fmt(taxDetailData.ytdTaxableIncome)}</td></tr>
|
||
<tr className="border-b"><td className="py-1.5 text-gray-500">累计已预扣税额</td><td className="py-1.5 text-right">¥{fmt(taxDetailData.ytdTaxDeducted)}</td></tr>
|
||
<tr className="bg-amber-50"><td className="py-1.5 font-medium text-danger">当月应预扣税额</td><td className="py-1.5 text-right font-bold text-danger">¥{fmt(taxDetailData.currentMonthTax)}</td></tr>
|
||
</tbody>
|
||
</table>
|
||
) : (
|
||
<table className="w-full text-xs">
|
||
<tbody>
|
||
<tr className="border-b"><td className="py-1.5 text-gray-500">年终奖金额</td><td className="py-1.5 text-right font-medium">¥{fmt(taxDetailData.bonus)}</td></tr>
|
||
<tr className="bg-amber-50"><td className="py-1.5 font-medium text-danger">应纳税额</td><td className="py-1.5 text-right font-bold text-danger">¥{fmt(taxDetailData.tax)}</td></tr>
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
{taxDetailData.archivedCount === 0 && taxDetailData.method === '累计预扣法' && (
|
||
<div className="text-xs text-gray-400 bg-gray-50 rounded p-2">
|
||
注:本年度暂无已归档批次,个税按当月收入全额计算。归档历史批次后累计预扣将自动生效。
|
||
</div>
|
||
)}
|
||
{/* 社保公积金:系统计算值 vs 实际值对比 */}
|
||
{(taxDetailData.systemSocialEmp !== undefined || taxDetailData.systemHousingEmp !== undefined) && (
|
||
<div className="border-t pt-2 mt-2">
|
||
<div className="font-medium text-gray-700 mb-1">社保公积金对比</div>
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b text-gray-500">
|
||
<th className="py-1 text-left">项目</th>
|
||
<th className="py-1 text-right">系统计算值</th>
|
||
<th className="py-1 text-right">实际缴纳值</th>
|
||
<th className="py-1 text-right">差异</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{taxDetailData.systemSocialEmp !== undefined && (
|
||
<tr className="border-b">
|
||
<td className="py-1 text-gray-500">个人社保</td>
|
||
<td className="py-1 text-right text-gray-500">¥{fmt(taxDetailData.systemSocialEmp)}</td>
|
||
<td className="py-1 text-right font-medium">¥{fmt(taxDetailData.actualSocialEmp)}</td>
|
||
<td className={`py-1 text-right ${Math.abs((taxDetailData.actualSocialEmp || 0) - (taxDetailData.systemSocialEmp || 0)) > 0.01 ? 'text-amber-600 font-medium' : 'text-gray-300'}`}>{Math.abs((taxDetailData.actualSocialEmp || 0) - (taxDetailData.systemSocialEmp || 0)) > 0.01 ? (taxDetailData.actualSocialEmp > taxDetailData.systemSocialEmp ? '+' : '') + '¥' + fmt((taxDetailData.actualSocialEmp || 0) - (taxDetailData.systemSocialEmp || 0)) : '—'}</td>
|
||
</tr>
|
||
)}
|
||
{taxDetailData.systemHousingEmp !== undefined && (
|
||
<tr className="border-b">
|
||
<td className="py-1 text-gray-500">个人公积金</td>
|
||
<td className="py-1 text-right text-gray-500">¥{fmt(taxDetailData.systemHousingEmp)}</td>
|
||
<td className="py-1 text-right font-medium">¥{fmt(taxDetailData.actualHousingEmp)}</td>
|
||
<td className={`py-1 text-right ${Math.abs((taxDetailData.actualHousingEmp || 0) - (taxDetailData.systemHousingEmp || 0)) > 0.01 ? 'text-amber-600 font-medium' : 'text-gray-300'}`}>{Math.abs((taxDetailData.actualHousingEmp || 0) - (taxDetailData.systemHousingEmp || 0)) > 0.01 ? (taxDetailData.actualHousingEmp > taxDetailData.systemHousingEmp ? '+' : '') + '¥' + fmt((taxDetailData.actualHousingEmp || 0) - (taxDetailData.systemHousingEmp || 0)) : '—'}</td>
|
||
</tr>
|
||
)}
|
||
{taxDetailData.systemSocialOrg !== undefined && (
|
||
<tr className="border-b">
|
||
<td className="py-1 text-gray-500">单位社保</td>
|
||
<td className="py-1 text-right text-gray-500">¥{fmt(taxDetailData.systemSocialOrg)}</td>
|
||
<td className="py-1 text-right font-medium">¥{fmt(taxDetailData.actualSocialOrg)}</td>
|
||
<td className={`py-1 text-right ${Math.abs((taxDetailData.actualSocialOrg || 0) - (taxDetailData.systemSocialOrg || 0)) > 0.01 ? 'text-amber-600 font-medium' : 'text-gray-300'}`}>{Math.abs((taxDetailData.actualSocialOrg || 0) - (taxDetailData.systemSocialOrg || 0)) > 0.01 ? (taxDetailData.actualSocialOrg > taxDetailData.systemSocialOrg ? '+' : '') + '¥' + fmt((taxDetailData.actualSocialOrg || 0) - (taxDetailData.systemSocialOrg || 0)) : '—'}</td>
|
||
</tr>
|
||
)}
|
||
{taxDetailData.systemHousingOrg !== undefined && (
|
||
<tr>
|
||
<td className="py-1 text-gray-500">单位公积金</td>
|
||
<td className="py-1 text-right text-gray-500">¥{fmt(taxDetailData.systemHousingOrg)}</td>
|
||
<td className="py-1 text-right font-medium">¥{fmt(taxDetailData.actualHousingOrg)}</td>
|
||
<td className={`py-1 text-right ${Math.abs((taxDetailData.actualHousingOrg || 0) - (taxDetailData.systemHousingOrg || 0)) > 0.01 ? 'text-amber-600 font-medium' : 'text-gray-300'}`}>{Math.abs((taxDetailData.actualHousingOrg || 0) - (taxDetailData.systemHousingOrg || 0)) > 0.01 ? (taxDetailData.actualHousingOrg > taxDetailData.systemHousingOrg ? '+' : '') + '¥' + fmt((taxDetailData.actualHousingOrg || 0) - (taxDetailData.systemHousingOrg || 0)) : '—'}</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
<div className="text-xs text-gray-400 mt-1">系统计算值按参保地政策比例自动计算,实际缴纳值可手动调整以匹配社保局核定金额</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="text-center py-6 text-gray-500">无数据</div>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
</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 () => {
|
||
return await employeeApi.paged({ pageSize: 100 })
|
||
},
|
||
})
|
||
|
||
const addMutation = useMutation({
|
||
mutationFn: (employeeIds: string[]) => payrollApi.addBatchEmployees(batchId, 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>
|
||
)
|
||
}
|