Files
RiskAgent/web/src/pages/RateManagement.tsx
T
freedakgmail c670b9e454 外包风险评估系统:领域引擎+前端+服务端持久化与生产部署
- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解
- 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护)
- 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理
- 专业图标体系替换全部emoji、看板美化
- 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC
- 444单测+204前端测试+51 e2e
2026-06-13 01:06:39 +08:00

353 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 费率管理页面 — 地域费率套维护(与评估引擎对齐)。
*
* 专业设计:
* - 以「地域费率套」为单位维护(一套 = 五险单位费率 + 公积金 + 增值税 + 附加税)
* - 展示引擎内置默认费率(全国/上海/北京/广东)作为对照基准
* - 与默认对比:偏离默认值的项高亮
* - 复核流程:编辑即重置待复核,复核后驱动评估盈利测算
* - 实时显示社保单位合计、用工成本加载估算
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { colorVar, FONT_FAMILY, RADIUS, space, typographyStyle } from '../design-system/components/styles.js';
import { Card, Icon } from '../design-system/index.js';
import {
fetchEngineDefaults,
fetchRegionRates,
saveRegionRate,
reviewRegionRate,
deleteRegionRate,
fetchMinWages,
saveMinWage,
deleteMinWageApi,
type RegionRates,
type RegionRateRecord,
type MinWageItem,
} from '../api/client.js';
import { useAuthStore } from '../stores/authStore.js';
/** 费率项定义(路径 + 标签 + 分组 + 单位/个人说明)。 */
const RATE_FIELDS: ReadonlyArray<{ path: string; label: string; group: string; hint?: string }> = [
{ path: 'socialInsurance.pension', label: '养老保险(单位)', group: '社会保险(单位部分)', hint: '通常 14%~16%' },
{ path: 'socialInsurance.medical', label: '医疗保险(单位)', group: '社会保险(单位部分)', hint: '通常 4.5%~10%' },
{ path: 'socialInsurance.unemployment', label: '失业保险(单位)', group: '社会保险(单位部分)', hint: '通常 0.5%~0.8%' },
{ path: 'socialInsurance.injury', label: '工伤保险(单位)', group: '社会保险(单位部分)', hint: '按行业风险 0.16%~1.9%' },
{ path: 'socialInsurance.maternity', label: '生育保险(单位)', group: '社会保险(单位部分)', hint: '与医疗并轨地区填 0' },
{ path: 'housingFund', label: '住房公积金(单位)', group: '公积金', hint: '5%~12%,按属地' },
{ path: 'vatGeneralRate', label: '增值税(一般计税)', group: '税率', hint: '现代服务业 6%' },
{ path: 'vatSimplifiedRate', label: '增值税(简易/差额)', group: '税率', hint: '劳务派遣差额 5%' },
{ path: 'surchargeRate', label: '附加税费(占增值税)', group: '税率', hint: '城建+教育附加,约 12%' },
];
const REGIONS = ['全国默认', '上海', '北京', '广东', '深圳', '江苏', '浙江', '河北', '四川', '重庆', '湖北', '天津'];
function getPath(obj: RegionRates, path: string): number {
const parts = path.split('.');
let cur: unknown = obj;
for (const p of parts) cur = (cur as Record<string, unknown>)?.[p];
return typeof cur === 'number' ? cur : 0;
}
function setPath(obj: RegionRates, path: string, value: number): RegionRates {
const clone: RegionRates = JSON.parse(JSON.stringify(obj));
const parts = path.split('.');
let cur: Record<string, unknown> = clone as unknown as Record<string, unknown>;
for (let i = 0; i < parts.length - 1; i += 1) cur = cur[parts[i]!] as Record<string, unknown>;
cur[parts[parts.length - 1]!] = value;
return clone;
}
function emptyRates(regionName: string): RegionRates {
return {
regionName,
socialInsurance: { pension: 0, medical: 0, unemployment: 0, injury: 0, maternity: 0 },
housingFund: 0, vatGeneralRate: 0.06, vatSimplifiedRate: 0.05, surchargeRate: 0.12,
};
}
function socialTotal(r: RegionRates): number {
const s = r.socialInsurance;
return s.pension + s.medical + s.unemployment + s.injury + s.maternity;
}
/** 全成本加载系数估算(应发=1,加社保+公积金)。 */
function loadingFactor(r: RegionRates): number {
return 1 + socialTotal(r) + r.housingFund;
}
export function RateManagement(): JSX.Element {
const { user } = useAuthStore();
const [defaults, setDefaults] = useState<{ national: RegionRates; regions: Record<string, RegionRates> } | null>(null);
const [records, setRecords] = useState<RegionRateRecord[]>([]);
const [loading, setLoading] = useState(true);
const [editRegion, setEditRegion] = useState('');
const [editRates, setEditRates] = useState<RegionRates | null>(null);
const load = useCallback(() => {
setLoading(true);
Promise.all([fetchEngineDefaults(), fetchRegionRates()])
.then(([d, r]) => { setDefaults(d); setRecords(r); })
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
// 当前编辑地域的"引擎默认"基准(用于对比高亮)
const baseline = useMemo<RegionRates | null>(() => {
if (defaults === null || editRegion === '') return null;
const key = ['上海', '北京', '广东'].find((k) => editRegion.includes(k));
return key ? (defaults.regions[key] ?? defaults.national) : defaults.national;
}, [defaults, editRegion]);
function startEdit(region: string): void {
const existing = records.find((r) => r.region === region);
if (existing) {
setEditRates(existing.rates);
} else {
// 以引擎默认为初始值
const key = ['上海', '北京', '广东'].find((k) => region.includes(k));
const init = defaults ? (key ? (defaults.regions[key] ?? defaults.national) : defaults.national) : emptyRates(region);
setEditRates({ ...JSON.parse(JSON.stringify(init)), regionName: region });
}
setEditRegion(region);
}
async function handleSave(): Promise<void> {
if (editRates === null || editRegion === '') return;
await saveRegionRate(editRegion, { ...editRates, regionName: editRegion }, user?.username);
setEditRegion('');
setEditRates(null);
load();
}
const inputStyle: React.CSSProperties = {
padding: `${space(1)}px ${space(2)}px`,
border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.sm}px`,
fontFamily: FONT_FAMILY, ...typographyStyle('body'),
backgroundColor: colorVar('color.bg.canvas'), color: colorVar('color.text.primary'),
width: 90, textAlign: 'right',
};
const groups = [...new Set(RATE_FIELDS.map((f) => f.group))];
return (
<div style={{ fontFamily: FONT_FAMILY, maxWidth: 1200, margin: '0 auto' }}>
<div style={{ marginBottom: `${space(4)}px` }}>
<h1 style={{ ...typographyStyle('heading'), color: colorVar('color.text.primary'), margin: 0 }}></h1>
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), margin: `${space(1)}px 0 0` }}>
<strong></strong>
</p>
</div>
{loading ? <p style={{ color: colorVar('color.text.secondary') }}></p> : (
<>
{/* 最低工资标准(驱动"低于最低工资"红线) */}
<div style={{ marginBottom: `${space(4)}px` }}>
<MinWagePanel />
</div>
{/* 已维护的地域费率套 */}
<Card title={`已维护地域费率套(${records.length}`}>
{records.length === 0 ? (
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
使
</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>
{['地域', '社保单位合计', '公积金', '增值税(一般)', '附加税', '加载系数', '复核状态', '更新', '操作'].map((h) => (
<th key={h} style={{ textAlign: 'left', padding: `${space(2)}px`, borderBottom: `2px solid ${colorVar('color.border.default')}`, color: colorVar('color.text.secondary'), ...typographyStyle('caption'), fontWeight: 700 }}>{h}</th>
))}
</tr></thead>
<tbody>
{records.map((rec) => (
<tr key={rec.region}>
<td style={tdStyle}>{rec.region}</td>
<td style={tdStyle}>{(socialTotal(rec.rates) * 100).toFixed(2)}%</td>
<td style={tdStyle}>{(rec.rates.housingFund * 100).toFixed(1)}%</td>
<td style={tdStyle}>{(rec.rates.vatGeneralRate * 100).toFixed(1)}%</td>
<td style={tdStyle}>{(rec.rates.surchargeRate * 100).toFixed(0)}%</td>
<td style={{ ...tdStyle, fontWeight: 700, color: colorVar('color.brand.primary') }}>{loadingFactor(rec.rates).toFixed(3)}×</td>
<td style={tdStyle}>{rec.reviewed ? <span style={{ color: '#15803D', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="check-circle" size={14} /> </span> : <span style={{ color: '#B45309', display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="clock" size={14} /> </span>}</td>
<td style={{ ...tdStyle, ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{rec.updatedBy ?? '—'}</td>
<td style={tdStyle}>
<div style={{ display: 'flex', gap: `${space(2)}px` }}>
<button onClick={() => startEdit(rec.region)} style={linkBtn(colorVar('color.brand.primary'))}></button>
{!rec.reviewed && <button onClick={() => reviewRegionRate(rec.region).then(load)} style={linkBtn('#15803D')}></button>}
<button onClick={() => deleteRegionRate(rec.region).then(load)} style={linkBtn(colorVar('color.risk.critical'))}></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div style={{ marginTop: `${space(3)}px`, display: 'flex', gap: `${space(2)}px`, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>/</span>
{REGIONS.filter((r) => !records.some((rec) => rec.region === r)).map((r) => (
<button key={r} onClick={() => startEdit(r)} style={{ padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.md}px`, border: `1px dashed ${colorVar('color.brand.primary')}`, background: 'transparent', color: colorVar('color.brand.primary'), cursor: 'pointer', ...typographyStyle('caption') }}>+ {r}</button>
))}
</div>
</Card>
{/* 编辑表单 */}
{editRates !== null && (
<div style={{ marginTop: `${space(4)}px` }}>
<Card title={`维护「${editRegion}」费率套`}>
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), margin: `0 0 ${space(3)}px` }}>
0.16 16%<span style={{ color: '#B45309' }}></span>
</p>
{groups.map((g) => (
<div key={g} style={{ marginBottom: `${space(3)}px` }}>
<div style={{ ...typographyStyle('caption'), fontWeight: 700, color: colorVar('color.text.secondary'), marginBottom: `${space(1)}px` }}>{g}</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: `${space(2)}px` }}>
{RATE_FIELDS.filter((f) => f.group === g).map((f) => {
const val = getPath(editRates, f.path);
const baseVal = baseline ? getPath(baseline, f.path) : val;
const deviated = Math.abs(val - baseVal) > 1e-6;
return (
<div key={f.path} style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<label style={{ ...typographyStyle('caption'), color: colorVar('color.text.primary') }}>{f.label}</label>
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(1)}px` }}>
<input
style={{ ...inputStyle, borderColor: deviated ? '#B45309' : colorVar('color.border.default'), color: deviated ? '#B45309' : colorVar('color.text.primary'), fontWeight: deviated ? 700 : 400 }}
value={val}
onChange={(e) => setEditRates((r) => r ? setPath(r, f.path, Number(e.target.value) || 0) : r)}
inputMode="decimal"
/>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{(val * 100).toFixed(2)}%</span>
</div>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), fontSize: '11px' }}>
{f.hint}{deviated ? ` · 默认 ${(baseVal * 100).toFixed(2)}%` : ''}
</span>
</div>
);
})}
</div>
</div>
))}
{/* 汇总 */}
<div style={{ display: 'flex', gap: `${space(4)}px`, padding: `${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px`, marginBottom: `${space(3)}px`, flexWrap: 'wrap' }}>
<Summary label="社保单位合计" value={`${(socialTotal(editRates) * 100).toFixed(2)}%`} />
<Summary label="公积金" value={`${(editRates.housingFund * 100).toFixed(1)}%`} />
<Summary label="全成本加载系数" value={`${loadingFactor(editRates).toFixed(3)}×`} highlight />
<Summary label="说明" value="加载系数 = 1 + 社保 + 公积金(不含福利/摊销)" small />
</div>
<div style={{ display: 'flex', gap: `${space(2)}px` }}>
<button onClick={handleSave} style={{ padding: `${space(2)}px ${space(5)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: colorVar('color.brand.primary'), color: '#fff', cursor: 'pointer', fontWeight: 600 }}></button>
<button onClick={() => { setEditRegion(''); setEditRates(null); }} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${colorVar('color.border.default')}`, background: 'transparent', cursor: 'pointer' }}></button>
</div>
</Card>
</div>
)}
{/* 引擎内置默认费率(只读对照) */}
{defaults !== null && (
<div style={{ marginTop: `${space(4)}px` }}>
<Card title="引擎内置默认费率(只读对照基准)">
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), margin: `0 0 ${space(2)}px` }}>
</p>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>
{['地域', '养老', '医疗', '失业', '工伤', '生育', '公积金', '增值税', '附加税', '加载系数'].map((h) => (
<th key={h} style={{ textAlign: 'left', padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, color: colorVar('color.text.secondary'), ...typographyStyle('caption'), fontWeight: 700 }}>{h}</th>
))}
</tr></thead>
<tbody>
{[defaults.national, ...Object.values(defaults.regions).filter((r) => r.regionName !== '全国(默认)')].map((r) => (
<tr key={r.regionName}>
<td style={defTd}>{r.regionName}</td>
<td style={defTd}>{(r.socialInsurance.pension * 100).toFixed(1)}%</td>
<td style={defTd}>{(r.socialInsurance.medical * 100).toFixed(1)}%</td>
<td style={defTd}>{(r.socialInsurance.unemployment * 100).toFixed(2)}%</td>
<td style={defTd}>{(r.socialInsurance.injury * 100).toFixed(2)}%</td>
<td style={defTd}>{(r.socialInsurance.maternity * 100).toFixed(2)}%</td>
<td style={defTd}>{(r.housingFund * 100).toFixed(1)}%</td>
<td style={defTd}>{(r.vatGeneralRate * 100).toFixed(1)}%</td>
<td style={defTd}>{(r.surchargeRate * 100).toFixed(0)}%</td>
<td style={{ ...defTd, fontWeight: 700, color: colorVar('color.brand.primary') }}>{loadingFactor(r).toFixed(3)}×</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
</>
)}
</div>
);
}
const tdStyle: React.CSSProperties = { padding: `${space(2)}px`, borderBottom: '1px solid var(--color-border-default)', fontSize: '14px' };
const defTd: React.CSSProperties = { padding: `${space(1)}px ${space(2)}px`, borderBottom: '1px solid var(--color-border-default)', fontSize: '13px', whiteSpace: 'nowrap' };
function linkBtn(color: string): React.CSSProperties {
return { cursor: 'pointer', border: 'none', background: 'none', color, fontWeight: 600, fontSize: '12px' };
}
function Summary({ label, value, highlight, small }: { label: string; value: string; highlight?: boolean; small?: boolean }): JSX.Element {
return (
<div>
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{label}</div>
<div style={{ ...(small ? typographyStyle('caption') : typographyStyle('title')), fontWeight: small ? 400 : 700, color: highlight ? colorVar('color.brand.primary') : colorVar('color.text.primary'), marginTop: 2 }}>{value}</div>
</div>
);
}
/** 最低工资标准维护面板:驱动"低于最低工资"红线自动比对。 */
function MinWagePanel(): JSX.Element {
const [items, setItems] = useState<MinWageItem[]>([]);
const [region, setRegion] = useState('');
const [wage, setWage] = useState('');
const reload = useCallback(() => {
fetchMinWages().then(setItems).catch(() => setItems([]));
}, []);
useEffect(() => { reload(); }, [reload]);
const inputStyle: React.CSSProperties = {
padding: `${space(1)}px ${space(2)}px`, border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.md}px`, fontFamily: FONT_FAMILY, ...typographyStyle('caption'),
backgroundColor: colorVar('color.bg.canvas'), color: colorVar('color.text.primary'),
};
async function handleSave(): Promise<void> {
const w = Number(wage);
if (region.trim() === '' || !Number.isFinite(w) || w <= 0) return;
await saveMinWage(region.trim(), w);
setRegion(''); setWage('');
reload();
}
return (
<Card title={`各地域最低工资标准(${items.length}`}>
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), margin: `0 0 ${space(2)}px` }}>
线 HR/
</p>
<div style={{ display: 'flex', gap: `${space(2)}px`, flexWrap: 'wrap', alignItems: 'center', marginBottom: `${space(2)}px` }}>
<input style={{ ...inputStyle, width: 120 }} value={region} onChange={(e) => setRegion(e.target.value)} placeholder="地域(如 北京)" />
<input style={{ ...inputStyle, width: 140 }} value={wage} onChange={(e) => setWage(e.target.value)} placeholder="月最低工资(元)" inputMode="decimal" />
<button type="button" onClick={handleSave} style={{ padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: colorVar('color.brand.primary'), color: '#fff', cursor: 'pointer', fontWeight: 600 }}>/</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: `${space(2)}px` }}>
{items.map((m) => (
<div key={m.region} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: `${space(1)}px ${space(2)}px`, border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.md}px`, ...typographyStyle('caption') }}>
<span style={{ color: colorVar('color.text.primary') }}>{m.region}</span>
<div style={{ display: 'flex', gap: `${space(2)}px`, alignItems: 'center' }}>
<span style={{ fontWeight: 700 }}>{m.monthlyWage.toLocaleString('zh-CN')} </span>
<button type="button" onClick={() => { void deleteMinWageApi(m.region).then(reload); }} style={{ display: 'inline-flex', alignItems: 'center', border: 'none', background: 'transparent', color: colorVar('color.risk.critical'), cursor: 'pointer' }} aria-label="删除"><Icon name="close" size={15} /></button>
</div>
</div>
))}
</div>
</Card>
);
}