Files
TurboHR/frontend/src/pages/CommissionBonus.tsx
T
freedakgmail 4d78eac5c2 fix: 提成奖金、证据链条、风险中心姓名显示身份证号
后端:
- commission-bonus.service.ts: listByMonth 查询加上 idCardNumber 并解密
- evidence.service.ts: getEvidenceList 加上 idCardNumber 并解密返回 employeeIdCardNumber
- roster.routes.ts: 证据链接口返回 employee.idCardNumber,Excel 导出加上身份证号

前端:
- CommissionBonus.tsx: 列表姓名下显示身份证号,下拉选项和编辑弹窗也加上
- EvidenceChain.tsx: 两处姓名显示加上身份证号,文本导出加上身份证号
- Evidence.tsx: 证据链列表页姓名后显示身份证号
2026-08-18 08:03:09 +08:00

310 lines
13 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Edit2, Trash2, Upload, Download, Search } from 'lucide-react'
import { toast } from 'sonner'
import { commissionBonusApi, employeeApi } from '../lib/api-services'
import { Input, Label, Select } from '../components/ui/Input'
import Button from '../components/ui/Button'
import Modal from '../components/ui/Modal'
import PageGuide from '../components/ui/PageGuide'
import Pagination from '../components/ui/Pagination'
import { usePageSize } from '../hooks/usePageSize'
export default function CommissionBonus() {
const queryClient = useQueryClient()
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [search, setSearch] = useState('')
const [showAdd, setShowAdd] = useState(false)
const [editRecord, setEditRecord] = useState<any>(null)
const fileRef = useRef<HTMLInputElement>(null)
// 列表 + 汇总
const { data, isLoading } = useQuery({
queryKey: ['commission-bonus', month],
queryFn: () => commissionBonusApi.list(month),
})
// 在职员工列表(新增用)
const { data: employees } = useQuery({
queryKey: ['employees-active'],
queryFn: () => employeeApi.list({ status: 'ACTIVE' }),
enabled: showAdd,
})
const records = data?.records || []
const summary = data?.summary || { count: 0, totalBonus: 0, totalDeduction: 0, netAmount: 0 }
// 搜索过滤
const filtered = records.filter((r: any) =>
!search || r.employee?.name?.includes(search) || r.employee?.department?.includes(search)
)
// 分页
const paged = filtered.slice((page - 1) * pageSize, page * pageSize)
// 新增
const addMutation = useMutation({
mutationFn: (data: { employeeId: string; month: string; amount: number; remark?: string }) =>
commissionBonusApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['commission-bonus'] })
setShowAdd(false)
toast.success('已添加')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '添加失败'),
})
// 更新
const updateMutation = useMutation({
mutationFn: (data: { id: string; amount?: number; remark?: string }) =>
commissionBonusApi.update(data.id, { amount: data.amount, remark: data.remark }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['commission-bonus'] })
setEditRecord(null)
toast.success('已更新')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '更新失败'),
})
// 删除
const deleteMutation = useMutation({
mutationFn: (id: string) => commissionBonusApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['commission-bonus'] })
toast.success('已删除')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
})
// 导入
const importMutation = useMutation({
mutationFn: (file: File) => commissionBonusApi.import(file, month),
onSuccess: (data: any) => {
queryClient.invalidateQueries({ queryKey: ['commission-bonus'] })
toast.success(`导入完成:新增 ${data.created},更新 ${data.updated},跳过 ${data.skipped}`)
if (data.errors?.length > 0) {
toast.error(`错误明细:${data.errors.slice(0, 3).map((e: any) => `${e.row}行: ${e.message}`).join('')}`)
}
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '导入失败'),
})
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) importMutation.mutate(file)
if (fileRef.current) fileRef.current.value = ''
}
const fmt = (n: number) => `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
return (
<div className="space-y-4">
<PageGuide>
<p>/"获取提成奖金"</p>
</PageGuide>
{/* 筛选栏 */}
<div className="flex items-center gap-3 flex-wrap">
<div>
<Label></Label>
<Input type="month" value={month} onChange={(e) => { setMonth(e.target.value); setPage(1) }} className="w-40" />
</div>
<div className="flex-1 min-w-[200px]">
<Label></Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input value={search} onChange={(e) => { setSearch(e.target.value); setPage(1) }} placeholder="员工姓名/部门" className="pl-9" />
</div>
</div>
<div className="flex items-end gap-2">
<Button variant="secondary" onClick={() => window.open(commissionBonusApi.templateUrl, '_blank')}>
<Download className="w-4 h-4 mr-1" />
</Button>
<Button variant="secondary" onClick={() => fileRef.current?.click()} disabled={importMutation.isPending}>
<Upload className="w-4 h-4 mr-1" />{importMutation.isPending ? '导入中...' : '批量导入'}
</Button>
<input ref={fileRef} type="file" accept=".xlsx,.xls" onChange={handleFileChange} className="hidden" />
<Button onClick={() => setShowAdd(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* 汇总卡片 */}
<div className="grid grid-cols-4 gap-4">
<div className="rounded-lg border border-gray-200 p-4">
<div className="text-xs text-gray-500"></div>
<div className="text-2xl font-bold text-gray-900 mt-1">{summary.count}</div>
</div>
<div className="rounded-lg border border-gray-200 p-4">
<div className="text-xs text-gray-500"></div>
<div className="text-2xl font-bold text-safe mt-1">{fmt(summary.totalBonus)}</div>
</div>
<div className="rounded-lg border border-gray-200 p-4">
<div className="text-xs text-gray-500"></div>
<div className="text-2xl font-bold text-danger mt-1">{fmt(summary.totalDeduction)}</div>
</div>
<div className="rounded-lg border border-gray-200 p-4">
<div className="text-xs text-gray-500"></div>
<div className={`text-2xl font-bold mt-1 ${summary.netAmount >= 0 ? 'text-gray-900' : 'text-danger'}`}>{fmt(summary.netAmount)}</div>
</div>
</div>
{/* 列表 */}
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500"></th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500"></th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{isLoading ? (
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400">...</td></tr>
) : paged.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400">{month} </td></tr>
) : paged.map((r: any) => (
<tr key={r.id} className="hover:bg-gray-50">
<td className="px-4 py-2.5 text-sm text-gray-900">
{r.employee?.name || '-'}
{r.employee?.idCardNumber && <div className="text-xs text-gray-400 font-mono">{r.employee.idCardNumber}</div>}
</td>
<td className="px-4 py-2.5 text-sm text-gray-500">{r.employee?.department || '-'}</td>
<td className={`px-4 py-2.5 text-sm text-right font-medium ${r.amount >= 0 ? 'text-safe' : 'text-danger'}`}>
{r.amount >= 0 ? '+' : ''}{fmt(r.amount)}
</td>
<td className="px-4 py-2.5 text-sm text-gray-500">{r.remark || '-'}</td>
<td className="px-4 py-2.5 text-center">
<button onClick={() => setEditRecord(r)} className="p-1 text-gray-400 hover:text-primary" title="编辑">
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => { if (confirm(`确认删除 ${r.employee?.name} 的提成奖金记录?`)) deleteMutation.mutate(r.id) }}
className="p-1 text-gray-400 hover:text-danger ml-1" title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filtered.length > pageSize && (
<Pagination page={page} pageSize={pageSize} total={filtered.length} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
)}
{/* 新增弹窗 */}
{showAdd && (
<AddModal
employees={employees || []}
month={month}
onClose={() => setShowAdd(false)}
onSubmit={(data) => addMutation.mutate(data)}
saving={addMutation.isPending}
/>
)}
{/* 编辑弹窗 */}
{editRecord && (
<EditModal
record={editRecord}
onClose={() => setEditRecord(null)}
onSubmit={(data) => updateMutation.mutate({ id: editRecord.id, ...data })}
saving={updateMutation.isPending}
/>
)}
</div>
)
}
function AddModal({ employees, month, onClose, onSubmit, saving }: {
employees: any[]
month: string
onClose: () => void
onSubmit: (data: { employeeId: string; month: string; amount: number; remark?: string }) => void
saving: boolean
}) {
const [employeeId, setEmployeeId] = useState('')
const [amount, setAmount] = useState('')
const [remark, setRemark] = useState('')
return (
<Modal open onClose={onClose} title="新增提成奖金" size="md">
<div className="space-y-4">
<div>
<Label> *</Label>
<Select value={employeeId} onChange={(e) => setEmployeeId(e.target.value)}>
<option value=""></option>
{employees.map((e: any) => (
<option key={e.id} value={e.id}>{e.name}{e.department}{e.idCardNumber ? ` ${e.idCardNumber}` : ''}</option>
))}
</Select>
</div>
<div>
<Label></Label>
<Input type="month" value={month} disabled className="bg-gray-50" />
</div>
<div>
<Label> *==</Label>
<Input type="number" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="如 5000 或 -200" />
</div>
<div>
<Label></Label>
<Input value={remark} onChange={(e) => setRemark(e.target.value)} placeholder="选填" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button
disabled={!employeeId || !amount || saving}
onClick={() => onSubmit({ employeeId, month, amount: parseFloat(amount) || 0, remark: remark || undefined })}
>
{saving ? '保存中...' : '保存'}
</Button>
</div>
</div>
</Modal>
)
}
function EditModal({ record, onClose, onSubmit, saving }: {
record: any
onClose: () => void
onSubmit: (data: { amount?: number; remark?: string }) => void
saving: boolean
}) {
const [amount, setAmount] = useState(String(record.amount))
const [remark, setRemark] = useState(record.remark || '')
return (
<Modal open onClose={onClose} title={`编辑 - ${record.employee?.name || ''}${record.employee?.idCardNumber ? `${record.employee.idCardNumber}` : ''}`} size="md">
<div className="space-y-4">
<div>
<Label> *==</Label>
<Input type="number" value={amount} onChange={(e) => setAmount(e.target.value)} />
</div>
<div>
<Label></Label>
<Input value={remark} onChange={(e) => setRemark(e.target.value)} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button
disabled={!amount || saving}
onClick={() => onSubmit({ amount: parseFloat(amount) || 0, remark: remark || undefined })}
>
{saving ? '保存中...' : '保存'}
</Button>
</div>
</div>
</Modal>
)
}