diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 789f0a7..c47125a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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) diff --git a/backend/src/routes/payroll2.routes.ts b/backend/src/routes/payroll2.routes.ts index 744757f..b2b9c25 100644 --- a/backend/src/routes/payroll2.routes.ts +++ b/backend/src/routes/payroll2.routes.ts @@ -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, diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts index 8722c16..e1cad0e 100644 --- a/backend/src/routes/roster.routes.ts +++ b/backend/src/routes/roster.routes.ts @@ -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 diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index c4c29cb..bcf5c3c 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -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', }, }) diff --git a/backend/src/services/payroll.service.ts b/backend/src/services/payroll.service.ts index c738641..a1c384c 100644 --- a/backend/src/services/payroll.service.ts +++ b/backend/src/services/payroll.service.ts @@ -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 + } + } } } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 0aacd5a..6c1c6fb 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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({ 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() { updateOrgMutation.mutate(data)} /> )} {activeSection === 'medical' && } - {activeSection === 'socialAccounts' && } {activeSection === 'import' && } {activeSection === 'export' && } diff --git a/frontend/src/pages/SocialInsurance.tsx b/frontend/src/pages/SocialInsurance.tsx index 9e8426c..7e81d48 100644 --- a/frontend/src/pages/SocialInsurance.tsx +++ b/frontend/src/pages/SocialInsurance.tsx @@ -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('北京') - const [selectedAccountId, setSelectedAccountId] = useState('') - 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(null) - const [editItems, setEditItems] = useState>({}) - const [editingId, setEditingId] = useState(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({ - 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({ - 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(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({ - 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({ - queryKey: ['housing-config', city], - queryFn: async () => { - return await socialInsuranceApi.housingConfig(city) - }, - }) - const { data: housingAllAccounts } = useQuery({ - 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({ - 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({ - 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({ - mutationFn: async () => { - return await socialInsuranceApi.calculate(base, city) - }, - }) - - const { data: housingResult, mutate: calcHousingMutate, isPending: housingCalcPending } = useMutation({ - 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({ - 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 (
@@ -412,22 +175,14 @@ export default function SocialInsurance() {

维护社保、公积金、商业保险缴费基数、版本及月度记录

-
- {(tab === 'social' || tab === 'housing') && ( - <> - - - - )} -
+ {(tab === 'social' || tab === 'housing') && ( + + )} - {/* Tab 切换 + 城市选择 */} + {/* Tab 切换 */}
{(['monthly', 'social', 'housing', 'enrollment', 'deduction'] as const).map((t) => ( ))} - {(tab === 'social' || tab === 'housing') && ( -
- - -
- )}
- {/* ========== 社保 / 公积金 Tab ========== */} + {/* ========== 社保 / 公积金 Tab:账户卡片列表 ========== */} {(tab === 'social' || tab === 'housing') && ( - (isHousing ? housingLoading : configLoading) ? ( -
加载中...
- ) : activeConfig ? ( - -
-
- 当前生效 - 生效月份:{activeConfig.effectiveFrom} - · {activeConfig.city} - {activeConfig.adjustmentDone && ( - 已调整员工基数 - )} -
-
- {activeConfig.adjustmentDone && ( - - )} - -
-
-
- - 批量调整用于每年7月统一调基。如需单独调整某员工基数,请前往「花名册」→ 点击员工 → 编辑 → 修改「社保缴费基数」/「公积金缴费基数」。 -
- {isHousing ? ( - <> - {(housingAllAccounts || []).length > 1 && ( -
- {(housingAllAccounts || []).map((a: any) => ( - - {a.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'} {a.housingOrg}%/{a.housingEmp}% - - ))} -
- )} -
-
账户类型{activeConfig.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}
-
缴费基数下限¥{fmt(activeConfig.baseMin)}
-
缴费基数上限¥{fmt(activeConfig.baseMax)}
-
公积金(企业){activeConfig.housingOrg}%
-
公积金(个人){activeConfig.housingEmp}%
+
+ {accounts.length === 0 ? ( + +
+ 暂无{tab === 'housing' ? '公积金' : '社保'}账户,点击右上角「新建账户」创建
- +
) : ( -
-
缴费基数下限¥{fmt(activeConfig.baseMin)}
-
缴费基数上限¥{fmt(activeConfig.baseMax)}
- {activeConfig.medicalBaseMin > 0 && ( -
医保基数下限¥{fmt(activeConfig.medicalBaseMin)}
- )} - {activeConfig.medicalBaseMax > 0 && ( -
医保基数上限¥{fmt(activeConfig.medicalBaseMax)}
- )} -
养老(企业/个人){activeConfig.pensionOrg}% / {activeConfig.pensionEmp}%
-
医疗(企业/个人){activeConfig.medicalOrg}% / {activeConfig.medicalEmp}%
-
失业(企业/个人){activeConfig.unemploymentOrg}% / {activeConfig.unemploymentEmp}%
-
工伤(企业){activeConfig.injuryOrg}%
-
生育(企业){activeConfig.maternityOrg}%
- {activeConfig.minWage > 0 && ( -
最低工资标准¥{fmt(activeConfig.minWage)}
- )} - {/* 最低工资快速设置(社保 Tab,当前配置区域可直接编辑) */} -
- 最低工资标准 -
- setMinWageInput(e.target.value)} - placeholder="0" - className="w-24 h-7 text-sm text-right" - /> - -
-
- {Array.isArray(activeConfig.extraInsurances) && activeConfig.extraInsurances.map((ins: any, idx: number) => ( -
- {ins.name}(企业/个人) - - {ins.baseType === 'fixed' - ? `¥${ins.fixedAmount} / ¥${ins.empFixedAmount || 0}` - : `${ins.orgRate}% / ${ins.empRate}%` + accounts.map((a: any) => ( + { setEditAccount(acc); setShowAccountForm(true) }} + onDelete={(acc) => deleteAccountMutation.mutate(acc.id)} + /> + )) + )} + + {/* 账户新建/编辑弹窗 */} + {showAccountForm && ( + { 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'] }) + }, } - -
- ))} -
- )} - - ) : ( -
该城市暂无{isHousing ? '公积金' : '社保'}配置,请点击「新建版本」创建
- ) - )} - - {/* 调整预览 */} - {(tab === 'social' || tab === 'housing') && showAdjust && adjustData && ( - -

- 员工{isHousing ? '公积金' : '社保'}基数调整 -

-
- -
- 按当前版本基数上下限(¥{fmt(adjustData.baseMin)} ~ ¥{fmt(adjustData.baseMax)})调整全部在职员工{isHousing ? '公积金' : '社保'}缴费基数。 - 建议基数=上年月均工资按上下限裁剪。您可逐行修改,也可点击「采用建议值」或「保持原基数」。确认后保存,此操作只能执行一次。 -
-
-
- - - 共 {adjustData.total} 名员工 -
-
- - - - - - - - - - - - - {adjustData.items.map((item: any) => { - const edit = editItems[item.employeeId] - const newBase = edit ?? item.suggestedBase - const changed = newBase !== item.oldBase - return ( - - - - - - - - - ) - })} - -
姓名部门上年月均{isHousing ? '公积金' : '社保'}基数(当前){isHousing ? '公积金' : '社保'}基数(建议){isHousing ? '公积金' : '社保'}基数(新)
{item.name}{item.department}¥{fmt(item.avgSalary)}¥{fmt(item.oldBase)}¥{fmt(item.suggestedBase)} - {editingId === item.employeeId ? ( - setEditItems({ ...editItems, [item.employeeId]: Number(e.target.value) || 0 })} - onBlur={() => setEditingId(null)} - autoFocus /> - ) : ( - setEditingId(item.employeeId)}> - ¥{fmt(newBase)} - - )} - {changed && } -
-
-
- - -
-
- )} - - {/* 版本历史 */} - {(tab === 'social' || tab === 'housing') && showVersions && ( - -

{isHousing ? '公积金' : '社保'}版本历史

- {!activeVersions || activeVersions.length === 0 ? ( -
暂无版本记录
- ) : ( -
- - - - - - - {isHousing && } - - - {isHousing ? ( - - ) : ( - <> - - - - )} - - - - - {activeVersions.map((v: any) => ( - - - - - {isHousing && } - - - {isHousing ? ( - - ) : ( - <> - - - - )} - - - ))} - -
生效月份失效月份城市账户类型基数下限基数上限公积金%养老%医疗%状态
{v.effectiveFrom}{v.effectiveTo || '—'}{v.city}{v.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}¥{fmt(v.baseMin)}¥{fmt(v.baseMax)}{v.housingOrg}/{v.housingEmp}{v.pensionOrg}/{v.pensionEmp}{v.medicalOrg}/{v.medicalEmp} - {v.isCurrent ? 当前 : 历史} -
-
- )} -
- )} - - {/* 新建版本 */} - {(tab === 'social' || tab === 'housing') && showNewVersion && ( - -

新建{isHousing ? '公积金' : '社保'}配置版本

-
-
- -
新版本生效后,当前版本将自动归档。发薪批次计算时按批次月份匹配对应版本的配置。通常每年7月调基时新建版本。
-
- -
-
activeSetNewVersion({ ...activeNewVersion, effectiveFrom: e.target.value })} />
-
- -

从所选账户继承

-
-
activeSetNewVersion({ ...activeNewVersion, baseMin: Number(e.target.value) })} />
-
activeSetNewVersion({ ...activeNewVersion, baseMax: Number(e.target.value) })} />
-
- {!isHousing && ( -
-
- - activeSetNewVersion({ ...activeNewVersion, medicalBaseMin: Number(e.target.value) })} /> -

填 0 时使用统一基数下限

-
-
- - activeSetNewVersion({ ...activeNewVersion, medicalBaseMax: Number(e.target.value) })} /> -

填 0 时使用统一基数上限

-
-
- )} - {!isHousing && ( -
-
- - activeSetNewVersion({ ...activeNewVersion, minWage: Number(e.target.value) })} placeholder="如 2420" /> -

当地月最低工资标准,填 0 不检查。实发低于此值时触发保护(递延扣款)

-
-
- )} - {isHousing ? ( - <> -
-
- - -
-
activeSetNewVersion({ ...activeNewVersion, housingOrg: Number(e.target.value) })} />
-
activeSetNewVersion({ ...activeNewVersion, housingEmp: Number(e.target.value) })} />
-
- - ) : ( -
-
activeSetNewVersion({ ...activeNewVersion, pensionOrg: Number(e.target.value) })} />
-
activeSetNewVersion({ ...activeNewVersion, pensionEmp: Number(e.target.value) })} />
-
activeSetNewVersion({ ...activeNewVersion, medicalOrg: Number(e.target.value) })} />
-
activeSetNewVersion({ ...activeNewVersion, medicalEmp: Number(e.target.value) })} />
-
activeSetNewVersion({ ...activeNewVersion, unemploymentOrg: Number(e.target.value) })} />
-
activeSetNewVersion({ ...activeNewVersion, unemploymentEmp: Number(e.target.value) })} />
-
activeSetNewVersion({ ...activeNewVersion, injuryOrg: Number(e.target.value) })} />
-
activeSetNewVersion({ ...activeNewVersion, maternityOrg: Number(e.target.value) })} />
-
- )} - {!isHousing && ( -
-
- 附加险种(大病险/长护险等) - -
- {(activeNewVersion.extraInsurances || []).map((ins: any, idx: number) => ( -
-
{ const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, name: e.target.value }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} />
-
- - -
- {ins.baseType === 'fixed' ? ( - <> -
{ const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, fixedAmount: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} />
-
{ const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, empFixedAmount: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} />
- - ) : ( - <> -
{ const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, orgRate: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} />
-
{ const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, empRate: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} />
- - )} - -
- ))} -
- )} -
- - -
-
-
- )} - - {/* 试算工具 */} - {(tab === 'social' || tab === 'housing') && ( -
- -

{isHousing ? '公积金' : '社保'}试算

-
-
- - setBase(Number(e.target.value) || 0)} /> -
- - {activeConfig && ( -
- 当前配置:{activeConfig.city} | 基数范围 {fmt(activeConfig.baseMin)}~{fmt(activeConfig.baseMax)} -
- )} -
-
- - -

计算结果

- {(() => { - const r = isHousing ? housingResult : result - if (!r) return
点击「开始计算」查看结果
- if (isHousing) { - return ( -
-
- 缴费基数:¥{fmt(r.actualBase)} - {r.capped && (已封顶)} - {r.floored && (已保底)} - {r.configVersion && | 配置版本:{r.configVersion}} -
-
-
- 企业缴纳 - ¥{fmt(r.housingOrg)} -
-
- 个人缴纳 - ¥{fmt(r.housingEmp)} -
-
-
-
- 总费用 - ¥{fmt(r.total)} -
-
- 企业承担 ¥{fmt(r.housingOrg)} + 个人承担 ¥{fmt(r.housingEmp)} -
-
-
) } - return ( -
-
- 缴费基数:¥{fmt(r.actualBase)} - {r.capped && (已封顶)} - {r.floored && (已保底)} - {r.configVersion && | 配置版本:{r.configVersion}} -
-
- - - - - - - - - - - - {r.items?.map((item: any) => ( - - - - - - - - ))} - - - - - - - - -
险种企业%个人%企业缴纳个人缴纳
{item.name}{item.orgRate}%{item.empRate}%¥{fmt(item.orgAmount)}¥{fmt(item.empAmount)}
合计¥{fmt(r.totalOrg)}¥{fmt(r.totalEmp)}
-
-
-
- 总费用 - ¥{fmt(r.total)} -
-
- 企业承担 ¥{fmt(r.totalOrg)} + 个人承担 ¥{fmt(r.totalEmp)} -
-
-
- ) - })()} -
+ }} + saving={createAccountMutation.isPending || updateAccountMutation.isPending} + /> + )}
)} - {/* 基数说明(仅社保/公积金Tab显示) */} - {(tab === 'social' || tab === 'housing') && ( -

- 社保/公积金基数按上年度月均工资核定,每人不同,在员工基本信息中设置。比例和基数上下限按版本管理,通常每年7月调整。 - 发薪批次计算时按批次月份自动匹配对应版本配置。 -

- )} - {/* ========== 月度办理 Tab ========== */} {tab === 'monthly' && (
diff --git a/frontend/src/pages/money/BatchTab.tsx b/frontend/src/pages/money/BatchTab.tsx index 11c366d..d7c248e 100644 --- a/frontend/src/pages/money/BatchTab.tsx +++ b/frontend/src/pages/money/BatchTab.tsx @@ -107,6 +107,8 @@ export function BatchManager() { const [filterType, setFilterType] = useState('') const [selectedBatchId, setSelectedBatchId] = useState(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('') @@ -260,6 +262,18 @@ export function BatchManager() {

创建发薪批次

+
+
+ + setCreateMonth(e.target.value)} /> +

决定调用哪个月的社保标准、个税累计

+
+
+ + setCreatePayMonth(e.target.value)} /> +

实际发放月份,留空=与所属月相同。如十一提前发薪:所属月=10月,发薪月=9月

+
+
@@ -307,7 +321,7 @@ export function BatchManager() {
handleHireDateChange(e.target.value)} />
+
setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" />
{ const phone = e.target.value.replace(/\D/g, '').slice(0, 11) diff --git a/frontend/src/pages/social-insurance/AccountCard.tsx b/frontend/src/pages/social-insurance/AccountCard.tsx new file mode 100644 index 0000000..b289f91 --- /dev/null +++ b/frontend/src/pages/social-insurance/AccountCard.tsx @@ -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(null) + const [editItems, setEditItems] = useState>({}) + const [editingId, setEditingId] = useState(null) + const [minWageInput, setMinWageInput] = useState('0') + const [base, setBase] = useState(8000) + + const [newVersion, setNewVersion] = useState({ + 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({ + queryKey: ['account-current-standard', account.id], + queryFn: () => socialAccountApi.currentStandard(account.id), + enabled: expanded, + }) + + // 查询版本历史(展开且显示历史时才查询) + const { data: versions } = useQuery({ + queryKey: ['account-standards', account.id], + queryFn: () => socialAccountApi.standards(account.id), + enabled: expanded && showVersions, + }) + + // 查询公积金所有账户当前配置(用于展示多账户概览) + const { data: housingAllAccounts } = useQuery({ + 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({ + 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({ + 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 ( +
+ {/* 卡片头部(点击展开/折叠) */} +
setExpanded(!expanded)} + > +
+ {expanded ? : } +
+ {account.name} + {account.isDefault && 默认} + {account.status === 'SUSPENDED' && 已停用} +
+
+ {account.city} + {account.accountNo && · {account.accountNo}} + · {deptCount}个部门 · {recordCount}条记录 +
+
+
e.stopPropagation()}> + {!account.isDefault && account.status === 'ACTIVE' && ( + + )} + + +
+
+ + {/* 展开内容 */} + {expanded && ( +
+ {/* 账户详情 */} +
+ {account.orgName &&
缴费主体:{account.orgName} {account.orgCode && `(${account.orgCode})`}
} + {isHousing && account.bankName &&
开户行:{account.bankName} {account.bankAccount && `· ${account.bankAccount}`}
} + {isHousing && account.accountType &&
账户类型:{account.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}
} +
+ + {/* 当前年度标准展示 */} + {configLoading ? ( +
加载中...
+ ) : config ? ( + +
+
+ 当前生效 + 生效月份:{config.effectiveFrom} + · {config.city} + {config.adjustmentDone && ( + 已调整员工基数 + )} +
+
+ {config.adjustmentDone && ( + + )} + +
+
+
+ + 批量调整用于每年7月统一调基。如需单独调整某员工基数,请前往「花名册」→ 点击员工 → 编辑 → 修改「社保缴费基数」/「公积金缴费基数」。 +
+ {isHousing ? ( + <> + {(housingAllAccounts || []).length > 1 && ( +
+ {(housingAllAccounts || []).map((a: any) => ( + + {a.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'} {a.housingOrg}%/{a.housingEmp}% + + ))} +
+ )} +
+
账户类型{config.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}
+
缴费基数下限¥{fmt(config.baseMin)}
+
缴费基数上限¥{fmt(config.baseMax)}
+
公积金(企业){config.housingOrg}%
+
公积金(个人){config.housingEmp}%
+
+ + ) : ( +
+
缴费基数下限¥{fmt(config.baseMin)}
+
缴费基数上限¥{fmt(config.baseMax)}
+ {config.medicalBaseMin > 0 && ( +
医保基数下限¥{fmt(config.medicalBaseMin)}
+ )} + {config.medicalBaseMax > 0 && ( +
医保基数上限¥{fmt(config.medicalBaseMax)}
+ )} +
养老(企业/个人){config.pensionOrg}% / {config.pensionEmp}%
+
医疗(企业/个人){config.medicalOrg}% / {config.medicalEmp}%
+
失业(企业/个人){config.unemploymentOrg}% / {config.unemploymentEmp}%
+
工伤(企业){config.injuryOrg}%
+
生育(企业){config.maternityOrg}%
+ {config.minWage > 0 && ( +
最低工资标准¥{fmt(config.minWage)}
+ )} + {/* 最低工资快速设置(仅社保) */} +
+ 最低工资标准 +
+ setMinWageInput(e.target.value)} + placeholder="0" + className="w-24 h-7 text-sm text-right" + /> + +
+
+ {Array.isArray(config.extraInsurances) && config.extraInsurances.map((ins: any, idx: number) => ( +
+ {ins.name}(企业/个人) + + {ins.baseType === 'fixed' + ? `¥${ins.fixedAmount} / ¥${ins.empFixedAmount || 0}` + : `${ins.orgRate}% / ${ins.empRate}%` + } + +
+ ))} +
+ )} +
+ ) : ( +
该账户暂无{isHousing ? '公积金' : '社保'}年度标准,请点击「新建年度标准」创建
+ )} + + {/* 操作按钮 */} +
+ + +
+ + {/* 调基预览 */} + {showAdjust && adjustData && ( + +

+ 员工{isHousing ? '公积金' : '社保'}基数调整 +

+
+ +
+ 按当前版本基数上下限(¥{fmt(adjustData.baseMin)} ~ ¥{fmt(adjustData.baseMax)})调整全部在职员工{isHousing ? '公积金' : '社保'}缴费基数。 + 建议基数=上年月均工资按上下限裁剪。您可逐行修改,也可点击「采用建议值」或「保持原基数」。确认后保存,此操作只能执行一次。 +
+
+
+ + + 共 {adjustData.total} 名员工 +
+
+ + + + + + + + + + + + + {adjustData.items.map((item: any) => { + const edit = editItems[item.employeeId] + const newBase = edit ?? item.suggestedBase + const changed = newBase !== item.oldBase + return ( + + + + + + + + + ) + })} + +
姓名部门上年月均{isHousing ? '公积金' : '社保'}基数(当前){isHousing ? '公积金' : '社保'}基数(建议){isHousing ? '公积金' : '社保'}基数(新)
{item.name}{item.department}¥{fmt(item.avgSalary)}¥{fmt(item.oldBase)}¥{fmt(item.suggestedBase)} + {editingId === item.employeeId ? ( + setEditItems({ ...editItems, [item.employeeId]: Number(e.target.value) || 0 })} + onBlur={() => setEditingId(null)} + autoFocus /> + ) : ( + setEditingId(item.employeeId)}> + ¥{fmt(newBase)} + + )} + {changed && } +
+
+
+ + +
+
+ )} + + {/* 版本历史 */} + {showVersions && ( + +

{isHousing ? '公积金' : '社保'}版本历史

+ {!versions || versions.length === 0 ? ( +
暂无版本记录
+ ) : ( +
+ + + + + + + {isHousing && } + + + {isHousing ? ( + + ) : ( + <> + + + + )} + + + + + {versions.map((v: any) => ( + + + + + {isHousing && } + + + {isHousing ? ( + + ) : ( + <> + + + + )} + + + ))} + +
生效月份失效月份城市账户类型基数下限基数上限公积金%养老%医疗%状态
{v.effectiveFrom}{v.effectiveTo || '—'}{v.city}{v.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}¥{fmt(v.baseMin)}¥{fmt(v.baseMax)}{v.housingOrg}/{v.housingEmp}{v.pensionOrg}/{v.pensionEmp}{v.medicalOrg}/{v.medicalEmp} + {v.isCurrent ? 当前 : 历史} +
+
+ )} +
+ )} + + {/* 新建年度标准 */} + {showNewVersion && ( + +

新建{isHousing ? '公积金' : '社保'}年度标准

+
+
+ +
新版本生效后,当前版本将自动归档。发薪批次计算时按批次月份匹配对应版本的配置。通常每年7月调基时新建版本。
+
+ +
+
setNewVersion({ ...newVersion, effectiveFrom: e.target.value })} />
+
+ +

从账户继承

+
+
setNewVersion({ ...newVersion, baseMin: Number(e.target.value) })} />
+
setNewVersion({ ...newVersion, baseMax: Number(e.target.value) })} />
+
+ {!isHousing && ( +
+
+ + setNewVersion({ ...newVersion, medicalBaseMin: Number(e.target.value) })} /> +

填 0 时使用统一基数下限

+
+
+ + setNewVersion({ ...newVersion, medicalBaseMax: Number(e.target.value) })} /> +

填 0 时使用统一基数上限

+
+
+ )} + {!isHousing && ( +
+
+ + setNewVersion({ ...newVersion, minWage: Number(e.target.value) })} placeholder="如 2420" /> +

当地月最低工资标准,填 0 不检查。实发低于此值时触发保护(递延扣款)

+
+
+ )} + {isHousing ? ( + <> +
+
+ + +
+
setNewVersion({ ...newVersion, housingOrg: Number(e.target.value) })} />
+
setNewVersion({ ...newVersion, housingEmp: Number(e.target.value) })} />
+
+ + ) : ( +
+
setNewVersion({ ...newVersion, pensionOrg: Number(e.target.value) })} />
+
setNewVersion({ ...newVersion, pensionEmp: Number(e.target.value) })} />
+
setNewVersion({ ...newVersion, medicalOrg: Number(e.target.value) })} />
+
setNewVersion({ ...newVersion, medicalEmp: Number(e.target.value) })} />
+
setNewVersion({ ...newVersion, unemploymentOrg: Number(e.target.value) })} />
+
setNewVersion({ ...newVersion, unemploymentEmp: Number(e.target.value) })} />
+
setNewVersion({ ...newVersion, injuryOrg: Number(e.target.value) })} />
+
setNewVersion({ ...newVersion, maternityOrg: Number(e.target.value) })} />
+
+ )} + {!isHousing && ( +
+
+ 附加险种(大病险/长护险等) + +
+ {(newVersion.extraInsurances || []).map((ins: any, idx: number) => ( +
+
{ const arr = [...(newVersion.extraInsurances || [])]; arr[idx] = { ...ins, name: e.target.value }; setNewVersion({ ...newVersion, extraInsurances: arr }) }} />
+
+ + +
+ {ins.baseType === 'fixed' ? ( + <> +
{ const arr = [...(newVersion.extraInsurances || [])]; arr[idx] = { ...ins, fixedAmount: Number(e.target.value) }; setNewVersion({ ...newVersion, extraInsurances: arr }) }} />
+
{ const arr = [...(newVersion.extraInsurances || [])]; arr[idx] = { ...ins, empFixedAmount: Number(e.target.value) }; setNewVersion({ ...newVersion, extraInsurances: arr }) }} />
+ + ) : ( + <> +
{ const arr = [...(newVersion.extraInsurances || [])]; arr[idx] = { ...ins, orgRate: Number(e.target.value) }; setNewVersion({ ...newVersion, extraInsurances: arr }) }} />
+
{ const arr = [...(newVersion.extraInsurances || [])]; arr[idx] = { ...ins, empRate: Number(e.target.value) }; setNewVersion({ ...newVersion, extraInsurances: arr }) }} />
+ + )} + +
+ ))} +
+ )} +
+ + +
+
+
+ )} + + {/* 试算工具 */} +
+ +

{isHousing ? '公积金' : '社保'}试算

+
+
+ + setBase(Number(e.target.value) || 0)} /> +
+ + {config && ( +
+ 当前配置:{config.city} | 基数范围 {fmt(config.baseMin)}~{fmt(config.baseMax)} +
+ )} +
+
+ + +

计算结果

+ {(() => { + const r = calcResult + if (!r) return
点击「开始计算」查看结果
+ if (isHousing) { + return ( +
+
+ 缴费基数:¥{fmt(r.actualBase)} + {r.capped && (已封顶)} + {r.floored && (已保底)} + {r.configVersion && | 配置版本:{r.configVersion}} +
+
+
+ 企业缴纳 + ¥{fmt(r.housingOrg)} +
+
+ 个人缴纳 + ¥{fmt(r.housingEmp)} +
+
+
+
+ 总费用 + ¥{fmt(r.total)} +
+
+ 企业承担 ¥{fmt(r.housingOrg)} + 个人承担 ¥{fmt(r.housingEmp)} +
+
+
+ ) + } + return ( +
+
+ 缴费基数:¥{fmt(r.actualBase)} + {r.capped && (已封顶)} + {r.floored && (已保底)} + {r.configVersion && | 配置版本:{r.configVersion}} +
+
+ + + + + + + + + + + + {r.items?.map((item: any) => ( + + + + + + + + ))} + + + + + + + + +
险种企业%个人%企业缴纳个人缴纳
{item.name}{item.orgRate}%{item.empRate}%¥{fmt(item.orgAmount)}¥{fmt(item.empAmount)}
合计¥{fmt(r.totalOrg)}¥{fmt(r.totalEmp)}
+
+
+
+ 总费用 + ¥{fmt(r.total)} +
+
+ 企业承担 ¥{fmt(r.totalOrg)} + 个人承担 ¥{fmt(r.totalEmp)} +
+
+
+ ) + })()} +
+
+ + {/* 基数说明 */} +

+ 社保/公积金基数按上年度月均工资核定,每人不同,在员工基本信息中设置。比例和基数上下限按版本管理,通常每年7月调整。 + 发薪批次计算时按批次月份自动匹配对应版本配置。 +

+
+ )} +
+ ) +}