refactor: 社保公积金Tab重构为账户卡片列表+展开年度标准管理
1. 社保/公积金Tab从"选账户→展示配置"改为"账户卡片列表+展开管理" 2. 每个账户卡片可展开显示:当前标准/最低工资编辑/新建年度标准/版本历史/调基/试算 3. 账户新建/编辑/删除/设默认从设置页面迁移到社保公积金菜单 4. 设置页面去掉"社保公积金账户"Tab 5. "新建版本"改名为"新建年度标准" 6. 新增AccountCard组件独立管理每个账户的展开状态和数据查询 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -24,6 +24,7 @@ enum Role {
|
||||
}
|
||||
|
||||
enum EmployeeStatus {
|
||||
PRE_ONBOARD // 预入职:已录入但未正式入职,不进入薪资批次
|
||||
ACTIVE
|
||||
RESIGNED
|
||||
TERMINATED
|
||||
@@ -848,7 +849,8 @@ model PayrollBatch {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
month String // YYYY-MM
|
||||
month String // 所属月(计薪月)YYYY-MM,决定调用哪个月的社保标准、个税累计
|
||||
payMonth String? // 发薪年月 YYYY-MM(实际发放月份,如十一提前发薪时=9月,所属月=10月)。null=与所属月相同
|
||||
batchNo Int // 批次序号(1, 2, 3...)
|
||||
name String // 批次名称
|
||||
type PayrollBatchType @default(REGULAR)
|
||||
|
||||
@@ -254,6 +254,7 @@ router.put('/batches/:id/name', async (req: AuthRequest, res: Response, next: Ne
|
||||
// 创建批次
|
||||
const createBatchSchema = z.object({
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
payMonth: z.string().regex(/^\d{4}-\d{2}$/).optional(),
|
||||
type: z.enum(['REGULAR', 'TERMINATION', 'BONUS', 'SEVERANCE']).default('REGULAR'),
|
||||
mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch', 'custom']).default('copy_last'),
|
||||
sourceBatchId: z.string().optional(),
|
||||
@@ -264,7 +265,7 @@ const createBatchSchema = z.object({
|
||||
|
||||
router.post('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, type, mode, sourceBatchId, employeeIds, name, remark } = createBatchSchema.parse(req.body)
|
||||
const { month, payMonth, type, mode, sourceBatchId, employeeIds, name, remark } = createBatchSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
// 查询当月最大批次号,避免删除后 count 不准导致唯一键冲突
|
||||
@@ -339,6 +340,8 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
{ status: 'ACTIVE' },
|
||||
{ status: 'RESIGNED', updatedAt: { gte: monthStart, lte: monthEnd } },
|
||||
],
|
||||
// 预入职员工不进入薪资批次
|
||||
NOT: { status: 'PRE_ONBOARD' },
|
||||
},
|
||||
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
|
||||
})
|
||||
@@ -350,6 +353,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
data: {
|
||||
orgId,
|
||||
month,
|
||||
payMonth: payMonth || null,
|
||||
batchNo,
|
||||
name: batchName,
|
||||
type,
|
||||
|
||||
@@ -100,12 +100,18 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
// 状态过滤在 DB 层完成(contractStatus 需要后处理计算,仍需内存过滤)
|
||||
if (status === 'RESIGNED') {
|
||||
whereBase.status = 'RESIGNED'
|
||||
} else if (status === 'PRE_HIRE') {
|
||||
whereBase.status = 'ACTIVE'
|
||||
whereBase.hireDate = { gt: todayEnd }
|
||||
} else if (status === 'PRE_HIRE' || status === 'PRE_ONBOARD') {
|
||||
// 预入职:PRE_ONBOARD 状态,或 ACTIVE 状态但入职日期在未来
|
||||
whereBase.OR = [
|
||||
{ status: 'PRE_ONBOARD' },
|
||||
{ status: 'ACTIVE', hireDate: { gt: todayEnd } },
|
||||
]
|
||||
} else if (status === 'ACTIVE') {
|
||||
whereBase.status = 'ACTIVE'
|
||||
whereBase.hireDate = { lte: todayEnd }
|
||||
} else if (!status) {
|
||||
// 无状态过滤时,排除 PRE_ONBOARD(默认只看在职和离职)
|
||||
whereBase.NOT = { status: 'PRE_ONBOARD' }
|
||||
}
|
||||
|
||||
// unsigned 合同状态可以在 DB 层过滤
|
||||
@@ -1600,4 +1606,20 @@ router.get('/contract-types', authMiddleware, (_req: AuthRequest, res) => {
|
||||
res.json({ success: true, data: types })
|
||||
})
|
||||
|
||||
// 预入职转正式(PRE_ONBOARD → ACTIVE)
|
||||
router.post('/:id/activate', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const emp = await prisma.employee.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId! } })
|
||||
if (!emp) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
if (emp.status !== 'PRE_ONBOARD') {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '该员工不是预入职状态' } })
|
||||
}
|
||||
const updated = await prisma.employee.update({
|
||||
where: { id: emp.id },
|
||||
data: { status: 'ACTIVE' },
|
||||
})
|
||||
res.json({ success: true, data: { id: updated.id, status: updated.status } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -401,6 +401,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
city: data.city || '北京',
|
||||
education: data.education || null,
|
||||
position: data.position || null,
|
||||
status: data.status || 'ACTIVE',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -391,37 +391,58 @@ export async function calcBatchEntry(
|
||||
let deferredMinWage = 0
|
||||
let minWageApplied = 0 // 当月实际补齐到最低工资的金额
|
||||
|
||||
// 最低工资保护:实发不得低于最低工资标准(仅 REGULAR / TERMINATION 批次)
|
||||
if (minWage > 0 && batchType !== 'BONUS' && batchType !== 'SEVERANCE' && netPay < minWage) {
|
||||
const shortfall = minWage - netPay // 需要补齐的金额
|
||||
// 最低工资保护:当月累计实发不得低于最低工资标准(仅 REGULAR / TERMINATION 批次)
|
||||
// 第二批次时需判断"当月累计实发"(已归档批次 netPay 之和 + 本批次 netPay)是否低于 minWage
|
||||
if (minWage > 0 && batchType !== 'BONUS' && batchType !== 'SEVERANCE') {
|
||||
// 查当月已归档批次的累计实发(同月已归档的 REGULAR/TERMINATION 批次)
|
||||
const archivedNetPayEntries = await prisma.batchEntry.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
employeeId,
|
||||
batch: { month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } },
|
||||
},
|
||||
select: { netPay: true },
|
||||
})
|
||||
const archivedNetPay = archivedNetPayEntries.reduce((s, e) => s + e.netPay, 0)
|
||||
|
||||
// 优先递延社保个人部分(减少当月社保扣款)
|
||||
if (shortfall <= socialEmp) {
|
||||
// 只递延社保就够了
|
||||
deferredSocialEmp = shortfall
|
||||
socialEmp -= shortfall
|
||||
netPay = minWage
|
||||
minWageApplied = shortfall
|
||||
} else if (shortfall <= socialEmp + housingEmp) {
|
||||
// 递延全部社保 + 部分公积金
|
||||
deferredSocialEmp = socialEmp
|
||||
deferredHousingEmp = shortfall - socialEmp
|
||||
socialEmp = 0
|
||||
housingEmp -= deferredHousingEmp
|
||||
netPay = minWage
|
||||
minWageApplied = shortfall
|
||||
} else {
|
||||
// 递延全部社保 + 全部公积金,仍不足 → 差额作为最低工资补齐递延
|
||||
deferredSocialEmp = socialEmp
|
||||
deferredHousingEmp = housingEmp
|
||||
const remainingShortfall = shortfall - socialEmp - housingEmp
|
||||
socialEmp = 0
|
||||
housingEmp = 0
|
||||
// 此时 netPay = totalPay - tax - prevDeferredMinWage
|
||||
// 差额 = minWage - (totalPay - tax - prevDeferredMinWage)
|
||||
deferredMinWage = remainingShortfall
|
||||
netPay = minWage
|
||||
minWageApplied = shortfall
|
||||
// 当月累计实发 = 已归档批次实发 + 本批次实发
|
||||
const monthlyCumulativeNetPay = archivedNetPay + netPay
|
||||
|
||||
// 仅当当月累计实发低于最低工资时才触发保护
|
||||
if (monthlyCumulativeNetPay < minWage) {
|
||||
// 需要补齐的金额 = 最低工资 - 当月累计实发
|
||||
// 但本批次最多补齐到本批次实发为正,且不超过 minWage - archivedNetPay
|
||||
const targetNetPay = Math.max(0, minWage - archivedNetPay)
|
||||
const shortfall = targetNetPay - netPay // 需要补齐的金额(正数表示需要补齐)
|
||||
|
||||
if (shortfall > 0) {
|
||||
// 优先递延社保个人部分(减少当月社保扣款)
|
||||
if (shortfall <= socialEmp) {
|
||||
// 只递延社保就够了
|
||||
deferredSocialEmp = shortfall
|
||||
socialEmp -= shortfall
|
||||
netPay = targetNetPay
|
||||
minWageApplied = shortfall
|
||||
} else if (shortfall <= socialEmp + housingEmp) {
|
||||
// 递延全部社保 + 部分公积金
|
||||
deferredSocialEmp = socialEmp
|
||||
deferredHousingEmp = shortfall - socialEmp
|
||||
socialEmp = 0
|
||||
housingEmp -= deferredHousingEmp
|
||||
netPay = targetNetPay
|
||||
minWageApplied = shortfall
|
||||
} else {
|
||||
// 递延全部社保 + 全部公积金,仍不足 → 差额作为最低工资补齐递延
|
||||
deferredSocialEmp = socialEmp
|
||||
deferredHousingEmp = housingEmp
|
||||
const remainingShortfall = shortfall - socialEmp - housingEmp
|
||||
socialEmp = 0
|
||||
housingEmp = 0
|
||||
deferredMinWage = remainingShortfall
|
||||
netPay = targetNetPay
|
||||
minWageApplied = shortfall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList, LayoutGrid, PenTool, HeartPulse, Trash2, Shield, Edit2 } from 'lucide-react'
|
||||
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList, LayoutGrid, PenTool, HeartPulse, Trash2, Edit2 } from 'lucide-react'
|
||||
import { settingsApi, notificationsApi, socialAccountApi } from '../lib/api-services'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
@@ -16,7 +16,7 @@ import PageGuide from '../components/ui/PageGuide'
|
||||
|
||||
export default function Settings() {
|
||||
const queryClient = useQueryClient()
|
||||
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications' | 'retirement' | 'medical' | 'socialAccounts' | 'import' | 'export'>('org')
|
||||
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications' | 'retirement' | 'medical' | 'import' | 'export'>('org')
|
||||
|
||||
const { data: orgData } = useQuery<any>({
|
||||
queryKey: ['org-settings'],
|
||||
@@ -44,7 +44,6 @@ export default function Settings() {
|
||||
{ key: 'notifications' as const, label: '通知设置', icon: Bell },
|
||||
{ key: 'retirement' as const, label: '退休提醒', icon: Clock },
|
||||
{ key: 'medical' as const, label: '医疗期政策', icon: HeartPulse },
|
||||
{ key: 'socialAccounts' as const, label: '社保公积金账户', icon: Shield },
|
||||
{ key: 'import' as const, label: '数据导入', icon: FileSpreadsheet },
|
||||
{ key: 'export' as const, label: '数据导出', icon: Download },
|
||||
]
|
||||
@@ -88,7 +87,6 @@ export default function Settings() {
|
||||
<RetirementSettings orgData={orgData} onSave={(data) => updateOrgMutation.mutate(data)} />
|
||||
)}
|
||||
{activeSection === 'medical' && <MedicalPeriodSettings />}
|
||||
{activeSection === 'socialAccounts' && <SocialAccountSettings />}
|
||||
{activeSection === 'import' && <ImportSettings />}
|
||||
{activeSection === 'export' && <ExportSettings />}
|
||||
</div>
|
||||
|
||||
@@ -2,62 +2,34 @@ import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock, Sparkles } from 'lucide-react'
|
||||
import { Calculator, Check, Plus, Download, AlertCircle, Clock } from 'lucide-react'
|
||||
import { InlineAlert } from '../components/ui/InlineAlert'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import { socialInsuranceApi, socialAccountApi } from '../lib/api-services'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label } from '../components/ui/Input'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { MonthlyRow, MonthlyHousingRow } from './social-insurance/MonthlyRows'
|
||||
import SpecialDeductionTab from './social-insurance/SpecialDeductionTab'
|
||||
import EmployeeEnrollmentTab from './social-insurance/EmployeeEnrollmentTab'
|
||||
import { AccountCard } from './social-insurance/AccountCard'
|
||||
import { AccountFormModal } from './social-insurance/AccountFormModal'
|
||||
|
||||
// 金额格式化:保留两位小数 + 千分位
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
export default function SocialInsurance() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [searchParams] = useSearchParams()
|
||||
const initialTab = (searchParams.get('tab') as 'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment') || 'monthly'
|
||||
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment'>(initialTab)
|
||||
const [city, setCity] = useState<string>('北京')
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<string>('')
|
||||
const [showAddCity, setShowAddCity] = useState(false)
|
||||
const [newCityName, setNewCityName] = useState('')
|
||||
const [base, setBase] = useState(8000)
|
||||
const [deductionMonth, setDeductionMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [showNewVersion, setShowNewVersion] = useState(false)
|
||||
const [showVersions, setShowVersions] = useState(false)
|
||||
const [showAdjust, setShowAdjust] = useState(false)
|
||||
const [adjustData, setAdjustData] = useState<any>(null)
|
||||
const [editItems, setEditItems] = useState<Record<string, number>>({})
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [monthlyMonth, setMonthlyMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [monthlyProcessed, setMonthlyProcessed] = useState(false)
|
||||
const [processStatus, setProcessStatus] = useState<{ social: any; housing: any } | null>(null)
|
||||
const [minWageInput, setMinWageInput] = useState('0')
|
||||
const [newVersion, setNewVersion] = useState<any>({
|
||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
||||
city: '北京',
|
||||
pensionOrg: 16, pensionEmp: 8,
|
||||
medicalOrg: 9.8, medicalEmp: 2,
|
||||
unemploymentOrg: 0.5, unemploymentEmp: 0.5,
|
||||
injuryOrg: 0.2, maternityOrg: 0.8,
|
||||
baseMin: 6326, baseMax: 33891,
|
||||
medicalBaseMin: 0, medicalBaseMax: 0,
|
||||
minWage: 0,
|
||||
extraInsurances: [],
|
||||
})
|
||||
const [newHousingVersion, setNewHousingVersion] = useState<any>({
|
||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
||||
city: '北京',
|
||||
accountType: 'BASIC',
|
||||
housingOrg: 12, housingEmp: 12,
|
||||
baseMin: 6326, baseMax: 33891,
|
||||
})
|
||||
// 账户管理相关 state
|
||||
const [showAccountForm, setShowAccountForm] = useState(false)
|
||||
const [editAccount, setEditAccount] = useState<any>(null)
|
||||
|
||||
// 获取账户列表(按当前 tab 类型)
|
||||
const accountType = tab === 'housing' ? 'HOUSING' : 'SOCIAL'
|
||||
@@ -66,55 +38,35 @@ export default function SocialInsurance() {
|
||||
queryFn: () => socialAccountApi.list(accountType),
|
||||
})
|
||||
|
||||
// 选中账户变化时同步 city
|
||||
useEffect(() => {
|
||||
if (selectedAccountId) {
|
||||
const acc = accounts.find((a: any) => a.id === selectedAccountId)
|
||||
if (acc) setCity(acc.city)
|
||||
} else if (accounts.length > 0) {
|
||||
// 默认选第一个(或默认账户)
|
||||
const def = accounts.find((a: any) => a.isDefault) || accounts[0]
|
||||
setSelectedAccountId(def.id)
|
||||
setCity(def.city)
|
||||
}
|
||||
}, [accounts, selectedAccountId])
|
||||
|
||||
const { data: config, isLoading: configLoading } = useQuery<any>({
|
||||
queryKey: ['social-config', city],
|
||||
queryFn: async () => {
|
||||
return await socialInsuranceApi.config(city)
|
||||
// 账户 CRUD mutations
|
||||
const createAccountMutation = useMutation({
|
||||
mutationFn: (data: any) => socialAccountApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||
setShowAccountForm(false)
|
||||
toast.success('账户已创建')
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
|
||||
})
|
||||
|
||||
const { data: housingConfig, isLoading: housingLoading } = useQuery<any>({
|
||||
queryKey: ['housing-config', city],
|
||||
queryFn: async () => {
|
||||
return await socialInsuranceApi.housingConfig(city)
|
||||
},
|
||||
})
|
||||
const { data: housingAllAccounts } = useQuery<any[]>({
|
||||
queryKey: ['housing-config-all-accounts', city],
|
||||
queryFn: async () => {
|
||||
const res = await socialInsuranceApi.housingConfigVersions(city) as any
|
||||
const current = (res || []).filter((v: any) => v.isCurrent)
|
||||
return current
|
||||
const updateAccountMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => socialAccountApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||
setEditAccount(null)
|
||||
setShowAccountForm(false)
|
||||
toast.success('已更新')
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '更新失败'),
|
||||
})
|
||||
|
||||
const { data: versions } = useQuery<any[]>({
|
||||
queryKey: ['social-config-versions', city],
|
||||
queryFn: async () => {
|
||||
return await socialInsuranceApi.configVersions(city)
|
||||
const deleteAccountMutation = useMutation({
|
||||
mutationFn: (id: string) => socialAccountApi.remove(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||
toast.success('已删除')
|
||||
},
|
||||
enabled: showVersions && tab === 'social',
|
||||
})
|
||||
|
||||
const { data: housingVersions } = useQuery<any[]>({
|
||||
queryKey: ['housing-config-versions'],
|
||||
queryFn: async () => {
|
||||
return await socialInsuranceApi.housingConfigVersions()
|
||||
},
|
||||
enabled: showVersions && tab === 'housing',
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
|
||||
})
|
||||
|
||||
// 已办理月份列表(进入月度办理Tab时自动加载)
|
||||
@@ -187,187 +139,6 @@ export default function SocialInsurance() {
|
||||
},
|
||||
})
|
||||
|
||||
const { data: result, mutate: calcMutate, isPending } = useMutation<any>({
|
||||
mutationFn: async () => {
|
||||
return await socialInsuranceApi.calculate(base, city)
|
||||
},
|
||||
})
|
||||
|
||||
const { data: housingResult, mutate: calcHousingMutate, isPending: housingCalcPending } = useMutation<any>({
|
||||
mutationFn: async () => {
|
||||
return await socialInsuranceApi.housingCalculate(base, city)
|
||||
},
|
||||
})
|
||||
|
||||
const createVersionMutation = useMutation({
|
||||
mutationFn: (data: any) => {
|
||||
if (selectedAccountId) {
|
||||
return socialAccountApi.createStandard(selectedAccountId, data)
|
||||
}
|
||||
return socialInsuranceApi.createConfigVersion(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||
setShowNewVersion(false)
|
||||
toast.success('新版本已创建,旧版本已自动归档')
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const msg = err?.response?.data?.message || err?.message || '创建失败'
|
||||
toast.error(msg)
|
||||
},
|
||||
})
|
||||
|
||||
const createHousingVersionMutation = useMutation({
|
||||
mutationFn: (data: any) => {
|
||||
if (selectedAccountId) {
|
||||
return socialAccountApi.createStandard(selectedAccountId, data)
|
||||
}
|
||||
return socialInsuranceApi.createHousingConfigVersion(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||
setShowNewVersion(false)
|
||||
toast.success('公积金新版本已创建,旧版本已自动归档')
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const msg = err?.response?.data?.message || err?.message || '创建失败'
|
||||
toast.error(msg)
|
||||
},
|
||||
})
|
||||
|
||||
// 快速更新最低工资(无需新建版本)
|
||||
const updateMinWageMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const accountId = selectedAccountId || activeConfig?.accountId
|
||||
if (!accountId) throw new Error('未选择账户')
|
||||
return socialAccountApi.updateMinWage(accountId, Number(minWageInput) || 0)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||
toast.success('最低工资标准已更新')
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '更新失败'),
|
||||
})
|
||||
|
||||
// 当前配置变化时同步最低工资输入框
|
||||
useEffect(() => {
|
||||
setMinWageInput(String(activeConfig?.minWage || 0))
|
||||
}, [activeConfig?.minWage])
|
||||
|
||||
const aiSuggestMut = useMutation<any, any, { city: string; effectiveFrom: string; type: 'social' | 'housing' }>({
|
||||
mutationFn: async (vars: { city: string; effectiveFrom: string; type: 'social' | 'housing' }) => {
|
||||
return await socialInsuranceApi.aiSuggest(vars)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (isHousing) {
|
||||
activeSetNewVersion({
|
||||
...activeNewVersion,
|
||||
baseMin: data.baseMin ?? activeNewVersion.baseMin,
|
||||
baseMax: data.baseMax ?? activeNewVersion.baseMax,
|
||||
housingOrg: data.housingOrg ?? activeNewVersion.housingOrg,
|
||||
housingEmp: data.housingEmp ?? activeNewVersion.housingEmp,
|
||||
})
|
||||
} else {
|
||||
activeSetNewVersion({
|
||||
...activeNewVersion,
|
||||
baseMin: data.baseMin ?? activeNewVersion.baseMin,
|
||||
baseMax: data.baseMax ?? activeNewVersion.baseMax,
|
||||
medicalBaseMin: data.medicalBaseMin ?? 0,
|
||||
medicalBaseMax: data.medicalBaseMax ?? 0,
|
||||
pensionOrg: data.pensionOrg ?? activeNewVersion.pensionOrg,
|
||||
pensionEmp: data.pensionEmp ?? activeNewVersion.pensionEmp,
|
||||
medicalOrg: data.medicalOrg ?? activeNewVersion.medicalOrg,
|
||||
medicalEmp: data.medicalEmp ?? activeNewVersion.medicalEmp,
|
||||
unemploymentOrg: data.unemploymentOrg ?? activeNewVersion.unemploymentOrg,
|
||||
unemploymentEmp: data.unemploymentEmp ?? activeNewVersion.unemploymentEmp,
|
||||
injuryOrg: data.injuryOrg ?? activeNewVersion.injuryOrg,
|
||||
maternityOrg: data.maternityOrg ?? activeNewVersion.maternityOrg,
|
||||
extraInsurances: data.extraInsurances ?? [],
|
||||
})
|
||||
}
|
||||
toast.success('AI建议已填入,请核对后保存')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('AI建议获取失败,请手动填写')
|
||||
},
|
||||
})
|
||||
|
||||
const previewAdjustMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return await socialInsuranceApi.adjustPreview(config?.id)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setAdjustData(data)
|
||||
setShowAdjust(true)
|
||||
},
|
||||
})
|
||||
|
||||
const previewHousingAdjustMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return await socialInsuranceApi.housingAdjustPreview(housingConfig?.id)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setAdjustData(data)
|
||||
setShowAdjust(true)
|
||||
},
|
||||
})
|
||||
|
||||
const applyAdjustMutation = useMutation({
|
||||
mutationFn: (data: { items: { employeeId: string; newBase: number }[] }) =>
|
||||
socialInsuranceApi.applyAdjust(config?.id, data),
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
setShowAdjust(false)
|
||||
setAdjustData(null)
|
||||
setEditItems({})
|
||||
setEditingId(null)
|
||||
toast.success(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的社保基数`)
|
||||
},
|
||||
})
|
||||
|
||||
const applyHousingAdjustMutation = useMutation({
|
||||
mutationFn: (data: { items: { employeeId: string; newBase: number }[] }) =>
|
||||
socialInsuranceApi.applyHousingAdjust(housingConfig?.id, data),
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
setShowAdjust(false)
|
||||
setAdjustData(null)
|
||||
setEditItems({})
|
||||
setEditingId(null)
|
||||
toast.success(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的公积金基数`)
|
||||
},
|
||||
})
|
||||
|
||||
const resetAdjustMutation = useMutation({
|
||||
mutationFn: () => socialInsuranceApi.resetAdjust(config?.id, city),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config', city] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config-versions', city] })
|
||||
toast.success('社保基数调整已重置,可以重新调整')
|
||||
},
|
||||
})
|
||||
|
||||
const resetHousingAdjustMutation = useMutation({
|
||||
mutationFn: () => socialInsuranceApi.resetHousingAdjust(housingConfig?.id, city),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config', city] })
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config-versions', city] })
|
||||
toast.success('公积金基数调整已重置,可以重新调整')
|
||||
},
|
||||
})
|
||||
|
||||
const handleExportCSV = (type: 'social' | 'housing', data: any) => {
|
||||
if (!data?.items?.length) return
|
||||
const headers = type === 'social'
|
||||
@@ -390,14 +161,6 @@ export default function SocialInsurance() {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const isHousing = tab === 'housing'
|
||||
const activeConfig = isHousing ? housingConfig : config
|
||||
const activeVersions = isHousing ? housingVersions : versions
|
||||
const activePreviewMut = isHousing ? previewHousingAdjustMutation : previewAdjustMutation
|
||||
const activeApplyMut = isHousing ? applyHousingAdjustMutation : applyAdjustMutation
|
||||
const activeCreateMut = isHousing ? createHousingVersionMutation : createVersionMutation
|
||||
const activeNewVersion = isHousing ? newHousingVersion : newVersion
|
||||
const activeSetNewVersion = isHousing ? setNewHousingVersion : setNewVersion
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -412,22 +175,14 @@ export default function SocialInsurance() {
|
||||
<p className="mt-1 text-sm text-gray-500">维护社保、公积金、商业保险缴费基数、版本及月度记录</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{(tab === 'social' || tab === 'housing') && (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowVersions(!showVersions)}>
|
||||
<History className="w-4 h-4 mr-1" />
|
||||
{showVersions ? '收起历史' : '版本历史'}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowNewVersion(!showNewVersion)}>
|
||||
<Plus className="w-4 h-4 mr-1" />新建版本
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{(tab === 'social' || tab === 'housing') && (
|
||||
<Button size="sm" onClick={() => { setEditAccount(null); setShowAccountForm(true) }}>
|
||||
<Plus className="w-4 h-4 mr-1" />新建账户
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab 切换 + 城市选择 */}
|
||||
{/* Tab 切换 */}
|
||||
<div className="flex items-center gap-4 border-b">
|
||||
{(['monthly', 'social', 'housing', 'enrollment', 'deduction'] as const).map((t) => (
|
||||
<button
|
||||
@@ -435,549 +190,67 @@ export default function SocialInsurance() {
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === t ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null); setMonthlyProcessed(false); setProcessStatus(null) }}
|
||||
onClick={() => { setTab(t); setMonthlyProcessed(false); setProcessStatus(null) }}
|
||||
>
|
||||
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : t === 'enrollment' ? '员工参保' : '专项附加扣除'}
|
||||
</button>
|
||||
))}
|
||||
{(tab === 'social' || tab === 'housing') && (
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<label className="text-sm text-gray-500">账户:</label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
|
||||
value={selectedAccountId}
|
||||
onChange={(e) => {
|
||||
const acc = accounts.find((a: any) => a.id === e.target.value)
|
||||
setSelectedAccountId(e.target.value)
|
||||
if (acc) setCity(acc.city)
|
||||
}}
|
||||
>
|
||||
{accounts.length === 0 && <option value="">暂无账户,请到设置中创建</option>}
|
||||
{accounts.map((a: any) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}({a.city}){a.isDefault ? ' · 默认' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ========== 社保 / 公积金 Tab ========== */}
|
||||
{/* ========== 社保 / 公积金 Tab:账户卡片列表 ========== */}
|
||||
{(tab === 'social' || tab === 'housing') && (
|
||||
(isHousing ? housingLoading : configLoading) ? (
|
||||
<Card><div className="text-center py-8 text-gray-500">加载中...</div></Card>
|
||||
) : activeConfig ? (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe">当前生效</span>
|
||||
<span className="text-sm text-gray-500">生效月份:{activeConfig.effectiveFrom}</span>
|
||||
<span className="text-sm text-gray-500">· {activeConfig.city}</span>
|
||||
{activeConfig.adjustmentDone && (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-500">已调整员工基数</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeConfig.adjustmentDone && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '重置确认', message: `确定要重置${isHousing ? '公积金' : '社保'}基数调整吗?重置后可重新调整。`, variant: 'primary' })) {
|
||||
isHousing ? resetHousingAdjustMutation.mutate() : resetAdjustMutation.mutate()
|
||||
}
|
||||
}}
|
||||
disabled={isHousing ? resetHousingAdjustMutation.isPending : resetAdjustMutation.isPending}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4 mr-1" />
|
||||
{isHousing ? resetHousingAdjustMutation.isPending ? '重置中...' : '重置调整' : resetAdjustMutation.isPending ? '重置中...' : '重置调整'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => activePreviewMut.mutate()}
|
||||
disabled={activeConfig.adjustmentDone || activePreviewMut.isPending}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4 mr-1" />
|
||||
{activeConfig.adjustmentDone ? '已调整' : activePreviewMut.isPending ? '加载中...' : `调整员工${isHousing ? '公积金' : '社保'}基数`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-center gap-2 mb-3">
|
||||
<Info className="w-4 h-4 shrink-0" />
|
||||
<span>批量调整用于每年7月统一调基。如需单独调整某员工基数,请前往「花名册」→ 点击员工 → 编辑 → 修改「社保缴费基数」/「公积金缴费基数」。</span>
|
||||
</div>
|
||||
{isHousing ? (
|
||||
<>
|
||||
{(housingAllAccounts || []).length > 1 && (
|
||||
<div className="flex gap-2 mb-3">
|
||||
{(housingAllAccounts || []).map((a: any) => (
|
||||
<span key={a.id} className={`px-2 py-0.5 rounded text-xs ${a.accountType === 'SUPPLEMENTARY' ? 'bg-purple-50 text-purple-700' : 'bg-blue-50 text-blue-700'}`}>
|
||||
{a.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'} {a.housingOrg}%/{a.housingEmp}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid md:grid-cols-4 gap-3 text-sm">
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">账户类型</span><span className="font-medium">{activeConfig.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">缴费基数下限</span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">缴费基数上限</span><span className="font-medium">¥{fmt(activeConfig.baseMax)}</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">公积金(企业)</span><span className="font-medium">{activeConfig.housingOrg}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">公积金(个人)</span><span className="font-medium">{activeConfig.housingEmp}%</span></div>
|
||||
<div className="space-y-3">
|
||||
{accounts.length === 0 ? (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
暂无{tab === 'housing' ? '公积金' : '社保'}账户,点击右上角「新建账户」创建
|
||||
</div>
|
||||
</>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-4 gap-3 text-sm">
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">缴费基数下限</span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">缴费基数上限</span><span className="font-medium">¥{fmt(activeConfig.baseMax)}</span></div>
|
||||
{activeConfig.medicalBaseMin > 0 && (
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">医保基数下限</span><span className="font-medium">¥{fmt(activeConfig.medicalBaseMin)}</span></div>
|
||||
)}
|
||||
{activeConfig.medicalBaseMax > 0 && (
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">医保基数上限</span><span className="font-medium">¥{fmt(activeConfig.medicalBaseMax)}</span></div>
|
||||
)}
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">养老(企业/个人)</span><span className="font-medium">{activeConfig.pensionOrg}% / {activeConfig.pensionEmp}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">医疗(企业/个人)</span><span className="font-medium">{activeConfig.medicalOrg}% / {activeConfig.medicalEmp}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">失业(企业/个人)</span><span className="font-medium">{activeConfig.unemploymentOrg}% / {activeConfig.unemploymentEmp}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">工伤(企业)</span><span className="font-medium">{activeConfig.injuryOrg}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">生育(企业)</span><span className="font-medium">{activeConfig.maternityOrg}%</span></div>
|
||||
{activeConfig.minWage > 0 && (
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">最低工资标准</span><span className="font-medium text-primary">¥{fmt(activeConfig.minWage)}</span></div>
|
||||
)}
|
||||
{/* 最低工资快速设置(社保 Tab,当前配置区域可直接编辑) */}
|
||||
<div className="flex justify-between items-center border-b pb-1.5">
|
||||
<span className="text-gray-500">最低工资标准</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={minWageInput}
|
||||
onChange={(e) => setMinWageInput(e.target.value)}
|
||||
placeholder="0"
|
||||
className="w-24 h-7 text-sm text-right"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => updateMinWageMutation.mutate()}
|
||||
disabled={updateMinWageMutation.isPending}
|
||||
>
|
||||
{updateMinWageMutation.isPending ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{Array.isArray(activeConfig.extraInsurances) && activeConfig.extraInsurances.map((ins: any, idx: number) => (
|
||||
<div key={idx} className="flex justify-between border-b pb-1.5">
|
||||
<span className="text-gray-500">{ins.name}(企业/个人)</span>
|
||||
<span className="font-medium">
|
||||
{ins.baseType === 'fixed'
|
||||
? `¥${ins.fixedAmount} / ¥${ins.empFixedAmount || 0}`
|
||||
: `${ins.orgRate}% / ${ins.empRate}%`
|
||||
accounts.map((a: any) => (
|
||||
<AccountCard
|
||||
key={a.id}
|
||||
account={a}
|
||||
isHousing={tab === 'housing'}
|
||||
onEdit={(acc) => { setEditAccount(acc); setShowAccountForm(true) }}
|
||||
onDelete={(acc) => deleteAccountMutation.mutate(acc.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* 账户新建/编辑弹窗 */}
|
||||
{showAccountForm && (
|
||||
<AccountFormModal
|
||||
type={accountType}
|
||||
account={editAccount}
|
||||
onClose={() => { setShowAccountForm(false); setEditAccount(null) }}
|
||||
onSubmit={async (data, departmentIds) => {
|
||||
if (editAccount) {
|
||||
updateAccountMutation.mutate({ id: editAccount.id, data })
|
||||
if (departmentIds) {
|
||||
await socialAccountApi.linkDepartments(editAccount.id, departmentIds)
|
||||
toast.success('账户已更新,部门关联已同步')
|
||||
}
|
||||
} else {
|
||||
createAccountMutation.mutate(
|
||||
{ ...data, type: accountType },
|
||||
{
|
||||
onSuccess: async (created: any) => {
|
||||
if (departmentIds && departmentIds.length > 0) {
|
||||
await socialAccountApi.linkDepartments(created.id, departmentIds)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||
},
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<Card><div className="text-center py-8 text-gray-500">该城市暂无{isHousing ? '公积金' : '社保'}配置,请点击「新建版本」创建</div></Card>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 调整预览 */}
|
||||
{(tab === 'social' || tab === 'housing') && showAdjust && adjustData && (
|
||||
<Card>
|
||||
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<SettingsIcon className="w-4 h-4" />员工{isHousing ? '公积金' : '社保'}基数调整
|
||||
</h3>
|
||||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2 mb-3">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
按当前版本基数上下限(¥{fmt(adjustData.baseMin)} ~ ¥{fmt(adjustData.baseMax)})调整全部在职员工{isHousing ? '公积金' : '社保'}缴费基数。
|
||||
建议基数=上年月均工资按上下限裁剪。您可逐行修改,也可点击「采用建议值」或「保持原基数」。确认后保存,此操作只能执行一次。
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Button variant="secondary" size="sm" onClick={() => {
|
||||
const newEdits: Record<string, number> = {}
|
||||
adjustData.items.forEach((i: any) => { newEdits[i.employeeId] = i.suggestedBase })
|
||||
setEditItems(newEdits)
|
||||
}}>
|
||||
<Check className="w-3.5 h-3.5 mr-1" />全部采用建议值
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => {
|
||||
const newEdits: Record<string, number> = {}
|
||||
adjustData.items.forEach((i: any) => { newEdits[i.employeeId] = i.oldBase })
|
||||
setEditItems(newEdits)
|
||||
}}>
|
||||
全部保持原基数
|
||||
</Button>
|
||||
<span className="text-xs text-gray-400">共 {adjustData.total} 名员工</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto mb-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 text-left">姓名</th>
|
||||
<th className="py-2 text-left">部门</th>
|
||||
<th className="py-2 text-right">上年月均</th>
|
||||
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}基数(当前)</th>
|
||||
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}基数(建议)</th>
|
||||
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}基数(新)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{adjustData.items.map((item: any) => {
|
||||
const edit = editItems[item.employeeId]
|
||||
const newBase = edit ?? item.suggestedBase
|
||||
const changed = newBase !== item.oldBase
|
||||
return (
|
||||
<tr key={item.employeeId} className="border-b last:border-0">
|
||||
<td className="py-1.5">{item.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{item.department}</td>
|
||||
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.avgSalary)}</td>
|
||||
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.oldBase)}</td>
|
||||
<td className="py-1.5 text-right text-gray-500">¥{fmt(item.suggestedBase)}</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{editingId === item.employeeId ? (
|
||||
<Input type="number" step="0.01" min="0" className="!w-28 text-right text-xs" value={newBase}
|
||||
onChange={(e) => setEditItems({ ...editItems, [item.employeeId]: Number(e.target.value) || 0 })}
|
||||
onBlur={() => setEditingId(null)}
|
||||
autoFocus />
|
||||
) : (
|
||||
<span className="cursor-text inline-block !w-28 text-right"
|
||||
onClick={() => setEditingId(item.employeeId)}>
|
||||
¥{fmt(newBase)}
|
||||
</span>
|
||||
)}
|
||||
{changed && <span className="text-warning ml-1">●</span>}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => {
|
||||
const items = adjustData.items.map((i: any) => ({ employeeId: i.employeeId, newBase: editItems[i.employeeId] ?? i.suggestedBase }))
|
||||
activeApplyMut.mutate({ items })
|
||||
}} disabled={activeApplyMut.isPending}>
|
||||
{activeApplyMut.isPending ? '保存中...' : '确认保存'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => { setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null) }}>取消</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 版本历史 */}
|
||||
{(tab === 'social' || tab === 'housing') && showVersions && (
|
||||
<Card>
|
||||
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><History className="w-4 h-4" />{isHousing ? '公积金' : '社保'}版本历史</h3>
|
||||
{!activeVersions || activeVersions.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-400 text-xs">暂无版本记录</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 text-left">生效月份</th>
|
||||
<th className="py-2 text-left">失效月份</th>
|
||||
<th className="py-2 text-left">城市</th>
|
||||
{isHousing && <th className="py-2 text-left">账户类型</th>}
|
||||
<th className="py-2 text-right">基数下限</th>
|
||||
<th className="py-2 text-right">基数上限</th>
|
||||
{isHousing ? (
|
||||
<th className="py-2 text-right">公积金%</th>
|
||||
) : (
|
||||
<>
|
||||
<th className="py-2 text-right">养老%</th>
|
||||
<th className="py-2 text-right">医疗%</th>
|
||||
</>
|
||||
)}
|
||||
<th className="py-2 text-center">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeVersions.map((v: any) => (
|
||||
<tr key={v.id} className="border-b last:border-0 hover:bg-gray-50">
|
||||
<td className="py-2">{v.effectiveFrom}</td>
|
||||
<td className="py-2 text-gray-400">{v.effectiveTo || '—'}</td>
|
||||
<td className="py-2">{v.city}</td>
|
||||
{isHousing && <td className="py-2">{v.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}</td>}
|
||||
<td className="py-2 text-right">¥{fmt(v.baseMin)}</td>
|
||||
<td className="py-2 text-right">¥{fmt(v.baseMax)}</td>
|
||||
{isHousing ? (
|
||||
<td className="py-2 text-right text-gray-500">{v.housingOrg}/{v.housingEmp}</td>
|
||||
) : (
|
||||
<>
|
||||
<td className="py-2 text-right text-gray-500">{v.pensionOrg}/{v.pensionEmp}</td>
|
||||
<td className="py-2 text-right text-gray-500">{v.medicalOrg}/{v.medicalEmp}</td>
|
||||
</>
|
||||
)}
|
||||
<td className="py-2 text-center">
|
||||
{v.isCurrent ? <span className="px-2 py-0.5 rounded bg-green-50 text-safe">当前</span> : <span className="px-2 py-0.5 rounded bg-gray-100 text-gray-400">历史</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 新建版本 */}
|
||||
{(tab === 'social' || tab === 'housing') && showNewVersion && (
|
||||
<Card>
|
||||
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><Plus className="w-4 h-4" />新建{isHousing ? '公积金' : '社保'}配置版本</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>新版本生效后,当前版本将自动归档。发薪批次计算时按批次月份匹配对应版本的配置。通常每年7月调基时新建版本。</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 font-medium"
|
||||
onClick={() => aiSuggestMut.mutate({ city: activeNewVersion.city, effectiveFrom: activeNewVersion.effectiveFrom, type: isHousing ? 'housing' : 'social' })}
|
||||
disabled={aiSuggestMut.isPending}
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
{aiSuggestMut.isPending ? 'AI获取中...' : 'AI建议 — 根据城市和年份自动填充最新政策'}
|
||||
</button>
|
||||
<div className="grid md:grid-cols-3 gap-3">
|
||||
<div><Label>生效月份</Label><Input type="month" value={activeNewVersion.effectiveFrom} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, effectiveFrom: e.target.value })} /></div>
|
||||
<div><Label>城市</Label>
|
||||
<Input value={city} disabled className="bg-gray-50" />
|
||||
<p className="text-xs text-gray-400 mt-1">从所选账户继承</p>
|
||||
</div>
|
||||
<div><Label>缴费基数下限</Label><Input type="number" value={activeNewVersion.baseMin} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMin: Number(e.target.value) })} /></div>
|
||||
<div><Label>缴费基数上限</Label><Input type="number" value={activeNewVersion.baseMax} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMax: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
{!isHousing && (
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>医保/生育基数下限</Label>
|
||||
<Input type="number" value={activeNewVersion.medicalBaseMin} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalBaseMin: Number(e.target.value) })} />
|
||||
<p className="text-xs text-gray-400 mt-1">填 0 时使用统一基数下限</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>医保/生育基数上限</Label>
|
||||
<Input type="number" value={activeNewVersion.medicalBaseMax} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalBaseMax: Number(e.target.value) })} />
|
||||
<p className="text-xs text-gray-400 mt-1">填 0 时使用统一基数上限</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isHousing && (
|
||||
<div className="grid md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label>最低工资标准</Label>
|
||||
<Input type="number" value={activeNewVersion.minWage} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, minWage: Number(e.target.value) })} placeholder="如 2420" />
|
||||
<p className="text-xs text-gray-400 mt-1">当地月最低工资标准,填 0 不检查。实发低于此值时触发保护(递延扣款)</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isHousing ? (
|
||||
<>
|
||||
<div className="grid md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label>账户类型</Label>
|
||||
<select className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm" value={activeNewVersion.accountType || 'BASIC'} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, accountType: e.target.value })}>
|
||||
<option value="BASIC">基本公积金</option>
|
||||
<option value="SUPPLEMENTARY">补充公积金</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><Label>公积金(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingOrg: Number(e.target.value) })} /></div>
|
||||
<div><Label>公积金(个人%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingEmp: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-4 gap-3">
|
||||
<div><Label>养老(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.pensionOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, pensionOrg: Number(e.target.value) })} /></div>
|
||||
<div><Label>养老(个人%)</Label><Input type="number" step="0.1" value={activeNewVersion.pensionEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, pensionEmp: Number(e.target.value) })} /></div>
|
||||
<div><Label>医疗(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.medicalOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalOrg: Number(e.target.value) })} /></div>
|
||||
<div><Label>医疗(个人%)</Label><Input type="number" step="0.1" value={activeNewVersion.medicalEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalEmp: Number(e.target.value) })} /></div>
|
||||
<div><Label>失业(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.unemploymentOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, unemploymentOrg: Number(e.target.value) })} /></div>
|
||||
<div><Label>失业(个人%)</Label><Input type="number" step="0.1" value={activeNewVersion.unemploymentEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, unemploymentEmp: Number(e.target.value) })} /></div>
|
||||
<div><Label>工伤(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.injuryOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, injuryOrg: Number(e.target.value) })} /></div>
|
||||
<div><Label>生育(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.maternityOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, maternityOrg: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
)}
|
||||
{!isHousing && (
|
||||
<div className="border rounded-md p-3 space-y-2 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-700">附加险种(大病险/长护险等)</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={() => activeSetNewVersion({
|
||||
...activeNewVersion,
|
||||
extraInsurances: [...(activeNewVersion.extraInsurances || []), { name: '', orgRate: 0, empRate: 0, baseType: 'pension', fixedAmount: 0, empFixedAmount: 0 }],
|
||||
})}
|
||||
>
|
||||
+ 添加险种
|
||||
</button>
|
||||
</div>
|
||||
{(activeNewVersion.extraInsurances || []).map((ins: any, idx: number) => (
|
||||
<div key={idx} className="grid grid-cols-5 gap-2 items-end">
|
||||
<div><Label>险种名称</Label><Input value={ins.name} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, name: e.target.value }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
|
||||
<div>
|
||||
<Label>计算方式</Label>
|
||||
<select className="w-full h-9 rounded-md border border-input px-2 text-sm" value={ins.baseType} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, baseType: e.target.value }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }}>
|
||||
<option value="pension">按养老基数</option>
|
||||
<option value="medical">按医保基数</option>
|
||||
<option value="fixed">固定金额</option>
|
||||
</select>
|
||||
</div>
|
||||
{ins.baseType === 'fixed' ? (
|
||||
<>
|
||||
<div><Label>企业固定(元)</Label><Input type="number" value={ins.fixedAmount} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, fixedAmount: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
|
||||
<div><Label>个人固定(元)</Label><Input type="number" value={ins.empFixedAmount} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, empFixedAmount: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div><Label>企业%</Label><Input type="number" step="0.01" value={ins.orgRate} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, orgRate: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
|
||||
<div><Label>个人%</Label><Input type="number" step="0.01" value={ins.empRate} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, empRate: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="text-xs text-danger h-9" onClick={() => { const arr = (activeNewVersion.extraInsurances || []).filter((_: any, i: number) => i !== idx); activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }}>删除</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => activeCreateMut.mutate(activeNewVersion)} disabled={activeCreateMut.isPending}>
|
||||
{activeCreateMut.isPending ? '保存中...' : '创建版本'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowNewVersion(false)}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 试算工具 */}
|
||||
{(tab === 'social' || tab === 'housing') && (
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<h2 className="text-sm font-medium mb-3">{isHousing ? '公积金' : '社保'}试算</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>缴费基数(月工资)</Label>
|
||||
<Input type="number" value={base} onChange={(e) => setBase(Number(e.target.value) || 0)} />
|
||||
</div>
|
||||
<Button onClick={() => isHousing ? calcHousingMutate() : calcMutate()} disabled={isHousing ? housingCalcPending : isPending}>
|
||||
<Calculator className="w-4 h-4 mr-1" />
|
||||
{(isHousing ? housingCalcPending : isPending) ? '计算中...' : '开始计算'}
|
||||
</Button>
|
||||
{activeConfig && (
|
||||
<div className="text-sm text-gray-400">
|
||||
当前配置:{activeConfig.city} | 基数范围 {fmt(activeConfig.baseMin)}~{fmt(activeConfig.baseMax)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="text-sm font-medium mb-3 flex items-center gap-2"><Calculator className="w-4 h-4" />计算结果</h2>
|
||||
{(() => {
|
||||
const r = isHousing ? housingResult : result
|
||||
if (!r) return <div className="text-gray-400 text-sm">点击「开始计算」查看结果</div>
|
||||
if (isHousing) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
缴费基数:<span className="text-gray-900 font-medium">¥{fmt(r.actualBase)}</span>
|
||||
{r.capped && <span className="text-warning ml-2">(已封顶)</span>}
|
||||
{r.floored && <span className="text-warning ml-2">(已保底)</span>}
|
||||
{r.configVersion && <span className="text-gray-400 ml-2">| 配置版本:{r.configVersion}</span>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between border-b pb-2 text-sm">
|
||||
<span className="text-gray-500">企业缴纳</span>
|
||||
<span className="font-medium text-danger">¥{fmt(r.housingOrg)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b pb-2 text-sm">
|
||||
<span className="text-gray-500">个人缴纳</span>
|
||||
<span className="font-medium text-warning">¥{fmt(r.housingEmp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">总费用</span>
|
||||
<span className="text-lg font-bold text-primary">¥{fmt(r.total)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
企业承担 ¥{fmt(r.housingOrg)} + 个人承担 ¥{fmt(r.housingEmp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
缴费基数:<span className="text-gray-900 font-medium">¥{fmt(r.actualBase)}</span>
|
||||
{r.capped && <span className="text-warning ml-2">(已封顶)</span>}
|
||||
{r.floored && <span className="text-warning ml-2">(已保底)</span>}
|
||||
{r.configVersion && <span className="text-gray-400 ml-2">| 配置版本:{r.configVersion}</span>}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs text-gray-500">
|
||||
<th className="py-1.5">险种</th>
|
||||
<th className="py-1.5 text-right">企业%</th>
|
||||
<th className="py-1.5 text-right">个人%</th>
|
||||
<th className="py-1.5 text-right">企业缴纳</th>
|
||||
<th className="py-1.5 text-right">个人缴纳</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{r.items?.map((item: any) => (
|
||||
<tr key={item.name} className="border-b last:border-0">
|
||||
<td className="py-1.5">{item.name}</td>
|
||||
<td className="py-1.5 text-right text-gray-500">{item.orgRate}%</td>
|
||||
<td className="py-1.5 text-right text-gray-500">{item.empRate}%</td>
|
||||
<td className="py-1.5 text-right">¥{fmt(item.orgAmount)}</td>
|
||||
<td className="py-1.5 text-right">¥{fmt(item.empAmount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 font-bold">
|
||||
<td className="py-2" colSpan={3}>合计</td>
|
||||
<td className="py-2 text-right text-danger">¥{fmt(r.totalOrg)}</td>
|
||||
<td className="py-2 text-right text-warning">¥{fmt(r.totalEmp)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">总费用</span>
|
||||
<span className="text-lg font-bold text-primary">¥{fmt(r.total)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
企业承担 ¥{fmt(r.totalOrg)} + 个人承担 ¥{fmt(r.totalEmp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Card>
|
||||
}}
|
||||
saving={createAccountMutation.isPending || updateAccountMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 基数说明(仅社保/公积金Tab显示) */}
|
||||
{(tab === 'social' || tab === 'housing') && (
|
||||
<p className="text-sm text-gray-400">
|
||||
社保/公积金基数按上年度月均工资核定,每人不同,在员工基本信息中设置。比例和基数上下限按版本管理,通常每年7月调整。
|
||||
发薪批次计算时按批次月份自动匹配对应版本配置。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ========== 月度办理 Tab ========== */}
|
||||
{tab === 'monthly' && (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -107,6 +107,8 @@ export function BatchManager() {
|
||||
const [filterType, setFilterType] = useState<string>('')
|
||||
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [createMonth, setCreateMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [createPayMonth, setCreatePayMonth] = useState('')
|
||||
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>('')
|
||||
@@ -260,6 +262,18 @@ export function BatchManager() {
|
||||
<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>
|
||||
<Input type="month" value={createMonth} onChange={(e) => setCreateMonth(e.target.value)} />
|
||||
<p className="text-xs text-gray-400 mt-0.5">决定调用哪个月的社保标准、个税累计</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>发薪年月</Label>
|
||||
<Input type="month" value={createPayMonth} onChange={(e) => setCreatePayMonth(e.target.value)} />
|
||||
<p className="text-xs text-gray-400 mt-0.5">实际发放月份,留空=与所属月相同。如十一提前发薪:所属月=10月,发薪月=9月</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>批次类型</Label>
|
||||
@@ -307,7 +321,7 @@ export function BatchManager() {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => createMutation.mutate({ month, type: createType, mode: createMode, sourceBatchId: sourceBatchId || undefined, employeeIds: createMode === 'custom' ? selectedEmployeeIds : undefined })}
|
||||
onClick={() => createMutation.mutate({ month: createMonth, payMonth: createPayMonth || undefined, 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 ? '创建中...' : '确认创建'}
|
||||
|
||||
@@ -705,7 +705,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
return {
|
||||
name: '', department: '', departmentId: '', position: '', hireDate: todayStr, monthlySalary: '',
|
||||
idCardNumber: '', gender: '男' as '男' | '女', femaleWorkerType: '' as '' | 'CADRE' | 'WORKER', phone: '',
|
||||
city: '北京', education: '',
|
||||
city: '北京', education: '', status: 'ACTIVE' as 'ACTIVE' | 'PRE_ONBOARD',
|
||||
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
|
||||
signDate: '', startDate: todayStr, endDate: defaultEndDate,
|
||||
contractYears: 3, probationMonths: 0, probationSalary: 0,
|
||||
@@ -945,6 +945,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
phone: form.phone || undefined,
|
||||
education: form.education || undefined,
|
||||
city: autoAccounts?.socialAccount?.city || form.city || '北京',
|
||||
status: form.status || 'ACTIVE',
|
||||
socialInsBase: form.socialInsBase ? parseFloat(form.socialInsBase) : undefined,
|
||||
socialInsStartMonth: form.socialInsStartMonth || undefined,
|
||||
housingFundBase: form.housingFundBase ? parseFloat(form.housingFundBase) : undefined,
|
||||
@@ -1015,6 +1016,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div><Label>入职日期 *</Label><Input type="date" value={form.hireDate} onChange={(e) => handleHireDateChange(e.target.value)} /></div>
|
||||
<div><Label>入职状态</Label><Select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value as 'ACTIVE' | 'PRE_ONBOARD' })}><option value="ACTIVE">正式入职</option><option value="PRE_ONBOARD">预入职(不进入薪资)</option></Select></div>
|
||||
<div><Label>月工资 *</Label><Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" /></div>
|
||||
<div><Label>手机号</Label><Input value={form.phone} onChange={(e) => {
|
||||
const phone = e.target.value.replace(/\D/g, '').slice(0, 11)
|
||||
|
||||
@@ -0,0 +1,799 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, ChevronDown, ChevronRight, Edit2, Trash2, Sparkles, AlertCircle } from 'lucide-react'
|
||||
import { socialInsuranceApi, socialAccountApi } from '../../lib/api-services'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
import { Input, Label } from '../../components/ui/Input'
|
||||
|
||||
/** 金额格式化:保留两位小数 + 千分位 */
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
/**
|
||||
* 账户卡片组件
|
||||
* 展示账户基本信息,点击可展开显示当前年度标准、操作按钮、试算工具等
|
||||
*/
|
||||
export function AccountCard({
|
||||
account,
|
||||
isHousing,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
account: any
|
||||
isHousing: boolean
|
||||
onEdit: (account: any) => void
|
||||
onDelete: (account: any) => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [showNewVersion, setShowNewVersion] = useState(false)
|
||||
const [showVersions, setShowVersions] = useState(false)
|
||||
const [showAdjust, setShowAdjust] = useState(false)
|
||||
const [adjustData, setAdjustData] = useState<any>(null)
|
||||
const [editItems, setEditItems] = useState<Record<string, number>>({})
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [minWageInput, setMinWageInput] = useState('0')
|
||||
const [base, setBase] = useState(8000)
|
||||
|
||||
const [newVersion, setNewVersion] = useState<any>({
|
||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
||||
city: account.city,
|
||||
pensionOrg: 16, pensionEmp: 8,
|
||||
medicalOrg: 9.8, medicalEmp: 2,
|
||||
unemploymentOrg: 0.5, unemploymentEmp: 0.5,
|
||||
injuryOrg: 0.2, maternityOrg: 0.8,
|
||||
baseMin: 6326, baseMax: 33891,
|
||||
medicalBaseMin: 0, medicalBaseMax: 0,
|
||||
minWage: 0,
|
||||
extraInsurances: [],
|
||||
// 公积金字段
|
||||
accountType: account.accountType || 'BASIC',
|
||||
housingOrg: 12, housingEmp: 12,
|
||||
})
|
||||
|
||||
// 查询当前年度标准(展开时才查询)
|
||||
const { data: config, isLoading: configLoading } = useQuery<any>({
|
||||
queryKey: ['account-current-standard', account.id],
|
||||
queryFn: () => socialAccountApi.currentStandard(account.id),
|
||||
enabled: expanded,
|
||||
})
|
||||
|
||||
// 查询版本历史(展开且显示历史时才查询)
|
||||
const { data: versions } = useQuery<any[]>({
|
||||
queryKey: ['account-standards', account.id],
|
||||
queryFn: () => socialAccountApi.standards(account.id),
|
||||
enabled: expanded && showVersions,
|
||||
})
|
||||
|
||||
// 查询公积金所有账户当前配置(用于展示多账户概览)
|
||||
const { data: housingAllAccounts } = useQuery<any[]>({
|
||||
queryKey: ['housing-config-all-accounts', account.city],
|
||||
queryFn: async () => {
|
||||
const res = await socialInsuranceApi.housingConfigVersions(account.city) as any
|
||||
const current = (res || []).filter((v: any) => v.isCurrent)
|
||||
return current
|
||||
},
|
||||
enabled: expanded && isHousing,
|
||||
})
|
||||
|
||||
// 试算结果
|
||||
const { data: calcResult, mutate: calcMutate, isPending: calcPending } = useMutation<any>({
|
||||
mutationFn: async () => {
|
||||
if (isHousing) {
|
||||
return await socialInsuranceApi.housingCalculate(base, account.city)
|
||||
}
|
||||
return await socialInsuranceApi.calculate(base, account.city)
|
||||
},
|
||||
})
|
||||
|
||||
// 新建年度标准
|
||||
const createVersionMutation = useMutation({
|
||||
mutationFn: (data: any) => socialAccountApi.createStandard(account.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['account-current-standard'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['account-standards'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||
setShowNewVersion(false)
|
||||
toast.success('新年度标准已创建,旧版本已自动归档')
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const msg = err?.response?.data?.message || err?.message || '创建失败'
|
||||
toast.error(msg)
|
||||
},
|
||||
})
|
||||
|
||||
// 快速更新最低工资(无需新建版本)
|
||||
const updateMinWageMutation = useMutation({
|
||||
mutationFn: () => socialAccountApi.updateMinWage(account.id, Number(minWageInput) || 0),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['account-current-standard', account.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['account-standards', account.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||
toast.success('最低工资标准已更新')
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '更新失败'),
|
||||
})
|
||||
|
||||
// 当前配置变化时同步最低工资输入框
|
||||
useEffect(() => {
|
||||
setMinWageInput(String(config?.minWage || 0))
|
||||
}, [config?.minWage])
|
||||
|
||||
// AI 建议
|
||||
const aiSuggestMut = useMutation<any, any, { city: string; effectiveFrom: string; type: 'social' | 'housing' }>({
|
||||
mutationFn: async (vars: { city: string; effectiveFrom: string; type: 'social' | 'housing' }) => {
|
||||
return await socialInsuranceApi.aiSuggest(vars)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (isHousing) {
|
||||
setNewVersion({
|
||||
...newVersion,
|
||||
baseMin: data.baseMin ?? newVersion.baseMin,
|
||||
baseMax: data.baseMax ?? newVersion.baseMax,
|
||||
housingOrg: data.housingOrg ?? newVersion.housingOrg,
|
||||
housingEmp: data.housingEmp ?? newVersion.housingEmp,
|
||||
})
|
||||
} else {
|
||||
setNewVersion({
|
||||
...newVersion,
|
||||
baseMin: data.baseMin ?? newVersion.baseMin,
|
||||
baseMax: data.baseMax ?? newVersion.baseMax,
|
||||
medicalBaseMin: data.medicalBaseMin ?? 0,
|
||||
medicalBaseMax: data.medicalBaseMax ?? 0,
|
||||
pensionOrg: data.pensionOrg ?? newVersion.pensionOrg,
|
||||
pensionEmp: data.pensionEmp ?? newVersion.pensionEmp,
|
||||
medicalOrg: data.medicalOrg ?? newVersion.medicalOrg,
|
||||
medicalEmp: data.medicalEmp ?? newVersion.medicalEmp,
|
||||
unemploymentOrg: data.unemploymentOrg ?? newVersion.unemploymentOrg,
|
||||
unemploymentEmp: data.unemploymentEmp ?? newVersion.unemploymentEmp,
|
||||
injuryOrg: data.injuryOrg ?? newVersion.injuryOrg,
|
||||
maternityOrg: data.maternityOrg ?? newVersion.maternityOrg,
|
||||
extraInsurances: data.extraInsurances ?? [],
|
||||
})
|
||||
}
|
||||
toast.success('AI建议已填入,请核对后保存')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('AI建议获取失败,请手动填写')
|
||||
},
|
||||
})
|
||||
|
||||
// 调基预览
|
||||
const previewAdjustMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (isHousing) {
|
||||
return await socialInsuranceApi.housingAdjustPreview(config?.id)
|
||||
}
|
||||
return await socialInsuranceApi.adjustPreview(config?.id)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setAdjustData(data)
|
||||
setShowAdjust(true)
|
||||
},
|
||||
})
|
||||
|
||||
// 应用调基
|
||||
const applyAdjustMutation = useMutation({
|
||||
mutationFn: (data: { items: { employeeId: string; newBase: number }[] }) => {
|
||||
if (isHousing) {
|
||||
return socialInsuranceApi.applyHousingAdjust(config?.id, data)
|
||||
}
|
||||
return socialInsuranceApi.applyAdjust(config?.id, data)
|
||||
},
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['account-current-standard', account.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['account-standards', account.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
setShowAdjust(false)
|
||||
setAdjustData(null)
|
||||
setEditItems({})
|
||||
setEditingId(null)
|
||||
toast.success(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的${isHousing ? '公积金' : '社保'}基数`)
|
||||
},
|
||||
})
|
||||
|
||||
// 重置调基
|
||||
const resetAdjustMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (isHousing) {
|
||||
return socialInsuranceApi.resetHousingAdjust(config?.id, account.city)
|
||||
}
|
||||
return socialInsuranceApi.resetAdjust(config?.id, account.city)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['account-current-standard', account.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['account-standards', account.id] })
|
||||
toast.success(`${isHousing ? '公积金' : '社保'}基数调整已重置,可以重新调整`)
|
||||
},
|
||||
})
|
||||
|
||||
// 设为默认
|
||||
const setDefaultMutation = useMutation({
|
||||
mutationFn: (id: string) => socialAccountApi.setDefault(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||
toast.success('已设为默认')
|
||||
},
|
||||
})
|
||||
|
||||
const deptCount = (account._count?.deptSocialAccounts || 0) + (account._count?.deptHousingAccounts || 0)
|
||||
const recordCount = (account._count?.socialRecords || 0) + (account._count?.housingRecords || 0)
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
{/* 卡片头部(点击展开/折叠) */}
|
||||
<div
|
||||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{expanded ? <ChevronDown className="w-4 h-4 text-gray-400 shrink-0" /> : <ChevronRight className="w-4 h-4 text-gray-400 shrink-0" />}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-sm font-medium">{account.name}</span>
|
||||
{account.isDefault && <span className="px-1.5 py-0.5 rounded text-xs bg-green-50 text-safe">默认</span>}
|
||||
{account.status === 'SUSPENDED' && <span className="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-500">已停用</span>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 flex items-center gap-2 flex-wrap min-w-0">
|
||||
<span>{account.city}</span>
|
||||
{account.accountNo && <span className="text-gray-400">· {account.accountNo}</span>}
|
||||
<span className="text-gray-400">· {deptCount}个部门 · {recordCount}条记录</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{!account.isDefault && account.status === 'ACTIVE' && (
|
||||
<button onClick={() => setDefaultMutation.mutate(account.id)} className="text-xs text-primary hover:underline" title="设为默认">设为默认</button>
|
||||
)}
|
||||
<button onClick={() => onEdit(account)} className="p-1 text-gray-400 hover:text-primary" title="编辑">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '删除确认', message: `确认删除账户"${account.name}"?关联的参保记录不会被删除。`, variant: 'danger' })) {
|
||||
onDelete(account)
|
||||
}
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-danger" title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 展开内容 */}
|
||||
{expanded && (
|
||||
<div className="border-t border-gray-200 p-4 space-y-3 bg-gray-50/50">
|
||||
{/* 账户详情 */}
|
||||
<div className="text-xs text-gray-500 space-y-0.5">
|
||||
{account.orgName && <div>缴费主体:{account.orgName} {account.orgCode && `(${account.orgCode})`}</div>}
|
||||
{isHousing && account.bankName && <div>开户行:{account.bankName} {account.bankAccount && `· ${account.bankAccount}`}</div>}
|
||||
{isHousing && account.accountType && <div>账户类型:{account.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}</div>}
|
||||
</div>
|
||||
|
||||
{/* 当前年度标准展示 */}
|
||||
{configLoading ? (
|
||||
<Card><div className="text-center py-4 text-gray-500 text-sm">加载中...</div></Card>
|
||||
) : config ? (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe">当前生效</span>
|
||||
<span className="text-sm text-gray-500">生效月份:{config.effectiveFrom}</span>
|
||||
<span className="text-sm text-gray-500">· {config.city}</span>
|
||||
{config.adjustmentDone && (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-500">已调整员工基数</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{config.adjustmentDone && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '重置确认', message: `确定要重置${isHousing ? '公积金' : '社保'}基数调整吗?重置后可重新调整。`, variant: 'primary' })) {
|
||||
resetAdjustMutation.mutate()
|
||||
}
|
||||
}}
|
||||
disabled={resetAdjustMutation.isPending}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4 mr-1" />
|
||||
{resetAdjustMutation.isPending ? '重置中...' : '重置调整'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => previewAdjustMutation.mutate()}
|
||||
disabled={config.adjustmentDone || previewAdjustMutation.isPending}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4 mr-1" />
|
||||
{config.adjustmentDone ? '已调整' : previewAdjustMutation.isPending ? '加载中...' : `批量调基`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-center gap-2 mb-3">
|
||||
<Info className="w-4 h-4 shrink-0" />
|
||||
<span>批量调整用于每年7月统一调基。如需单独调整某员工基数,请前往「花名册」→ 点击员工 → 编辑 → 修改「社保缴费基数」/「公积金缴费基数」。</span>
|
||||
</div>
|
||||
{isHousing ? (
|
||||
<>
|
||||
{(housingAllAccounts || []).length > 1 && (
|
||||
<div className="flex gap-2 mb-3">
|
||||
{(housingAllAccounts || []).map((a: any) => (
|
||||
<span key={a.id} className={`px-2 py-0.5 rounded text-xs ${a.accountType === 'SUPPLEMENTARY' ? 'bg-purple-50 text-purple-700' : 'bg-blue-50 text-blue-700'}`}>
|
||||
{a.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'} {a.housingOrg}%/{a.housingEmp}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid md:grid-cols-4 gap-3 text-sm">
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">账户类型</span><span className="font-medium">{config.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">缴费基数下限</span><span className="font-medium">¥{fmt(config.baseMin)}</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">缴费基数上限</span><span className="font-medium">¥{fmt(config.baseMax)}</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">公积金(企业)</span><span className="font-medium">{config.housingOrg}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">公积金(个人)</span><span className="font-medium">{config.housingEmp}%</span></div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-4 gap-3 text-sm">
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">缴费基数下限</span><span className="font-medium">¥{fmt(config.baseMin)}</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">缴费基数上限</span><span className="font-medium">¥{fmt(config.baseMax)}</span></div>
|
||||
{config.medicalBaseMin > 0 && (
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">医保基数下限</span><span className="font-medium">¥{fmt(config.medicalBaseMin)}</span></div>
|
||||
)}
|
||||
{config.medicalBaseMax > 0 && (
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">医保基数上限</span><span className="font-medium">¥{fmt(config.medicalBaseMax)}</span></div>
|
||||
)}
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">养老(企业/个人)</span><span className="font-medium">{config.pensionOrg}% / {config.pensionEmp}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">医疗(企业/个人)</span><span className="font-medium">{config.medicalOrg}% / {config.medicalEmp}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">失业(企业/个人)</span><span className="font-medium">{config.unemploymentOrg}% / {config.unemploymentEmp}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">工伤(企业)</span><span className="font-medium">{config.injuryOrg}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">生育(企业)</span><span className="font-medium">{config.maternityOrg}%</span></div>
|
||||
{config.minWage > 0 && (
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">最低工资标准</span><span className="font-medium text-primary">¥{fmt(config.minWage)}</span></div>
|
||||
)}
|
||||
{/* 最低工资快速设置(仅社保) */}
|
||||
<div className="flex justify-between items-center border-b pb-1.5">
|
||||
<span className="text-gray-500">最低工资标准</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={minWageInput}
|
||||
onChange={(e) => setMinWageInput(e.target.value)}
|
||||
placeholder="0"
|
||||
className="w-24 h-7 text-sm text-right"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => updateMinWageMutation.mutate()}
|
||||
disabled={updateMinWageMutation.isPending}
|
||||
>
|
||||
{updateMinWageMutation.isPending ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{Array.isArray(config.extraInsurances) && config.extraInsurances.map((ins: any, idx: number) => (
|
||||
<div key={idx} className="flex justify-between border-b pb-1.5">
|
||||
<span className="text-gray-500">{ins.name}(企业/个人)</span>
|
||||
<span className="font-medium">
|
||||
{ins.baseType === 'fixed'
|
||||
? `¥${ins.fixedAmount} / ¥${ins.empFixedAmount || 0}`
|
||||
: `${ins.orgRate}% / ${ins.empRate}%`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<Card><div className="text-center py-6 text-gray-500 text-sm">该账户暂无{isHousing ? '公积金' : '社保'}年度标准,请点击「新建年度标准」创建</div></Card>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={() => setShowNewVersion(!showNewVersion)}>
|
||||
<Plus className="w-4 h-4 mr-1" />新建年度标准
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowVersions(!showVersions)}>
|
||||
<History className="w-4 h-4 mr-1" />
|
||||
{showVersions ? '收起历史' : '查看历史'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 调基预览 */}
|
||||
{showAdjust && adjustData && (
|
||||
<Card>
|
||||
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<SettingsIcon className="w-4 h-4" />员工{isHousing ? '公积金' : '社保'}基数调整
|
||||
</h3>
|
||||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2 mb-3">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
按当前版本基数上下限(¥{fmt(adjustData.baseMin)} ~ ¥{fmt(adjustData.baseMax)})调整全部在职员工{isHousing ? '公积金' : '社保'}缴费基数。
|
||||
建议基数=上年月均工资按上下限裁剪。您可逐行修改,也可点击「采用建议值」或「保持原基数」。确认后保存,此操作只能执行一次。
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Button variant="secondary" size="sm" onClick={() => {
|
||||
const newEdits: Record<string, number> = {}
|
||||
adjustData.items.forEach((i: any) => { newEdits[i.employeeId] = i.suggestedBase })
|
||||
setEditItems(newEdits)
|
||||
}}>
|
||||
<Check className="w-3.5 h-3.5 mr-1" />全部采用建议值
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => {
|
||||
const newEdits: Record<string, number> = {}
|
||||
adjustData.items.forEach((i: any) => { newEdits[i.employeeId] = i.oldBase })
|
||||
setEditItems(newEdits)
|
||||
}}>
|
||||
全部保持原基数
|
||||
</Button>
|
||||
<span className="text-xs text-gray-400">共 {adjustData.total} 名员工</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto mb-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 text-left">姓名</th>
|
||||
<th className="py-2 text-left">部门</th>
|
||||
<th className="py-2 text-right">上年月均</th>
|
||||
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}基数(当前)</th>
|
||||
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}基数(建议)</th>
|
||||
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}基数(新)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{adjustData.items.map((item: any) => {
|
||||
const edit = editItems[item.employeeId]
|
||||
const newBase = edit ?? item.suggestedBase
|
||||
const changed = newBase !== item.oldBase
|
||||
return (
|
||||
<tr key={item.employeeId} className="border-b last:border-0">
|
||||
<td className="py-1.5">{item.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{item.department}</td>
|
||||
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.avgSalary)}</td>
|
||||
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.oldBase)}</td>
|
||||
<td className="py-1.5 text-right text-gray-500">¥{fmt(item.suggestedBase)}</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{editingId === item.employeeId ? (
|
||||
<Input type="number" step="0.01" min="0" className="!w-28 text-right text-xs" value={newBase}
|
||||
onChange={(e) => setEditItems({ ...editItems, [item.employeeId]: Number(e.target.value) || 0 })}
|
||||
onBlur={() => setEditingId(null)}
|
||||
autoFocus />
|
||||
) : (
|
||||
<span className="cursor-text inline-block !w-28 text-right"
|
||||
onClick={() => setEditingId(item.employeeId)}>
|
||||
¥{fmt(newBase)}
|
||||
</span>
|
||||
)}
|
||||
{changed && <span className="text-warning ml-1">●</span>}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => {
|
||||
const items = adjustData.items.map((i: any) => ({ employeeId: i.employeeId, newBase: editItems[i.employeeId] ?? i.suggestedBase }))
|
||||
applyAdjustMutation.mutate({ items })
|
||||
}} disabled={applyAdjustMutation.isPending}>
|
||||
{applyAdjustMutation.isPending ? '保存中...' : '确认保存'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => { setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null) }}>取消</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 版本历史 */}
|
||||
{showVersions && (
|
||||
<Card>
|
||||
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><History className="w-4 h-4" />{isHousing ? '公积金' : '社保'}版本历史</h3>
|
||||
{!versions || versions.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-400 text-xs">暂无版本记录</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 text-left">生效月份</th>
|
||||
<th className="py-2 text-left">失效月份</th>
|
||||
<th className="py-2 text-left">城市</th>
|
||||
{isHousing && <th className="py-2 text-left">账户类型</th>}
|
||||
<th className="py-2 text-right">基数下限</th>
|
||||
<th className="py-2 text-right">基数上限</th>
|
||||
{isHousing ? (
|
||||
<th className="py-2 text-right">公积金%</th>
|
||||
) : (
|
||||
<>
|
||||
<th className="py-2 text-right">养老%</th>
|
||||
<th className="py-2 text-right">医疗%</th>
|
||||
</>
|
||||
)}
|
||||
<th className="py-2 text-center">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{versions.map((v: any) => (
|
||||
<tr key={v.id} className="border-b last:border-0 hover:bg-gray-50">
|
||||
<td className="py-2">{v.effectiveFrom}</td>
|
||||
<td className="py-2 text-gray-400">{v.effectiveTo || '—'}</td>
|
||||
<td className="py-2">{v.city}</td>
|
||||
{isHousing && <td className="py-2">{v.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}</td>}
|
||||
<td className="py-2 text-right">¥{fmt(v.baseMin)}</td>
|
||||
<td className="py-2 text-right">¥{fmt(v.baseMax)}</td>
|
||||
{isHousing ? (
|
||||
<td className="py-2 text-right text-gray-500">{v.housingOrg}/{v.housingEmp}</td>
|
||||
) : (
|
||||
<>
|
||||
<td className="py-2 text-right text-gray-500">{v.pensionOrg}/{v.pensionEmp}</td>
|
||||
<td className="py-2 text-right text-gray-500">{v.medicalOrg}/{v.medicalEmp}</td>
|
||||
</>
|
||||
)}
|
||||
<td className="py-2 text-center">
|
||||
{v.isCurrent ? <span className="px-2 py-0.5 rounded bg-green-50 text-safe">当前</span> : <span className="px-2 py-0.5 rounded bg-gray-100 text-gray-400">历史</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 新建年度标准 */}
|
||||
{showNewVersion && (
|
||||
<Card>
|
||||
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><Plus className="w-4 h-4" />新建{isHousing ? '公积金' : '社保'}年度标准</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>新版本生效后,当前版本将自动归档。发薪批次计算时按批次月份匹配对应版本的配置。通常每年7月调基时新建版本。</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 font-medium"
|
||||
onClick={() => aiSuggestMut.mutate({ city: account.city, effectiveFrom: newVersion.effectiveFrom, type: isHousing ? 'housing' : 'social' })}
|
||||
disabled={aiSuggestMut.isPending}
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
{aiSuggestMut.isPending ? 'AI获取中...' : 'AI建议 — 根据城市和年份自动填充最新政策'}
|
||||
</button>
|
||||
<div className="grid md:grid-cols-3 gap-3">
|
||||
<div><Label>生效月份</Label><Input type="month" value={newVersion.effectiveFrom} onChange={(e) => setNewVersion({ ...newVersion, effectiveFrom: e.target.value })} /></div>
|
||||
<div><Label>城市</Label>
|
||||
<Input value={account.city} disabled className="bg-gray-50" />
|
||||
<p className="text-xs text-gray-400 mt-1">从账户继承</p>
|
||||
</div>
|
||||
<div><Label>缴费基数下限</Label><Input type="number" value={newVersion.baseMin} onChange={(e) => setNewVersion({ ...newVersion, baseMin: Number(e.target.value) })} /></div>
|
||||
<div><Label>缴费基数上限</Label><Input type="number" value={newVersion.baseMax} onChange={(e) => setNewVersion({ ...newVersion, baseMax: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
{!isHousing && (
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>医保/生育基数下限</Label>
|
||||
<Input type="number" value={newVersion.medicalBaseMin} onChange={(e) => setNewVersion({ ...newVersion, medicalBaseMin: Number(e.target.value) })} />
|
||||
<p className="text-xs text-gray-400 mt-1">填 0 时使用统一基数下限</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>医保/生育基数上限</Label>
|
||||
<Input type="number" value={newVersion.medicalBaseMax} onChange={(e) => setNewVersion({ ...newVersion, medicalBaseMax: Number(e.target.value) })} />
|
||||
<p className="text-xs text-gray-400 mt-1">填 0 时使用统一基数上限</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isHousing && (
|
||||
<div className="grid md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label>最低工资标准</Label>
|
||||
<Input type="number" value={newVersion.minWage} onChange={(e) => setNewVersion({ ...newVersion, minWage: Number(e.target.value) })} placeholder="如 2420" />
|
||||
<p className="text-xs text-gray-400 mt-1">当地月最低工资标准,填 0 不检查。实发低于此值时触发保护(递延扣款)</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isHousing ? (
|
||||
<>
|
||||
<div className="grid md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label>账户类型</Label>
|
||||
<select className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm" value={newVersion.accountType || 'BASIC'} onChange={(e) => setNewVersion({ ...newVersion, accountType: e.target.value })}>
|
||||
<option value="BASIC">基本公积金</option>
|
||||
<option value="SUPPLEMENTARY">补充公积金</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><Label>公积金(企业%)</Label><Input type="number" step="0.1" value={newVersion.housingOrg} onChange={(e) => setNewVersion({ ...newVersion, housingOrg: Number(e.target.value) })} /></div>
|
||||
<div><Label>公积金(个人%)</Label><Input type="number" step="0.1" value={newVersion.housingEmp} onChange={(e) => setNewVersion({ ...newVersion, housingEmp: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-4 gap-3">
|
||||
<div><Label>养老(企业%)</Label><Input type="number" step="0.1" value={newVersion.pensionOrg} onChange={(e) => setNewVersion({ ...newVersion, pensionOrg: Number(e.target.value) })} /></div>
|
||||
<div><Label>养老(个人%)</Label><Input type="number" step="0.1" value={newVersion.pensionEmp} onChange={(e) => setNewVersion({ ...newVersion, pensionEmp: Number(e.target.value) })} /></div>
|
||||
<div><Label>医疗(企业%)</Label><Input type="number" step="0.1" value={newVersion.medicalOrg} onChange={(e) => setNewVersion({ ...newVersion, medicalOrg: Number(e.target.value) })} /></div>
|
||||
<div><Label>医疗(个人%)</Label><Input type="number" step="0.1" value={newVersion.medicalEmp} onChange={(e) => setNewVersion({ ...newVersion, medicalEmp: Number(e.target.value) })} /></div>
|
||||
<div><Label>失业(企业%)</Label><Input type="number" step="0.1" value={newVersion.unemploymentOrg} onChange={(e) => setNewVersion({ ...newVersion, unemploymentOrg: Number(e.target.value) })} /></div>
|
||||
<div><Label>失业(个人%)</Label><Input type="number" step="0.1" value={newVersion.unemploymentEmp} onChange={(e) => setNewVersion({ ...newVersion, unemploymentEmp: Number(e.target.value) })} /></div>
|
||||
<div><Label>工伤(企业%)</Label><Input type="number" step="0.1" value={newVersion.injuryOrg} onChange={(e) => setNewVersion({ ...newVersion, injuryOrg: Number(e.target.value) })} /></div>
|
||||
<div><Label>生育(企业%)</Label><Input type="number" step="0.1" value={newVersion.maternityOrg} onChange={(e) => setNewVersion({ ...newVersion, maternityOrg: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
)}
|
||||
{!isHousing && (
|
||||
<div className="border rounded-md p-3 space-y-2 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-700">附加险种(大病险/长护险等)</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={() => setNewVersion({
|
||||
...newVersion,
|
||||
extraInsurances: [...(newVersion.extraInsurances || []), { name: '', orgRate: 0, empRate: 0, baseType: 'pension', fixedAmount: 0, empFixedAmount: 0 }],
|
||||
})}
|
||||
>
|
||||
+ 添加险种
|
||||
</button>
|
||||
</div>
|
||||
{(newVersion.extraInsurances || []).map((ins: any, idx: number) => (
|
||||
<div key={idx} className="grid grid-cols-5 gap-2 items-end">
|
||||
<div><Label>险种名称</Label><Input value={ins.name} onChange={(e) => { const arr = [...(newVersion.extraInsurances || [])]; arr[idx] = { ...ins, name: e.target.value }; setNewVersion({ ...newVersion, extraInsurances: arr }) }} /></div>
|
||||
<div>
|
||||
<Label>计算方式</Label>
|
||||
<select className="w-full h-9 rounded-md border border-input px-2 text-sm" value={ins.baseType} onChange={(e) => { const arr = [...(newVersion.extraInsurances || [])]; arr[idx] = { ...ins, baseType: e.target.value }; setNewVersion({ ...newVersion, extraInsurances: arr }) }}>
|
||||
<option value="pension">按养老基数</option>
|
||||
<option value="medical">按医保基数</option>
|
||||
<option value="fixed">固定金额</option>
|
||||
</select>
|
||||
</div>
|
||||
{ins.baseType === 'fixed' ? (
|
||||
<>
|
||||
<div><Label>企业固定(元)</Label><Input type="number" value={ins.fixedAmount} onChange={(e) => { const arr = [...(newVersion.extraInsurances || [])]; arr[idx] = { ...ins, fixedAmount: Number(e.target.value) }; setNewVersion({ ...newVersion, extraInsurances: arr }) }} /></div>
|
||||
<div><Label>个人固定(元)</Label><Input type="number" value={ins.empFixedAmount} onChange={(e) => { const arr = [...(newVersion.extraInsurances || [])]; arr[idx] = { ...ins, empFixedAmount: Number(e.target.value) }; setNewVersion({ ...newVersion, extraInsurances: arr }) }} /></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div><Label>企业%</Label><Input type="number" step="0.01" value={ins.orgRate} onChange={(e) => { const arr = [...(newVersion.extraInsurances || [])]; arr[idx] = { ...ins, orgRate: Number(e.target.value) }; setNewVersion({ ...newVersion, extraInsurances: arr }) }} /></div>
|
||||
<div><Label>个人%</Label><Input type="number" step="0.01" value={ins.empRate} onChange={(e) => { const arr = [...(newVersion.extraInsurances || [])]; arr[idx] = { ...ins, empRate: Number(e.target.value) }; setNewVersion({ ...newVersion, extraInsurances: arr }) }} /></div>
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="text-xs text-danger h-9" onClick={() => { const arr = (newVersion.extraInsurances || []).filter((_: any, i: number) => i !== idx); setNewVersion({ ...newVersion, extraInsurances: arr }) }}>删除</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => createVersionMutation.mutate(newVersion)} disabled={createVersionMutation.isPending}>
|
||||
{createVersionMutation.isPending ? '保存中...' : '创建年度标准'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowNewVersion(false)}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 试算工具 */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<h2 className="text-sm font-medium mb-3">{isHousing ? '公积金' : '社保'}试算</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>缴费基数(月工资)</Label>
|
||||
<Input type="number" value={base} onChange={(e) => setBase(Number(e.target.value) || 0)} />
|
||||
</div>
|
||||
<Button onClick={() => calcMutate()} disabled={calcPending}>
|
||||
<Calculator className="w-4 h-4 mr-1" />
|
||||
{calcPending ? '计算中...' : '开始计算'}
|
||||
</Button>
|
||||
{config && (
|
||||
<div className="text-sm text-gray-400">
|
||||
当前配置:{config.city} | 基数范围 {fmt(config.baseMin)}~{fmt(config.baseMax)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="text-sm font-medium mb-3 flex items-center gap-2"><Calculator className="w-4 h-4" />计算结果</h2>
|
||||
{(() => {
|
||||
const r = calcResult
|
||||
if (!r) return <div className="text-gray-400 text-sm">点击「开始计算」查看结果</div>
|
||||
if (isHousing) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
缴费基数:<span className="text-gray-900 font-medium">¥{fmt(r.actualBase)}</span>
|
||||
{r.capped && <span className="text-warning ml-2">(已封顶)</span>}
|
||||
{r.floored && <span className="text-warning ml-2">(已保底)</span>}
|
||||
{r.configVersion && <span className="text-gray-400 ml-2">| 配置版本:{r.configVersion}</span>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between border-b pb-2 text-sm">
|
||||
<span className="text-gray-500">企业缴纳</span>
|
||||
<span className="font-medium text-danger">¥{fmt(r.housingOrg)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b pb-2 text-sm">
|
||||
<span className="text-gray-500">个人缴纳</span>
|
||||
<span className="font-medium text-warning">¥{fmt(r.housingEmp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">总费用</span>
|
||||
<span className="text-lg font-bold text-primary">¥{fmt(r.total)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
企业承担 ¥{fmt(r.housingOrg)} + 个人承担 ¥{fmt(r.housingEmp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
缴费基数:<span className="text-gray-900 font-medium">¥{fmt(r.actualBase)}</span>
|
||||
{r.capped && <span className="text-warning ml-2">(已封顶)</span>}
|
||||
{r.floored && <span className="text-warning ml-2">(已保底)</span>}
|
||||
{r.configVersion && <span className="text-gray-400 ml-2">| 配置版本:{r.configVersion}</span>}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs text-gray-500">
|
||||
<th className="py-1.5">险种</th>
|
||||
<th className="py-1.5 text-right">企业%</th>
|
||||
<th className="py-1.5 text-right">个人%</th>
|
||||
<th className="py-1.5 text-right">企业缴纳</th>
|
||||
<th className="py-1.5 text-right">个人缴纳</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{r.items?.map((item: any) => (
|
||||
<tr key={item.name} className="border-b last:border-0">
|
||||
<td className="py-1.5">{item.name}</td>
|
||||
<td className="py-1.5 text-right text-gray-500">{item.orgRate}%</td>
|
||||
<td className="py-1.5 text-right text-gray-500">{item.empRate}%</td>
|
||||
<td className="py-1.5 text-right">¥{fmt(item.orgAmount)}</td>
|
||||
<td className="py-1.5 text-right">¥{fmt(item.empAmount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 font-bold">
|
||||
<td className="py-2" colSpan={3}>合计</td>
|
||||
<td className="py-2 text-right text-danger">¥{fmt(r.totalOrg)}</td>
|
||||
<td className="py-2 text-right text-warning">¥{fmt(r.totalEmp)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">总费用</span>
|
||||
<span className="text-lg font-bold text-primary">¥{fmt(r.total)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
企业承担 ¥{fmt(r.totalOrg)} + 个人承担 ¥{fmt(r.totalEmp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 基数说明 */}
|
||||
<p className="text-sm text-gray-400">
|
||||
社保/公积金基数按上年度月均工资核定,每人不同,在员工基本信息中设置。比例和基数上下限按版本管理,通常每年7月调整。
|
||||
发薪批次计算时按批次月份自动匹配对应版本配置。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user