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:
selfrelease
2026-08-16 15:05:06 +08:00
parent a5911d1874
commit 8391c123fc
10 changed files with 985 additions and 849 deletions
+3 -1
View File
@@ -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)
+5 -1
View File
@@ -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,
+25 -3
View File
@@ -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
+1
View File
@@ -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',
},
})
+51 -30
View File
@@ -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
}
}
}
}
+2 -4
View File
@@ -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>
+81 -808
View File
@@ -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">
+15 -1
View File
@@ -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 ? '创建中...' : '确认创建'}
+3 -1
View File
@@ -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>
)
}