feat: Phase 1-3 优化全部完成

Phase 1 紧急修复(8项):
- 社保城市选择改为可输入
- 社保上下限拆分(三险/医保独立基数)
- 公积金试算结果展示修复
- 花名册合同保存修复(日期ISO格式)
- 薪酬批次创建失败修复(城市过滤+错误处理)
- 证据链查看修复
- 个税计算修复(blank_employees读取基本工资)
- 加班费倍率读取配置

Phase 2 功能完善(3项):
- 批量导入per-row异常捕获+导入按钮
- 单人发薪UI入口优化
- 解除协议模板补充(员工提出离职版)

Phase 3 后期规划(4项):
- 工资表导入功能(POST /import/payroll + 前端入口)
- 大病险/长护险附加险种(extraInsurances JSON + 计算适配)
- 专项附加扣除按月录入(SpecialDeductionRecord模型 + 前端Tab)
- 预置河北省社保政策(seed数据)
This commit is contained in:
selfrelease
2026-07-27 18:55:08 +08:00
parent 034fcc4111
commit 255af519d2
16 changed files with 1464 additions and 77 deletions
+282 -12
View File
@@ -14,9 +14,10 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
export default function SocialInsurance() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [tab, setTab] = useState<'monthly' | 'social' | 'housing'>('monthly')
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction'>('monthly')
const [city, setCity] = useState<string>('北京')
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)
@@ -34,6 +35,8 @@ export default function SocialInsurance() {
unemploymentOrg: 0.5, unemploymentEmp: 0.5,
injuryOrg: 0.2, maternityOrg: 0.8,
baseMin: 6326, baseMax: 33891,
medicalBaseMin: 0, medicalBaseMax: 0,
extraInsurances: [],
})
const [newHousingVersion, setNewHousingVersion] = useState<any>({
effectiveFrom: new Date().toISOString().slice(0, 7),
@@ -320,7 +323,7 @@ export default function SocialInsurance() {
{/* Tab 切换 + 城市选择 */}
<div className="flex items-center gap-4 border-b">
{(['monthly', 'social', 'housing'] as const).map((t) => (
{(['monthly', 'social', 'housing', 'deduction'] as const).map((t) => (
<button
key={t}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
@@ -328,23 +331,22 @@ export default function SocialInsurance() {
}`}
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null); setMonthlyProcessed(false); setProcessStatus(null) }}
>
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : '公积金'}
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : '专项附加扣除'}
</button>
))}
{tab !== 'monthly' && (
<div className="flex items-center gap-2 ml-auto">
<label className="text-sm text-gray-500">:</label>
<select
<input
list="social-cities"
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={city}
onChange={(e) => setCity(e.target.value)}
>
{cities.length > 0 ? (
cities.map((c) => <option key={c} value={c}>{c}</option>)
) : (
<option value="北京"></option>
)}
</select>
placeholder="输入或选择城市"
/>
<datalist id="social-cities">
{cities.map((c) => <option key={c} value={c} />)}
</datalist>
</div>
)}
</div>
@@ -402,6 +404,12 @@ export default function SocialInsurance() {
<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>
@@ -571,6 +579,20 @@ export default function SocialInsurance() {
<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-2 gap-3">
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingOrg: Number(e.target.value) })} /></div>
@@ -588,6 +610,48 @@ export default function SocialInsurance() {
<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 ? '保存中...' : '创建版本'}
@@ -625,6 +689,37 @@ export default function SocialInsurance() {
{(() => {
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">
@@ -645,7 +740,7 @@ export default function SocialInsurance() {
</tr>
</thead>
<tbody>
{r.items.map((item: any) => (
{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>
@@ -904,6 +999,11 @@ export default function SocialInsurance() {
</Card>
)}
{/* ========== 专项附加扣除 Tab ========== */}
{tab === 'deduction' && (
<SpecialDeductionTab month={deductionMonth} setMonth={setDeductionMonth} />
)}
<p className="text-sm text-gray-400">
/7
@@ -980,3 +1080,173 @@ function MonthlyHousingRow({ item: i, type }: { item: any; type: 'add' | 'sub' |
</tr>
)
}
/** 专项附加扣除按月录入组件 */
function SpecialDeductionTab({ month, setMonth }: { month: string; setMonth: (m: string) => void }) {
const queryClient = useQueryClient()
const [editing, setEditing] = useState<string | null>(null)
const [editForm, setEditForm] = useState<any>(null)
// 查询当月所有员工的专项附加扣除
const { data: records = [], isLoading } = useQuery<any[]>({
queryKey: ['special-deduction', month],
queryFn: async () => {
const res = await api.get('/social/special-deduction/batch', { params: { month } }) as any
return res.data
},
})
// 查询所有员工列表(用于添加未录入的员工)
const { data: employees = [] } = useQuery<any[]>({
queryKey: ['employees-for-deduction'],
queryFn: async () => {
const res = await api.get('/roster', { params: { pageSize: 999 } }) as any
return res.data?.items || res.data || []
},
})
const saveMutation = useMutation({
mutationFn: (data: any) => api.post('/social/special-deduction', { ...data, month }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['special-deduction'] })
setEditing(null)
setEditForm(null)
},
})
const recordMap = new Map(records.map((r: any) => [r.employeeId, r]))
const unrecorded = employees.filter((e: any) => !recordMap.has(e.id))
const startEdit = (empId: string, existing?: any) => {
setEditing(empId)
setEditForm(existing ? {
children: existing.children,
elderly: existing.elderly,
housing: existing.housing,
education: existing.education,
infant: existing.infant,
remark: existing.remark,
} : { children: 0, elderly: 0, housing: 0, education: 0, infant: 0, remark: '' })
}
const calcTotal = (f: any) => (f.children || 0) + (f.elderly || 0) + (f.housing || 0) + (f.education || 0) + (f.infant || 0)
return (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"> {month}</h2>
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-32" />
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : (
<div className="space-y-3">
{/* 已录入列表 */}
{records.length > 0 && (
<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-2 font-medium"></th>
<th className="py-2 font-medium"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium"></th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{records.map((r: any) => (
<tr key={r.id} className="border-b last:border-0 hover:bg-gray-50">
{editing === r.employeeId ? (
<>
<td className="py-1.5">{r.employee?.name}</td>
<td className="py-1.5 text-gray-500">{r.employee?.department}</td>
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.children} onChange={(e) => setEditForm({ ...editForm, children: Number(e.target.value) })} /></td>
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.elderly} onChange={(e) => setEditForm({ ...editForm, elderly: Number(e.target.value) })} /></td>
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.housing} onChange={(e) => setEditForm({ ...editForm, housing: Number(e.target.value) })} /></td>
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.education} onChange={(e) => setEditForm({ ...editForm, education: Number(e.target.value) })} /></td>
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.infant} onChange={(e) => setEditForm({ ...editForm, infant: Number(e.target.value) })} /></td>
<td className="py-1.5 text-right font-medium text-primary">¥{fmt(calcTotal(editForm))}</td>
<td className="py-1"><Input className="!w-24 !h-8" value={editForm.remark || ''} onChange={(e) => setEditForm({ ...editForm, remark: e.target.value })} /></td>
<td className="py-1">
<div className="flex gap-1">
<Button size="sm" className="!h-7 !px-2" onClick={() => saveMutation.mutate({ employeeId: r.employeeId, ...editForm })} disabled={saveMutation.isPending}></Button>
<Button size="sm" variant="secondary" className="!h-7 !px-2" onClick={() => { setEditing(null); setEditForm(null) }}></Button>
</div>
</td>
</>
) : (
<>
<td className="py-1.5">{r.employee?.name}</td>
<td className="py-1.5 text-gray-500">{r.employee?.department}</td>
<td className="py-1.5 text-right">{r.children > 0 ? `¥${fmt(r.children)}` : '-'}</td>
<td className="py-1.5 text-right">{r.elderly > 0 ? `¥${fmt(r.elderly)}` : '-'}</td>
<td className="py-1.5 text-right">{r.housing > 0 ? `¥${fmt(r.housing)}` : '-'}</td>
<td className="py-1.5 text-right">{r.education > 0 ? `¥${fmt(r.education)}` : '-'}</td>
<td className="py-1.5 text-right">{r.infant > 0 ? `¥${fmt(r.infant)}` : '-'}</td>
<td className="py-1.5 text-right font-medium text-primary">¥{fmt(r.amount)}</td>
<td className="py-1.5 text-gray-400 text-xs">{r.remark || '-'}</td>
<td className="py-1.5"><button className="text-xs text-primary hover:underline" onClick={() => startEdit(r.employeeId, r)}></button></td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 未录入员工 */}
{unrecorded.length > 0 && (
<div className="border-t pt-3">
<h3 className="text-xs font-medium text-gray-500 mb-2">{unrecorded.length}</h3>
<div className="flex flex-wrap gap-2">
{unrecorded.map((e: any) => (
<button
key={e.id}
className="px-2 py-1 rounded-md border border-gray-200 text-xs text-gray-600 hover:border-primary hover:text-primary"
onClick={() => startEdit(e.id)}
>
{e.name}{e.department}
</button>
))}
</div>
</div>
)}
{/* 新增/编辑表单 */}
{editing && !recordMap.has(editing) && (
<div className="border rounded-md p-3 bg-gray-50 space-y-2">
<h3 className="text-xs font-medium"> {employees.find((e: any) => e.id === editing)?.name}</h3>
<div className="grid grid-cols-5 gap-2">
<div><Label></Label><Input type="number" value={editForm.children} onChange={(e) => setEditForm({ ...editForm, children: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={editForm.elderly} onChange={(e) => setEditForm({ ...editForm, elderly: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={editForm.housing} onChange={(e) => setEditForm({ ...editForm, housing: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={editForm.education} onChange={(e) => setEditForm({ ...editForm, education: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={editForm.infant} onChange={(e) => setEditForm({ ...editForm, infant: Number(e.target.value) })} /></div>
</div>
<div className="flex items-center gap-3">
<div className="flex-1"><Label></Label><Input value={editForm.remark} onChange={(e) => setEditForm({ ...editForm, remark: e.target.value })} /></div>
<div className="text-sm text-gray-500 pt-5"><span className="font-medium text-primary">¥{fmt(calcTotal(editForm))}</span></div>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={() => saveMutation.mutate({ employeeId: editing, ...editForm })} disabled={saveMutation.isPending}></Button>
<Button size="sm" variant="secondary" onClick={() => { setEditing(null); setEditForm(null) }}></Button>
</div>
</div>
)}
{records.length === 0 && unrecorded.length === 0 && (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
)}
</Card>
)
}