fix: 线下签署登记标题英文转中文+完成时间时区+签署记录角标

1. documentTitle 中 contractType 枚举值转中文
   (LABOR→劳务协议、FIXED→固定期限劳动合同等)
2. 签署日期解析改为本地时区构造(new Date(y,m-1,d,12)),
   避免 new Date('2026-08-16') 被解析为 UTC 00:00 导致
   本地 +8 显示为 08:00:00
3. 签署记录 Tab 增加数字角标显示总记录数

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-16 12:26:13 +08:00
parent 72a6eab3bd
commit 5a9d440339
9 changed files with 485 additions and 3 deletions
+2
View File
@@ -61,6 +61,7 @@ const SalaryDashboard = lazy(() => import('./pages/SalaryDashboard'))
const CommercialInsurance = lazy(() => import('./pages/CommercialInsurance'))
const EmployeeBenefits = lazy(() => import('./pages/EmployeeBenefits'))
const ESign = lazy(() => import('./pages/ESign'))
const CommissionBonus = lazy(() => import('./pages/CommissionBonus'))
// 平台管理端
const PlatformLogin = lazy(() => import('./pages/platform/PlatformLogin'))
@@ -204,6 +205,7 @@ export default function App() {
<Route path="/tools/health-check" element={<ProtectedRoute><AdminLayout><HealthCheck /></AdminLayout></ProtectedRoute>} />
<Route path="/tools/annual-value" element={<ProtectedRoute><AdminLayout><AnnualValueReport /></AdminLayout></ProtectedRoute>} />
<Route path="/special-status" element={<ProtectedRoute><AdminLayout><SpecialStatus /></AdminLayout></ProtectedRoute>} />
<Route path="/commission-bonus" element={<ProtectedRoute><AdminLayout><CommissionBonus /></AdminLayout></ProtectedRoute>} />
<Route path="/company-files" element={<ProtectedRoute><AdminLayout><CompanyFiles /></AdminLayout></ProtectedRoute>} />
<Route path="/leave-approval" element={<ProtectedRoute><AdminLayout><LeaveApproval /></AdminLayout></ProtectedRoute>} />
<Route path="/training-records" element={<ProtectedRoute><AdminLayout><TrainingRecords /></AdminLayout></ProtectedRoute>} />
@@ -16,6 +16,7 @@ import {
ChevronDown, ChevronRight,
Building2, CalendarDays, ClipboardList, Heart, CalendarClock,
Gift, PenTool, Umbrella, GraduationCap, TrendingUp, AlertTriangle,
DollarSign,
} from 'lucide-react'
import Logo from '../ui/Logo'
import { settingsApi } from '../../lib/api-services'
@@ -50,6 +51,7 @@ const navGroups: NavGroup[] = [
{ path: '/performance-records', label: '绩效考核', icon: TrendingUp },
{ path: '/disciplinary-records', label: '违纪记录', icon: AlertTriangle },
{ path: '/special-status', label: '特殊员工', icon: Heart },
{ path: '/commission-bonus', label: '提成奖金', icon: DollarSign },
],
},
{
+3
View File
@@ -477,6 +477,9 @@ export const payrollApi = {
/** 导入加班费到批次 */
importOvertimeToBatch: (batchId: string) =>
post(`/payroll/overtime/import-to-batch/${batchId}`).then(unwrap<any>()),
/** 获取提成奖金到批次 */
fetchBonusToBatch: (batchId: string) =>
post(`/payroll2/batches/${batchId}/fetch-bonus`).then(unwrap<any>()),
/** 加班费配置 */
overtimeConfig: () =>
get('/payroll/overtime/config').then(unwrap<any>()),
+306
View File
@@ -0,0 +1,306 @@
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 || '-'}</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}</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 || ''}`} 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>
)
}
+1
View File
@@ -183,6 +183,7 @@ export default function ESign() {
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'records' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
>
{records.length > 0 && <span className="ml-1.5 px-1.5 py-0.5 rounded-full text-xs bg-blue-100 text-blue-700">{records.length}</span>}
</button>
</div>
<div className="flex gap-2">
+27 -1
View File
@@ -3,7 +3,7 @@ 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 } from 'lucide-react'
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'
@@ -556,6 +556,22 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
},
})
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
@@ -774,6 +790,16 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
<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 () => {