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:
@@ -346,3 +346,54 @@
|
|||||||
- **修复**:Dashboard 待办列表渲染时,`actionUrl` 为空或 '/' 时不渲染 Link 和"立刻办理"按钮,改为纯展示 div;有有效 URL 时才渲染可跳转链接。问题 10A 已修正三期/医疗期/工伤的反向引导 URL,其余 actionUrl(`/roster?employee=xxx`、`/special-status`、`/money`)均指向有效路由,Roster 已处理 `?employee` 参数自动定位。
|
- **修复**:Dashboard 待办列表渲染时,`actionUrl` 为空或 '/' 时不渲染 Link 和"立刻办理"按钮,改为纯展示 div;有有效 URL 时才渲染可跳转链接。问题 10A 已修正三期/医疗期/工伤的反向引导 URL,其余 actionUrl(`/roster?employee=xxx`、`/special-status`、`/money`)均指向有效路由,Roster 已处理 `?employee` 参数自动定位。
|
||||||
- **涉及文件**:`frontend/src/pages/Dashboard.tsx`(待办列表渲染 1136-1233 行)
|
- **涉及文件**:`frontend/src/pages/Dashboard.tsx`(待办列表渲染 1136-1233 行)
|
||||||
|
|
||||||
|
### ✅ 已完成 13:风险提醒规则优化(问题 10B)
|
||||||
|
|
||||||
|
- **问题**:合同到期提醒仅 30 天预警,HR 反应时间不足;去重后的待办列表未按优先级排序。
|
||||||
|
- **修复**:
|
||||||
|
1. 合同到期预警从 30 天提前到 60 天,分级:≤30 天 HIGH(需立即处理),31-60 天 MEDIUM(提前预警)。
|
||||||
|
2. `dedupedTodos` 按优先级排序(URGENT > HIGH > MEDIUM > LOW),确保最紧急的待办排在最前。
|
||||||
|
- **涉及文件**:`backend/src/services/risk.service.ts`(`detectContractRisks` 216-248 行、`getDashboardData` dedupedTodos 排序 941-952 行)
|
||||||
|
|
||||||
|
### ✅ 已完成 14:提成奖金新模块(问题 12)
|
||||||
|
|
||||||
|
- **问题**:无独立提成奖金管理模块,`BONUS` 批次是年终奖发放,非按月提成奖金管理。
|
||||||
|
- **实现**:
|
||||||
|
1. **数据模型**:新增 `CommissionBonus` 表(`orgId`、`employeeId`、`month` YYYY-MM、`amount` 支持正负值、`remark`、`createdBy`),唯一索引 `(orgId, employeeId, month)`。
|
||||||
|
2. **后端**:新增 `commission-bonus.service.ts` + `commission-bonus.routes.ts`:
|
||||||
|
- `GET /commission-bonus?month=YYYY-MM` 按月列表 + 汇总(总奖金/总扣款/净额)
|
||||||
|
- `POST /commission-bonus` 新增单条(upsert,同员工同月自动覆盖)
|
||||||
|
- `PUT /commission-bonus/:id` 编辑单条
|
||||||
|
- `DELETE /commission-bonus/:id` 删除单条
|
||||||
|
- `POST /commission-bonus/import` 批量导入 Excel(字段:员工姓名/证件号码/金额/备注)
|
||||||
|
- `GET /commission-bonus/template` 下载导入模板
|
||||||
|
3. **前端**:新增 `CommissionBonus.tsx` 页面:
|
||||||
|
- 月份选择器(默认当月)
|
||||||
|
- 汇总卡片(记录数/总奖金/总扣款/净额)
|
||||||
|
- 列表展示(员工/部门/金额/备注),支持搜索、分页
|
||||||
|
- 单条新增/编辑/删除
|
||||||
|
- 批量导入 Excel + 模板下载
|
||||||
|
- 金额支持正负值(正=奖金,负=扣款)
|
||||||
|
4. **菜单**:`SidebarNav.tsx` "团队"分组新增"提成奖金"入口,路由 `/commission-bonus`。
|
||||||
|
5. **DB 同步**:`prisma db push` 已执行,Prisma Client 已重新生成。
|
||||||
|
- **涉及文件**:
|
||||||
|
- `backend/prisma/schema.prisma`(新增 `CommissionBonus` 模型 + Employee/Organization 反向关联)
|
||||||
|
- `backend/src/services/commission-bonus.service.ts`(新增)
|
||||||
|
- `backend/src/routes/commission-bonus.routes.ts`(新增)
|
||||||
|
- `backend/src/app.ts`(路由注册)
|
||||||
|
- `frontend/src/pages/CommissionBonus.tsx`(新增)
|
||||||
|
- `frontend/src/components/layout/SidebarNav.tsx`(菜单项)
|
||||||
|
- `frontend/src/App.tsx`(路由注册)
|
||||||
|
- `frontend/src/lib/api-services.ts`(`commissionBonusApi`)
|
||||||
|
|
||||||
|
### ✅ 已完成 15:获取提成奖金(问题 13)
|
||||||
|
|
||||||
|
- **问题**:薪资批次编辑薪资时无"获取提成奖金"按钮,无法从提成奖金模块按月拉取填充 `bonus` 字段。
|
||||||
|
- **实现**:
|
||||||
|
1. 后端新增 `POST /payroll2/batches/:batchId/fetch-bonus`:按批次月份从 `CommissionBonus` 表拉取,填充到对应 `entries.bonus`,重算批次汇总。
|
||||||
|
2. 前端 `BatchTab.tsx` 编辑薪资步骤新增"获取提成奖金"按钮(在"导入加班费"旁),调用后展示结果摘要(X 人提成奖金已填充,合计 ¥Y)。
|
||||||
|
3. 已归档批次禁用按钮。
|
||||||
|
- **涉及文件**:
|
||||||
|
- `backend/src/routes/payroll2.routes.ts`(`fetch-bonus` 接口 552-628 行)
|
||||||
|
- `frontend/src/lib/api-services.ts`(`fetchBonusToBatch`)
|
||||||
|
- `frontend/src/pages/money/BatchTab.tsx`(`fetchBonusMutation` + 按钮 784-798 行)
|
||||||
|
|
||||||
|
|||||||
@@ -214,7 +214,10 @@ router.post('/sign-date', async (req: AuthRequest, res: Response, next: NextFunc
|
|||||||
if (contract.signMethod === 'ELECTRONIC') {
|
if (contract.signMethod === 'ELECTRONIC') {
|
||||||
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '电子签合同的签署日期由电签系统自动回写,不可手动修改' } })
|
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '电子签合同的签署日期由电签系统自动回写,不可手动修改' } })
|
||||||
}
|
}
|
||||||
const parsedDate = new Date(signDate)
|
// 解析日期字符串(YYYY-MM-DD),手动构造本地中午时间避免时区偏移
|
||||||
|
const dateStr = String(signDate).slice(0, 10)
|
||||||
|
const [y, m, d] = dateStr.split('-').map(Number)
|
||||||
|
const parsedDate = new Date(y, (m || 1) - 1, d || 1, 12, 0, 0, 0)
|
||||||
if (isNaN(parsedDate.getTime())) {
|
if (isNaN(parsedDate.getTime())) {
|
||||||
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '签署日期格式无效' } })
|
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '签署日期格式无效' } })
|
||||||
}
|
}
|
||||||
@@ -229,6 +232,18 @@ router.post('/sign-date', async (req: AuthRequest, res: Response, next: NextFunc
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
if (!existingRecord) {
|
if (!existingRecord) {
|
||||||
|
// 合同类型枚举转中文
|
||||||
|
const CONTRACT_TYPE_LABEL: Record<string, string> = {
|
||||||
|
FIXED: '固定期限劳动合同',
|
||||||
|
UNFIXED: '无固定期限劳动合同',
|
||||||
|
UNSIGNED: '未签合同',
|
||||||
|
LABOR: '劳务协议',
|
||||||
|
INTERNSHIP: '实习协议',
|
||||||
|
DISPATCH: '劳务派遣合同',
|
||||||
|
OUTSOURCING: '外包合同',
|
||||||
|
PARTTIME: '非全日制合同',
|
||||||
|
}
|
||||||
|
const contractLabel = CONTRACT_TYPE_LABEL[contract.contractType] || '合同'
|
||||||
await prisma.eSignRecord.create({
|
await prisma.eSignRecord.create({
|
||||||
data: {
|
data: {
|
||||||
orgId: req.user!.orgId,
|
orgId: req.user!.orgId,
|
||||||
@@ -236,7 +251,7 @@ router.post('/sign-date', async (req: AuthRequest, res: Response, next: NextFunc
|
|||||||
employeeId: contract.employeeId,
|
employeeId: contract.employeeId,
|
||||||
scene: 'CONTRACT',
|
scene: 'CONTRACT',
|
||||||
signMethod: 'PAPER',
|
signMethod: 'PAPER',
|
||||||
documentTitle: `${contract.contractType}合同线下签署登记`,
|
documentTitle: `${contractLabel}线下签署登记`,
|
||||||
status: 'COMPLETED',
|
status: 'COMPLETED',
|
||||||
completedAt: parsedDate,
|
completedAt: parsedDate,
|
||||||
signedAt: parsedDate,
|
signedAt: parsedDate,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
prePayrollCheck,
|
prePayrollCheck,
|
||||||
} from '../services/payroll.service'
|
} from '../services/payroll.service'
|
||||||
import { isInProbation } from '../services/contract.service'
|
import { isInProbation } from '../services/contract.service'
|
||||||
|
import { getBonusByMonthAndEmployeeIds } from '../services/commission-bonus.service'
|
||||||
|
|
||||||
// RFC 5987 编码中文文件名
|
// RFC 5987 编码中文文件名
|
||||||
function contentDisposition(filename: string): string {
|
function contentDisposition(filename: string): string {
|
||||||
@@ -548,6 +549,81 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 获取提成奖金:按批次月份从 CommissionBonus 表拉取,填充到 entries.bonus
|
||||||
|
router.post('/batches/:batchId/fetch-bonus', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { batchId } = req.params
|
||||||
|
const orgId = req.user!.orgId
|
||||||
|
|
||||||
|
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||||
|
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||||
|
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '已归档批次不可操作' } })
|
||||||
|
|
||||||
|
// 获取批次所有条目
|
||||||
|
const entries = await prisma.batchEntry.findMany({
|
||||||
|
where: { batchId },
|
||||||
|
select: { id: true, employeeId: true, bonus: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return res.json({ success: true, data: { filled: 0, totalAmount: 0, message: '批次无员工条目' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按批次月份查询提成奖金
|
||||||
|
const bonusMap = await getBonusByMonthAndEmployeeIds(orgId, batch.month, entries.map((e) => e.employeeId))
|
||||||
|
|
||||||
|
let filled = 0
|
||||||
|
let totalAmount = 0
|
||||||
|
for (const entry of entries) {
|
||||||
|
const bonus = bonusMap.get(entry.employeeId)
|
||||||
|
if (bonus) {
|
||||||
|
await prisma.batchEntry.update({
|
||||||
|
where: { id: entry.id },
|
||||||
|
data: { bonus: bonus.amount },
|
||||||
|
})
|
||||||
|
filled++
|
||||||
|
totalAmount += bonus.amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重算批次汇总
|
||||||
|
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
|
||||||
|
const totals = allEntries.reduce((acc, e) => ({
|
||||||
|
totalPay: acc.totalPay + e.baseSalary + e.overtimePay + e.allowance + e.bonus - e.deduction,
|
||||||
|
totalNetPay: acc.totalNetPay + e.netPay,
|
||||||
|
totalSocialOrg: acc.totalSocialOrg + e.socialOrg,
|
||||||
|
totalSocialEmp: acc.totalSocialEmp + e.socialEmp,
|
||||||
|
totalHousingOrg: acc.totalHousingOrg + e.housingOrg,
|
||||||
|
totalHousingEmp: acc.totalHousingEmp + e.housingEmp,
|
||||||
|
totalTax: acc.totalTax + e.tax,
|
||||||
|
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
|
||||||
|
|
||||||
|
await prisma.payrollBatch.update({
|
||||||
|
where: { id: batchId },
|
||||||
|
data: {
|
||||||
|
totalPay: Math.round(totals.totalPay * 100) / 100,
|
||||||
|
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
|
||||||
|
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
|
||||||
|
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
|
||||||
|
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
|
||||||
|
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
|
||||||
|
totalTax: Math.round(totals.totalTax * 100) / 100,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
filled,
|
||||||
|
totalAmount: Math.round(totalAmount * 100) / 100,
|
||||||
|
message: filled > 0 ? `已填充 ${filled} 人提成奖金,合计 ¥${Math.round(totalAmount * 100) / 100}` : `${batch.month} 无提成奖金数据`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 获取条目个税计算明细
|
// 获取条目个税计算明细
|
||||||
router.get('/batches/:batchId/entries/:employeeId/tax-detail', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
router.get('/batches/:batchId/entries/:employeeId/tax-detail', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const SalaryDashboard = lazy(() => import('./pages/SalaryDashboard'))
|
|||||||
const CommercialInsurance = lazy(() => import('./pages/CommercialInsurance'))
|
const CommercialInsurance = lazy(() => import('./pages/CommercialInsurance'))
|
||||||
const EmployeeBenefits = lazy(() => import('./pages/EmployeeBenefits'))
|
const EmployeeBenefits = lazy(() => import('./pages/EmployeeBenefits'))
|
||||||
const ESign = lazy(() => import('./pages/ESign'))
|
const ESign = lazy(() => import('./pages/ESign'))
|
||||||
|
const CommissionBonus = lazy(() => import('./pages/CommissionBonus'))
|
||||||
|
|
||||||
// 平台管理端
|
// 平台管理端
|
||||||
const PlatformLogin = lazy(() => import('./pages/platform/PlatformLogin'))
|
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/health-check" element={<ProtectedRoute><AdminLayout><HealthCheck /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/tools/annual-value" element={<ProtectedRoute><AdminLayout><AnnualValueReport /></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="/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="/company-files" element={<ProtectedRoute><AdminLayout><CompanyFiles /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/leave-approval" element={<ProtectedRoute><AdminLayout><LeaveApproval /></AdminLayout></ProtectedRoute>} />
|
<Route path="/leave-approval" element={<ProtectedRoute><AdminLayout><LeaveApproval /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/training-records" element={<ProtectedRoute><AdminLayout><TrainingRecords /></AdminLayout></ProtectedRoute>} />
|
<Route path="/training-records" element={<ProtectedRoute><AdminLayout><TrainingRecords /></AdminLayout></ProtectedRoute>} />
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
ChevronDown, ChevronRight,
|
ChevronDown, ChevronRight,
|
||||||
Building2, CalendarDays, ClipboardList, Heart, CalendarClock,
|
Building2, CalendarDays, ClipboardList, Heart, CalendarClock,
|
||||||
Gift, PenTool, Umbrella, GraduationCap, TrendingUp, AlertTriangle,
|
Gift, PenTool, Umbrella, GraduationCap, TrendingUp, AlertTriangle,
|
||||||
|
DollarSign,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Logo from '../ui/Logo'
|
import Logo from '../ui/Logo'
|
||||||
import { settingsApi } from '../../lib/api-services'
|
import { settingsApi } from '../../lib/api-services'
|
||||||
@@ -50,6 +51,7 @@ const navGroups: NavGroup[] = [
|
|||||||
{ path: '/performance-records', label: '绩效考核', icon: TrendingUp },
|
{ path: '/performance-records', label: '绩效考核', icon: TrendingUp },
|
||||||
{ path: '/disciplinary-records', label: '违纪记录', icon: AlertTriangle },
|
{ path: '/disciplinary-records', label: '违纪记录', icon: AlertTriangle },
|
||||||
{ path: '/special-status', label: '特殊员工', icon: Heart },
|
{ path: '/special-status', label: '特殊员工', icon: Heart },
|
||||||
|
{ path: '/commission-bonus', label: '提成奖金', icon: DollarSign },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -477,6 +477,9 @@ export const payrollApi = {
|
|||||||
/** 导入加班费到批次 */
|
/** 导入加班费到批次 */
|
||||||
importOvertimeToBatch: (batchId: string) =>
|
importOvertimeToBatch: (batchId: string) =>
|
||||||
post(`/payroll/overtime/import-to-batch/${batchId}`).then(unwrap<any>()),
|
post(`/payroll/overtime/import-to-batch/${batchId}`).then(unwrap<any>()),
|
||||||
|
/** 获取提成奖金到批次 */
|
||||||
|
fetchBonusToBatch: (batchId: string) =>
|
||||||
|
post(`/payroll2/batches/${batchId}/fetch-bonus`).then(unwrap<any>()),
|
||||||
/** 加班费配置 */
|
/** 加班费配置 */
|
||||||
overtimeConfig: () =>
|
overtimeConfig: () =>
|
||||||
get('/payroll/overtime/config').then(unwrap<any>()),
|
get('/payroll/overtime/config').then(unwrap<any>()),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'}`}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { usePageSize } from '../../hooks/usePageSize'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useConfirm } from '../../hooks/useConfirm'
|
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 { Stepper, type Step } from '../../components/ui/Stepper'
|
||||||
import { InlineAlert } from '../../components/ui/InlineAlert'
|
import { InlineAlert } from '../../components/ui/InlineAlert'
|
||||||
import PageGuide from '../../components/ui/PageGuide'
|
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({
|
const importPayrollMutation = useMutation({
|
||||||
mutationFn: async (file: File) => {
|
mutationFn: async (file: File) => {
|
||||||
const token = useAuthStore.getState().accessToken
|
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" />
|
<Calculator className="w-4 h-4 mr-1" />
|
||||||
{importOvertimeMutation.isPending ? '导入中...' : '导入加班费'}
|
{importOvertimeMutation.isPending ? '导入中...' : '导入加班费'}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user