feat: 社保配置回退SocialYearStandard、月度办理tab切换、员工参保筛选分页

- 后端:dotenv/config 加载 ENCRYPTION_KEY,修复薪资数据解密
- 后端:社保/公积金 calculate API 回退到 SocialYearStandard(通过默认账户)
- 后端:getSocialConfigByMonth/getHousingConfigByMonth 回退到 SocialYearStandard
- 后端:active-declaration 查询条件改为 lte,当月办理的增员也显示在在保列表
- 后端:employee-enrollment API 增加部门/参保状态筛选和分页
- 后端:updateEmployeeSchema 增加 baseSalary/performanceSalary 字段
- 后端:payroll2.routes 批次详情包含 contracts 合同类型
- 前端:月度办理社保/公积金改为 tab 切换(不再同时展开)
- 前端:员工参保列表增加筛选(部门/社保状态/公积金状态)和分页
- 前端:花名册基本信息增加月度工资只读显示(基本+绩效自动计算)
- 前端:薪资批次详情表增加合同类型列

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-19 10:13:37 +08:00
parent 20f920686e
commit 41be262dee
9 changed files with 395 additions and 72 deletions
+8 -3
View File
@@ -674,9 +674,14 @@ export const socialInsuranceApi = {
/** 公积金活跃申报 */
housingActiveDeclaration: (month: string) =>
get('/social/housing/active-declaration', { params: { month } }).then(unwrap<any>()),
/** 员工参保信息列表 */
employeeEnrollment: (keyword?: string) =>
get('/social/employee-enrollment', { params: keyword ? { keyword } : {} }).then(unwrap<any[]>()),
/** 员工参保信息列表(支持筛选和分页,返回 { data, total, page, pageSize } */
employeeEnrollment: (params?: { keyword?: string; department?: string; socialStatus?: string; housingStatus?: string; page?: number; pageSize?: number }) =>
get('/social/employee-enrollment', { params: params || {} }).then((res: any) => {
const body = res?.data ?? res
// 兼容旧格式(纯数组)和新格式({ data, total, page, pageSize }
if (Array.isArray(body)) return { data: body, total: body.length, page: 1, pageSize: body.length }
return { data: body.data || [], total: body.total ?? 0, page: body.page ?? 1, pageSize: body.pageSize ?? 20 }
}),
/** 办理社保增员(批量创建社保记录) */
enrollSocial: (employeeIds: string[], startMonth: string) =>
post('/social/enroll-social', { employeeIds, startMonth }).then(unwrap<any>()),
+63 -42
View File
@@ -27,6 +27,7 @@ export default function SocialInsurance() {
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 [monthlySubTab, setMonthlySubTab] = useState<'social' | 'housing'>('social')
// 账户管理相关 state
const [showAccountForm, setShowAccountForm] = useState(false)
const [editAccount, setEditAccount] = useState<any>(null)
@@ -495,52 +496,72 @@ export default function SocialInsurance() {
<div className="bg-gray-50 px-4 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded bg-indigo-50 text-indigo-600 text-xs font-medium">{city}</span>
<span className="text-gray-400 text-xs">{city}/</span>
<span className="text-gray-400 text-xs">{city}{monthlySubTab === 'social' ? '社保' : '公积金'}</span>
</div>
</div>
<div className="p-3 space-y-3">
<CollapsibleSection
title="社保"
icon={<Shield className="w-4 h-4 text-blue-500" />}
summary={`${sAddCity.length + sSubCity.length + sNormalCity.length} 人 | 企业 ¥${fmt([...sAddCity, ...sNormalCity].reduce((s: number, i: any) => s + (i.detail?.totalOrg || 0), 0))} + 个人 ¥${fmt([...sAddCity, ...sNormalCity].reduce((s: number, i: any) => s + (i.detail?.totalEmp || 0), 0))} = ¥${fmt(sTotal)}`}
defaultOpen={true}
action={sAddCity.filter((i: any) => i.changeType === 'PENDING').length > 0 ? (
<Button
size="sm"
variant="primary"
onClick={() => {
const pendingIds = sAddCity.filter((i: any) => i.changeType === 'PENDING').map((i: any) => i.employeeId)
enrollSocialMutation.mutate({ employeeIds: pendingIds, startMonth: monthlyMonth })
}}
disabled={enrollSocialMutation.isPending}
>
{enrollSocialMutation.isPending ? '办理中...' : `办理增员(${sAddCity.filter((i: any) => i.changeType === 'PENDING').length}人)`}
</Button>
) : undefined}
{/* 子 Tab 切换 */}
<div className="flex border-b px-4 pt-2 gap-1">
<button
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${monthlySubTab === 'social' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
onClick={() => setMonthlySubTab('social')}
>
{sTable}
</CollapsibleSection>
<CollapsibleSection
title="公积金"
icon={<Home className="w-4 h-4 text-green-500" />}
summary={`${hAddCity.length + hSubCity.length + hNormalCity.length} 人 | 企业 ¥${fmt([...hAddCity, ...hNormalCity].reduce((s: number, i: any) => s + (i.detail?.orgAmount || 0), 0))} + 个人 ¥${fmt([...hAddCity, ...hNormalCity].reduce((s: number, i: any) => s + (i.detail?.empAmount || 0), 0))} = ¥${fmt(hTotal)}`}
defaultOpen={true}
action={hAddCity.filter((i: any) => i.changeType === 'PENDING').length > 0 ? (
<Button
size="sm"
variant="primary"
onClick={() => {
const pendingIds = hAddCity.filter((i: any) => i.changeType === 'PENDING').map((i: any) => i.employeeId)
enrollHousingMutation.mutate({ employeeIds: pendingIds, startMonth: monthlyMonth })
}}
disabled={enrollHousingMutation.isPending}
>
{enrollHousingMutation.isPending ? '办理中...' : `办理增员(${hAddCity.filter((i: any) => i.changeType === 'PENDING').length}人)`}
</Button>
) : undefined}
<Shield className="w-4 h-4 inline mr-1 -mt-0.5" />
<span className="ml-1 text-xs text-gray-400">{sAddCity.length + sSubCity.length + sNormalCity.length}</span>
</button>
<button
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${monthlySubTab === 'housing' ? 'border-green-500 text-green-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
onClick={() => setMonthlySubTab('housing')}
>
{hTable}
</CollapsibleSection>
<Home className="w-4 h-4 inline mr-1 -mt-0.5" />
<span className="ml-1 text-xs text-gray-400">{hAddCity.length + hSubCity.length + hNormalCity.length}</span>
</button>
</div>
<div className="p-3">
{monthlySubTab === 'social' ? (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-xs text-gray-500">
¥{fmt([...sAddCity, ...sNormalCity].reduce((s: number, i: any) => s + (i.detail?.totalOrg || 0), 0))} + ¥{fmt([...sAddCity, ...sNormalCity].reduce((s: number, i: any) => s + (i.detail?.totalEmp || 0), 0))} = ¥{fmt(sTotal)}
</div>
{sAddCity.filter((i: any) => i.changeType === 'PENDING').length > 0 && (
<Button
size="sm"
variant="primary"
onClick={() => {
const pendingIds = sAddCity.filter((i: any) => i.changeType === 'PENDING').map((i: any) => i.employeeId)
enrollSocialMutation.mutate({ employeeIds: pendingIds, startMonth: monthlyMonth })
}}
disabled={enrollSocialMutation.isPending}
>
{enrollSocialMutation.isPending ? '办理中...' : `办理增员(${sAddCity.filter((i: any) => i.changeType === 'PENDING').length}人)`}
</Button>
)}
</div>
{sTable}
</div>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-xs text-gray-500">
¥{fmt([...hAddCity, ...hNormalCity].reduce((s: number, i: any) => s + (i.detail?.orgAmount || 0), 0))} + ¥{fmt([...hAddCity, ...hNormalCity].reduce((s: number, i: any) => s + (i.detail?.empAmount || 0), 0))} = ¥{fmt(hTotal)}
</div>
{hAddCity.filter((i: any) => i.changeType === 'PENDING').length > 0 && (
<Button
size="sm"
variant="primary"
onClick={() => {
const pendingIds = hAddCity.filter((i: any) => i.changeType === 'PENDING').map((i: any) => i.employeeId)
enrollHousingMutation.mutate({ employeeIds: pendingIds, startMonth: monthlyMonth })
}}
disabled={enrollHousingMutation.isPending}
>
{enrollHousingMutation.isPending ? '办理中...' : `办理增员(${hAddCity.filter((i: any) => i.changeType === 'PENDING').length}人)`}
</Button>
)}
</div>
{hTable}
</div>
)}
</div>
</div>
)
+6
View File
@@ -362,6 +362,12 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
setForm({ ...form, performanceSalary: e.target.value, monthlySalary: base + perf })
}} placeholder="可为0" />
</div>
<div>
<Label></Label>
<div className="px-3 py-2 rounded-md bg-gray-50 text-sm text-gray-600 border border-gray-200">
¥{fmt(Number(form.monthlySalary) || 0)} <span className="text-xs text-gray-400"> + </span>
</div>
</div>
<div><Label></Label><Input value={form.emergencyContact} onChange={(e) => setForm({ ...form, emergencyContact: e.target.value })} placeholder="选填" /></div>
<div><Label></Label><Input value={form.emergencyPhone} onChange={(e) => setForm({ ...form, emergencyPhone: e.target.value })} placeholder="选填" /></div>
<div className="md:col-span-2"><Label></Label><Input value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} placeholder="选填" /></div>
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Search, CheckCircle, XCircle } from 'lucide-react'
import { socialInsuranceApi } from '../../lib/api-services'
import { Search, CheckCircle, XCircle, ChevronLeft, ChevronRight } from 'lucide-react'
import { socialInsuranceApi, rosterApi } from '../../lib/api-services'
import { Input } from '../../components/ui/Input'
import { useDebouncedValue } from '../../hooks/useDebouncedValue'
@@ -10,34 +10,107 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
export default function EmployeeEnrollmentTab() {
const [keyword, setKeyword] = useState('')
const debouncedSearch = useDebouncedValue(keyword, 300)
const [department, setDepartment] = useState('')
const [socialStatus, setSocialStatus] = useState('')
const [housingStatus, setHousingStatus] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const { data: list = [], isLoading } = useQuery<any[]>({
queryKey: ['social-employee-enrollment', debouncedSearch],
queryFn: () => socialInsuranceApi.employeeEnrollment(debouncedSearch || undefined),
const params = useMemo(() => ({
keyword: debouncedSearch || undefined,
department: department || undefined,
socialStatus: socialStatus || undefined,
housingStatus: housingStatus || undefined,
page,
pageSize,
}), [debouncedSearch, department, socialStatus, housingStatus, page, pageSize])
const { data: result, isLoading } = useQuery<any>({
queryKey: ['social-employee-enrollment', params],
queryFn: () => socialInsuranceApi.employeeEnrollment(params),
placeholderData: (prev: any) => prev,
})
const insuredCount = list.filter(e => e.socialInsStatus === 'INSURED').length
const housingCount = list.filter(e => e.housingFundStatus === 'INSURED').length
const { data: departments = [] } = useQuery<string[]>({
queryKey: ['roster-departments'],
queryFn: () => rosterApi.departments(),
})
const list = result?.data || []
const total = result?.total || 0
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const insuredCount = list.filter((e: any) => e.socialInsStatus === 'INSURED').length
const housingCount = list.filter((e: any) => e.housingFundStatus === 'INSURED').length
// 筛选变化时重置到第一页
const handleFilterChange = (setter: (v: string) => void) => (v: string) => {
setter(v)
setPage(1)
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 text-sm">
<span className="text-gray-500"> {list.length} </span>
<span className="text-gray-500"> <span className="font-medium text-primary">{insuredCount}</span></span>
<span className="text-gray-500"> <span className="font-medium text-primary">{housingCount}</span></span>
</div>
{/* 筛选栏 */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative w-48">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onChange={(e) => { setKeyword(e.target.value); setPage(1) }}
placeholder="搜索员工姓名"
className="pl-9"
/>
</div>
<select
value={department}
onChange={(e) => handleFilterChange(setDepartment)(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value=""></option>
{departments.map((d: string) => (
<option key={d} value={d}>{d}</option>
))}
</select>
<select
value={socialStatus}
onChange={(e) => handleFilterChange(setSocialStatus)(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value=""></option>
<option value="INSURED"></option>
<option value="UNINSURED"></option>
</select>
<select
value={housingStatus}
onChange={(e) => handleFilterChange(setHousingStatus)(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value=""></option>
<option value="INSURED"></option>
<option value="UNINSURED"></option>
</select>
<div className="ml-auto flex items-center gap-2 text-sm">
<select
value={pageSize}
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1) }}
className="px-2 py-1 text-sm border rounded-md bg-white focus:outline-none"
>
<option value={20}>20 /</option>
<option value={50}>50 /</option>
<option value={100}>100 /</option>
</select>
</div>
</div>
{/* 统计 */}
<div className="flex items-center gap-4 text-sm">
<span className="text-gray-500"> <span className="font-medium text-gray-700">{total}</span> </span>
<span className="text-gray-500"> <span className="font-medium text-primary">{insuredCount}</span></span>
<span className="text-gray-500"> <span className="font-medium text-primary">{housingCount}</span></span>
</div>
{/* 表格 */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
@@ -59,7 +132,7 @@ export default function EmployeeEnrollmentTab() {
{isLoading ? (
<tr><td colSpan={11} className="py-8 text-center text-gray-400">...</td></tr>
) : list.length === 0 ? (
<tr><td colSpan={11} className="py-8 text-center text-gray-400"></td></tr>
<tr><td colSpan={11} className="py-8 text-center text-gray-400"></td></tr>
) : list.map((emp: any) => (
<tr key={emp.id} className="border-b hover:bg-gray-50">
<td className="py-2 pr-4 font-medium">{emp.name}</td>
@@ -98,6 +171,32 @@ export default function EmployeeEnrollmentTab() {
</tbody>
</table>
</div>
{/* 分页 */}
{total > 0 && (
<div className="flex items-center justify-between pt-2">
<span className="text-xs text-gray-500">
{(page - 1) * pageSize + 1}-{Math.min(page * pageSize, total)} {total}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="p-1.5 rounded border disabled:opacity-40 disabled:cursor-not-allowed hover:bg-gray-50"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm text-gray-600">{page} / {totalPages}</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="p-1.5 rounded border disabled:opacity-40 disabled:cursor-not-allowed hover:bg-gray-50"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
)
}