外包风险评估系统:领域引擎+前端+服务端持久化与生产部署
- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解 - 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护) - 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理 - 专业图标体系替换全部emoji、看板美化 - 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC - 444单测+204前端测试+51 e2e
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 附件管理区:上传/列表,显示在详情页。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { colorVar, FONT_FAMILY, RADIUS, space, typographyStyle } from '../design-system/components/styles.js';
|
||||
import { API_BASE } from '../api/client.js';
|
||||
|
||||
interface AttachmentMeta {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
uploadedBy: string | null;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
const API = API_BASE;
|
||||
|
||||
export function AttachmentSection({ assessmentId, user }: { readonly assessmentId: string; readonly user?: string }): JSX.Element {
|
||||
const [attachments, setAttachments] = useState<AttachmentMeta[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetch(`${API}/api/assessments/${assessmentId}/attachments`)
|
||||
.then((r) => r.json())
|
||||
.then(setAttachments)
|
||||
.catch(() => setAttachments([]));
|
||||
}, [assessmentId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function handleUpload(): Promise<void> {
|
||||
const file = fileRef.current?.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const buf = await file.arrayBuffer();
|
||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
await fetch(`${API}/api/assessments/${assessmentId}/attachments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename: file.name, mimeType: file.type || 'application/octet-stream', base64, uploadedBy: user }),
|
||||
});
|
||||
load();
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: `${space(3)}px`, padding: `${space(3)}px`, backgroundColor: colorVar('color.bg.elevated'), borderRadius: `${RADIUS.lg}px`, border: `1px solid ${colorVar('color.border.default')}` }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: `${space(2)}px` }}>
|
||||
<span style={{ ...typographyStyle('title'), fontWeight: 700, color: colorVar('color.text.primary') }}>
|
||||
附件与证据({attachments.length})
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: `${space(2)}px`, alignItems: 'center' }}>
|
||||
<input ref={fileRef} type="file" style={{ ...typographyStyle('caption'), fontFamily: FONT_FAMILY }} />
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading}
|
||||
style={{ padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.sm}px`, border: 'none', backgroundColor: colorVar('color.brand.primary'), color: '#fff', cursor: 'pointer', fontWeight: 600, ...typographyStyle('caption') }}
|
||||
>
|
||||
{uploading ? '上传中…' : '上传'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{attachments.length === 0 ? (
|
||||
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>暂无附件</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{attachments.map((a) => (
|
||||
<div key={a.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, ...typographyStyle('caption') }}>
|
||||
<span style={{ fontWeight: 600, color: colorVar('color.text.primary') }}>{a.filename}</span>
|
||||
<span style={{ color: colorVar('color.text.secondary') }}>
|
||||
{(a.sizeBytes / 1024).toFixed(1)} KB · {a.uploadedBy ?? '未知'} · {new Date(a.uploadedAt).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* 客户档案 — 专业 CRM(对标费率管理设计水准)。
|
||||
*
|
||||
* 模块:
|
||||
* 1. 信用评分模型面板(类似引擎默认对照)
|
||||
* 2. 统计看板(客户数/等级分布/平均逾期/预警数)
|
||||
* 3. 客户列表(按信用等级分组 + 筛选排序)
|
||||
* 4. 客户详情面板(信用仪表 + 关联评估 + 合作概况 + 预警规则)
|
||||
* 5. 新增/编辑表单
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { colorVar, FONT_FAMILY, RADIUS, SHADOW, space, typographyStyle } from '../design-system/components/styles.js';
|
||||
import { Card, Icon } from '../design-system/index.js';
|
||||
import { fetchCustomers, createCustomer, deleteCustomerApi, fetchConcentration, fetchAssessmentsPage, fetchPayments, addPayment, deletePaymentApi, type CustomerItem, type AssessmentListItem, type CustomerPayment } from '../api/client.js';
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 信用评分模型(引擎对照基准)
|
||||
* ------------------------------------------------------------------ */
|
||||
const CREDIT_MODEL = [
|
||||
{ grade: 'AAA', label: '极优', badDebt: 0.01, color: '#15803D', desc: '历史无逾期,付款极准时,大型国企/上市公司' },
|
||||
{ grade: 'AA', label: '优良', badDebt: 0.015, color: '#15803D', desc: '偶有短期逾期(<7天),付款习惯好' },
|
||||
{ grade: 'A', label: '良好', badDebt: 0.02, color: '#2563EB', desc: '正常付款周期,偶有延迟(7-15天)' },
|
||||
{ grade: 'BBB', label: '一般', badDebt: 0.03, color: '#B45309', desc: '付款需催促,平均逾期 15-30 天' },
|
||||
{ grade: 'BB', label: '关注', badDebt: 0.04, color: '#B45309', desc: '经常逾期 30+ 天,需加强催收' },
|
||||
{ grade: 'B', label: '风险', badDebt: 0.06, color: '#BE123C', desc: '严重逾期 60+ 天,存在坏账风险' },
|
||||
{ grade: '未评级', label: '待评', badDebt: 0.03, color: '#64748B', desc: '新客户,暂按一般标准计提' },
|
||||
] as const;
|
||||
|
||||
const CREDIT_COLOR: Record<string, string> = Object.fromEntries(CREDIT_MODEL.map((c) => [c.grade, c.color]));
|
||||
const CREDIT_BAD_DEBT: Record<string, number> = Object.fromEntries(CREDIT_MODEL.map((c) => [c.grade, c.badDebt]));
|
||||
const CREDIT_OPTIONS = CREDIT_MODEL.map((c) => c.grade);
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 分组
|
||||
* ------------------------------------------------------------------ */
|
||||
const GROUPS = [
|
||||
{ label: '风险客户', grades: ['B', 'BB'], color: '#BE123C' },
|
||||
{ label: '关注客户', grades: ['BBB', '未评级'], color: '#B45309' },
|
||||
{ label: '良好客户', grades: ['A', 'AA', 'AAA'], color: '#15803D' },
|
||||
] as const;
|
||||
|
||||
function getGroup(grade: string): string {
|
||||
for (const g of GROUPS) {
|
||||
if (g.grades.includes(grade as never)) return g.label;
|
||||
}
|
||||
return '关注客户';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 页面
|
||||
* ------------------------------------------------------------------ */
|
||||
const EMPTY_FORM = { id: '', name: '', creditRating: 'A', avgOverdueDays: '0', totalContractAmount: '0', assessmentCount: '0', notes: '' };
|
||||
|
||||
export function CustomerManagement(): JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
const [customers, setCustomers] = useState<CustomerItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [concentrations, setConcentrations] = useState<Record<string, { concentration: number; warning: string | null }>>({});
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [detail, setDetail] = useState<CustomerItem | null>(null);
|
||||
const [detailAssessments, setDetailAssessments] = useState<AssessmentListItem[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterGrade, setFilterGrade] = useState('');
|
||||
// 信用评分模型对照表默认收起。
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetchCustomers().then((c) => {
|
||||
setCustomers(c);
|
||||
c.forEach((cust) => fetchConcentration(cust.id).then((r) => setConcentrations((prev) => ({ ...prev, [cust.id]: r }))));
|
||||
}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// 打开详情时加载关联评估
|
||||
useEffect(() => {
|
||||
if (detail === null) { setDetailAssessments([]); return; }
|
||||
fetchAssessmentsPage({ page: 1, pageSize: 50, q: detail.name, archived: 'all' })
|
||||
.then((res) => setDetailAssessments(res.items))
|
||||
.catch(() => setDetailAssessments([]));
|
||||
}, [detail]);
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
if (!form.id || !form.name) return;
|
||||
await createCustomer({
|
||||
id: form.id, name: form.name, creditRating: form.creditRating,
|
||||
avgOverdueDays: Number(form.avgOverdueDays) || 0,
|
||||
totalContractAmount: Number(form.totalContractAmount.replace(/,/g, '')) || 0,
|
||||
assessmentCount: Number(form.assessmentCount) || 0,
|
||||
notes: form.notes || null,
|
||||
});
|
||||
setForm(EMPTY_FORM); setShowForm(false); setEditing(false); load();
|
||||
}
|
||||
|
||||
function handleEdit(c: CustomerItem): void {
|
||||
setForm({ id: c.id, name: c.name, creditRating: c.creditRating, avgOverdueDays: String(c.avgOverdueDays), totalContractAmount: c.totalContractAmount.toLocaleString('zh-CN'), assessmentCount: String(c.assessmentCount), notes: c.notes ?? '' });
|
||||
setEditing(true); setShowForm(true); setDetail(null);
|
||||
}
|
||||
|
||||
// 筛选
|
||||
const filtered = customers.filter((c) => {
|
||||
if (search && !c.name.includes(search) && !c.id.includes(search)) return false;
|
||||
if (filterGrade && c.creditRating !== filterGrade) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// 统计
|
||||
const totalCustomers = customers.length;
|
||||
const warningCount = customers.filter((c) => { const cc = concentrations[c.id]; return c.avgOverdueDays > 30 || (cc !== undefined && cc.concentration > 0.3); }).length;
|
||||
const avgOverdue = totalCustomers > 0 ? Math.round(customers.reduce((s, c) => s + c.avgOverdueDays, 0) / totalCustomers) : 0;
|
||||
const gradeDistribution = CREDIT_MODEL.map((m) => ({ ...m, count: customers.filter((c) => c.creditRating === m.grade).length })).filter((m) => m.count > 0);
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: `${space(2)}px ${space(3)}px`,
|
||||
border: `1px solid ${colorVar('color.border.default')}`,
|
||||
borderRadius: `${RADIUS.md}px`,
|
||||
fontFamily: FONT_FAMILY, ...typographyStyle('body'),
|
||||
backgroundColor: colorVar('color.bg.canvas'), color: colorVar('color.text.primary'),
|
||||
width: '100%',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: FONT_FAMILY, maxWidth: 1200, margin: '0 auto' }}>
|
||||
{/* 页头 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: `${space(4)}px` }}>
|
||||
<div>
|
||||
<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` }}>
|
||||
信用档案驱动评估:客户等级 → 坏账准备金计提,逾期/集中度 → 预警。评估时按【客户】名称自动匹配。
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => { setShowForm(!showForm); if (!showForm) { setForm(EMPTY_FORM); setEditing(false); } }} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: colorVar('color.brand.primary'), color: '#fff', cursor: 'pointer', fontWeight: 600 }}>
|
||||
{showForm ? '收起' : '+ 新增客户'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 统计看板 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: `${space(3)}px`, marginBottom: `${space(3)}px` }}>
|
||||
<MetricCard icon="user" label="客户总数" value={String(totalCustomers)} accent={colorVar('color.brand.primary')} />
|
||||
<MetricCard icon="warning" label="预警客户" value={String(warningCount)} accent={warningCount > 0 ? '#BE123C' : '#15803D'} sub={warningCount > 0 ? '逾期或集中度超限' : '暂无预警'} />
|
||||
<MetricCard icon="clock" label="平均逾期" value={`${avgOverdue} 天`} accent={avgOverdue > 30 ? '#B45309' : '#15803D'} sub={avgOverdue > 30 ? '超 30 天警戒线' : '处于正常区间'} />
|
||||
</div>
|
||||
|
||||
{/* 等级分布 */}
|
||||
{gradeDistribution.length > 0 && (
|
||||
<div style={{ padding: `${space(4)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.lg}px`, marginBottom: `${space(4)}px`, boxShadow: '0 1px 2px rgba(16,24,40,0.04)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px`, marginBottom: `${space(3)}px` }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 8, backgroundColor: `${colorVar('color.brand.primary')}1A`, color: colorVar('color.brand.primary') }}>
|
||||
<Icon name="chart" size={18} />
|
||||
</span>
|
||||
<span style={{ ...typographyStyle('body'), fontWeight: 700, color: colorVar('color.text.primary') }}>信用等级分布</span>
|
||||
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>共 {totalCustomers} 家客户</span>
|
||||
</div>
|
||||
{/* 堆叠占比条 */}
|
||||
<div style={{ display: 'flex', height: 10, borderRadius: 999, overflow: 'hidden', marginBottom: `${space(3)}px`, backgroundColor: colorVar('color.bg.surface') }}>
|
||||
{gradeDistribution.map((g) => (
|
||||
<div key={g.grade} title={`${g.grade} ${g.count} 家`} style={{ width: `${(g.count / totalCustomers) * 100}%`, backgroundColor: g.color }} />
|
||||
))}
|
||||
</div>
|
||||
{/* 图例 chips */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: `${space(2)}px` }}>
|
||||
{gradeDistribution.map((g) => (
|
||||
<div key={g.grade} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: `4px ${space(2)}px`, borderRadius: 999, backgroundColor: `${g.color}12`, border: `1px solid ${g.color}33` }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: g.color, display: 'inline-block' }} />
|
||||
<span style={{ ...typographyStyle('caption'), fontWeight: 700, color: g.color }}>{g.grade}</span>
|
||||
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{g.label}</span>
|
||||
<span style={{ ...typographyStyle('caption'), fontWeight: 700, color: colorVar('color.text.primary') }}>{g.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 信用评分模型面板(引擎对照,默认收起) */}
|
||||
<Card
|
||||
title={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModelOpen((v) => !v)}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%', border: 'none', background: 'transparent', cursor: 'pointer', padding: 0, fontFamily: FONT_FAMILY, color: colorVar('color.text.primary'), ...typographyStyle('title'), fontWeight: 600 }}
|
||||
aria-expanded={modelOpen}
|
||||
>
|
||||
<span>信用评分模型(评估引擎对照基准)</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, ...typographyStyle('caption'), color: colorVar('color.text.secondary'), fontWeight: 400 }}>
|
||||
{modelOpen ? '收起' : '展开查看'}
|
||||
<span style={{ display: 'inline-flex', transform: modelOpen ? 'rotate(180deg)' : 'none', transition: 'transform 150ms ease' }}><Icon name="chevron-down" size={18} /></span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
padded={modelOpen}
|
||||
>
|
||||
{modelOpen ? (
|
||||
<>
|
||||
<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: `2px solid ${colorVar('color.border.default')}`, color: colorVar('color.text.secondary'), ...typographyStyle('caption'), fontWeight: 700 }}>{h}</th>
|
||||
))}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{CREDIT_MODEL.map((m) => (
|
||||
<tr key={m.grade}>
|
||||
<td style={{ ...tds, fontWeight: 700, color: m.color }}>{m.grade} ({m.label})</td>
|
||||
<td style={tds}>{m.desc}</td>
|
||||
<td style={{ ...tds, fontWeight: 700, color: m.color }}>{(m.badDebt * 100).toFixed(1)}%</td>
|
||||
<td style={{ ...tds, ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>坏账准备金按 {(m.badDebt * 100).toFixed(1)}% 计提</td>
|
||||
<td style={{ ...tds, ...typographyStyle('caption') }}>{m.badDebt <= 0.02 ? '大型国企/上市公司' : m.badDebt <= 0.03 ? '中型企业' : '小微/新客户'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{/* 新增/编辑弹窗 */}
|
||||
{showForm && (
|
||||
<div style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} onClick={() => { setShowForm(false); setEditing(false); }}>
|
||||
<div style={{ backgroundColor: colorVar('color.bg.elevated'), borderRadius: `${RADIUS.lg}px`, padding: `${space(5)}px`, maxWidth: 600, width: '90%', boxShadow: SHADOW.lg }} onClick={(e) => e.stopPropagation()}>
|
||||
<h3 style={{ ...typographyStyle('title'), margin: `0 0 ${space(3)}px`, color: colorVar('color.text.primary') }}>{editing ? '编辑客户' : '新增客户'}</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: `${space(3)}px` }}>
|
||||
<FormField label="客户ID"><input style={inputStyle} value={form.id} disabled={editing} onChange={(e) => setForm((f) => ({ ...f, id: e.target.value }))} placeholder="唯一标识" /></FormField>
|
||||
<FormField label="客户全称(须与评估【客户】一致)"><input style={inputStyle} value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="如 某省电信公司" /></FormField>
|
||||
<FormField label="资信等级"><select style={inputStyle} value={form.creditRating} onChange={(e) => setForm((f) => ({ ...f, creditRating: e.target.value }))}>{CREDIT_OPTIONS.map((o) => { const m = CREDIT_MODEL.find((x) => x.grade === o); return <option key={o} value={o}>{o} — {m?.label ?? ''}(坏账{((m?.badDebt ?? 0) * 100).toFixed(1)}%)</option>; })}</select></FormField>
|
||||
<FormField label="平均逾期(天)"><input style={inputStyle} value={form.avgOverdueDays} onChange={(e) => setForm((f) => ({ ...f, avgOverdueDays: e.target.value }))} inputMode="numeric" /></FormField>
|
||||
<FormField label="累计合同额(元)">
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={form.totalContractAmount}
|
||||
onChange={(e) => setForm((f) => ({ ...f, totalContractAmount: e.target.value.replace(/,/g, '') }))}
|
||||
onFocus={(e) => { e.target.value = form.totalContractAmount; }}
|
||||
onBlur={() => {
|
||||
const n = Number(form.totalContractAmount.replace(/,/g, ''));
|
||||
if (Number.isFinite(n) && n > 0) {
|
||||
setForm((f) => ({ ...f, totalContractAmount: n.toLocaleString('zh-CN') }));
|
||||
}
|
||||
}}
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="备注"><input style={inputStyle} value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} placeholder="内部备注" /></FormField>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: `${space(2)}px`, marginTop: `${space(3)}px`, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => { setShowForm(false); setEditing(false); }} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${colorVar('color.border.default')}`, background: 'transparent', cursor: 'pointer' }}>取消</button>
|
||||
<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 }}>{editing ? '保存' : '添加'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 筛选 */}
|
||||
<div style={{ display: 'flex', gap: `${space(3)}px`, marginTop: `${space(4)}px`, marginBottom: `${space(3)}px`, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input style={{ ...inputStyle, maxWidth: 280, width: 'auto' }} placeholder="搜索客户名称或ID" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<select style={{ ...inputStyle, width: 'auto', minWidth: 130 }} value={filterGrade} onChange={(e) => setFilterGrade(e.target.value)}>
|
||||
<option value="">全部等级</option>
|
||||
{CREDIT_OPTIONS.map((g) => <option key={g} value={g}>{g}</option>)}
|
||||
</select>
|
||||
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
|
||||
共 {filtered.length} 个客户{filterGrade ? ` · ${filterGrade}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 客户列表(按信用分组) */}
|
||||
{loading ? <p style={{ color: colorVar('color.text.secondary') }}>加载中…</p> : (
|
||||
GROUPS.map((g) => {
|
||||
const groupCustomers = filtered.filter((c) => getGroup(c.creditRating) === g.label);
|
||||
if (groupCustomers.length === 0) return null;
|
||||
return (
|
||||
<div key={g.label} style={{ marginBottom: `${space(4)}px` }}>
|
||||
<div style={{ ...typographyStyle('title'), fontWeight: 700, color: g.color, padding: `${space(2)}px 0`, borderBottom: `2px solid ${g.color}`, marginBottom: `${space(2)}px` }}>
|
||||
{g.label}({groupCustomers.length})
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead><tr>
|
||||
{['客户名称', '信用等级', '坏账准备金率', '平均逾期', '合同累计', '集中度', '评估次数', '操作'].map((h) => (
|
||||
<th key={h} style={{ textAlign: 'left', padding: `${space(2)}px ${space(3)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, color: colorVar('color.text.secondary'), ...typographyStyle('caption'), fontWeight: 700 }}>{h}</th>
|
||||
))}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{groupCustomers.map((c) => {
|
||||
const conc = concentrations[c.id];
|
||||
const concVal = conc !== undefined ? conc.concentration : 0;
|
||||
const concDanger = concVal > 0.3;
|
||||
const overdueDanger = c.avgOverdueDays > 30;
|
||||
const cc = CREDIT_COLOR[c.creditRating] ?? '#64748B';
|
||||
return (
|
||||
<tr key={c.id} style={{ cursor: 'pointer' }} onClick={() => setDetail(c)}>
|
||||
<td style={{ ...tds, fontWeight: 600 }}>{c.name}</td>
|
||||
<td style={tds}><span style={{ padding: '2px 8px', borderRadius: '999px', backgroundColor: cc + '14', color: cc, fontWeight: 700, ...typographyStyle('caption') }}>{c.creditRating}</span></td>
|
||||
<td style={{ ...tds, color: cc, fontWeight: 600 }}>{((CREDIT_BAD_DEBT[c.creditRating] ?? 0.02) * 100).toFixed(1)}%</td>
|
||||
<td style={{ ...tds, color: overdueDanger ? '#BE123C' : undefined, fontWeight: overdueDanger ? 700 : 400 }}>{c.avgOverdueDays} 天{overdueDanger ? <Icon name="warning" size={12} color="#BE123C" /> : null}</td>
|
||||
<td style={tds}>{c.totalContractAmount.toLocaleString('zh-CN')} 元</td>
|
||||
<td style={{ ...tds, color: concDanger ? '#BE123C' : undefined, fontWeight: concDanger ? 700 : 400 }}>{(concVal * 100).toFixed(1)}%{concDanger ? <Icon name="warning" size={12} color="#BE123C" /> : null}</td>
|
||||
<td style={tds}>{c.assessmentCount}</td>
|
||||
<td style={tds} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', gap: `${space(2)}px` }}>
|
||||
<button onClick={() => handleEdit(c)} style={linkBtnS(colorVar('color.brand.primary'))}>编辑</button>
|
||||
<button onClick={() => deleteCustomerApi(c.id).then(load)} style={linkBtnS('#BE123C')}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* 详情面板 */}
|
||||
{detail !== null && (
|
||||
<div style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} onClick={() => setDetail(null)}>
|
||||
<div style={{ backgroundColor: colorVar('color.bg.elevated'), borderRadius: `${RADIUS.lg}px`, padding: `${space(5)}px`, maxWidth: 700, width: '92%', boxShadow: SHADOW.lg, maxHeight: '90vh', overflowY: 'auto' }} onClick={(e) => e.stopPropagation()}>
|
||||
{/* 头部 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: `${space(4)}px` }}>
|
||||
<div>
|
||||
<h2 style={{ ...typographyStyle('heading'), margin: 0, color: colorVar('color.text.primary') }}>{detail.name}</h2>
|
||||
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>ID: {detail.id}</span>
|
||||
</div>
|
||||
<span style={{ padding: '4px 14px', borderRadius: '999px', backgroundColor: (CREDIT_COLOR[detail.creditRating] ?? '#64748B') + '14', color: CREDIT_COLOR[detail.creditRating] ?? '#64748B', fontWeight: 700, ...typographyStyle('title') }}>{detail.creditRating}</span>
|
||||
</div>
|
||||
|
||||
{/* 信用仪表盘 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: `${space(2)}px`, marginBottom: `${space(3)}px` }}>
|
||||
<MCard label="信用等级" value={detail.creditRating} sub={CREDIT_MODEL.find((m) => m.grade === detail.creditRating)?.label ?? ''} color={CREDIT_COLOR[detail.creditRating]} />
|
||||
<MCard label="坏账准备金率" value={`${((CREDIT_BAD_DEBT[detail.creditRating] ?? 0.02) * 100).toFixed(1)}%`} sub="按等级自动计提" color={CREDIT_COLOR[detail.creditRating]} />
|
||||
<MCard label="平均逾期" value={`${detail.avgOverdueDays} 天`} sub={detail.avgOverdueDays > 30 ? '超警戒' : '正常'} color={detail.avgOverdueDays > 30 ? '#BE123C' : undefined} />
|
||||
{(() => {
|
||||
const cc = concentrations[detail.id];
|
||||
const cv = cc !== undefined ? cc.concentration : 0;
|
||||
return <MCard label="收入集中度" value={`${(cv * 100).toFixed(1)}%`} sub={cv > 0.3 ? '超阈值' : '正常'} color={cv > 0.3 ? '#BE123C' : undefined} />;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 合作概况 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: `${space(3)}px`, marginBottom: `${space(3)}px`, padding: `${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px` }}>
|
||||
<div><div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>累计合同额</div><div style={{ ...typographyStyle('title'), fontWeight: 700 }}>{detail.totalContractAmount.toLocaleString('zh-CN')} 元</div></div>
|
||||
<div><div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>评估次数</div><div style={{ ...typographyStyle('title'), fontWeight: 700 }}>{detail.assessmentCount} 次</div></div>
|
||||
<div><div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>信用分组</div><div style={{ ...typographyStyle('title'), fontWeight: 700, color: GROUPS.find((g) => g.grades.includes(detail.creditRating as never))?.color }}>{getGroup(detail.creditRating)}</div></div>
|
||||
</div>
|
||||
|
||||
{/* 预警 */}
|
||||
{(() => {
|
||||
const cc = concentrations[detail.id];
|
||||
const concHigh = cc !== undefined && cc.concentration > 0.3;
|
||||
if (detail.avgOverdueDays <= 30 && !concHigh) return null;
|
||||
return (
|
||||
<div style={{ padding: `${space(3)}px`, backgroundColor: 'rgba(190,18,60,0.06)', border: '1px solid rgba(190,18,60,0.2)', borderRadius: `${RADIUS.md}px`, marginBottom: `${space(3)}px` }}>
|
||||
<div style={{ ...typographyStyle('caption'), fontWeight: 700, color: '#BE123C', marginBottom: 4, display: 'flex', alignItems: 'center', gap: 6 }}><Icon name="warning" size={14} /> 信用预警</div>
|
||||
{detail.avgOverdueDays > 30 && <div style={{ ...typographyStyle('caption'), color: '#BE123C' }}>• 逾期 {detail.avgOverdueDays} 天 > 30 天警戒 → 建议:提高坏账准备金 / 要求预付款 / 缩短账期</div>}
|
||||
{concHigh && cc !== undefined && <div style={{ ...typographyStyle('caption'), color: '#BE123C' }}>• 集中度 {(cc.concentration * 100).toFixed(1)}% > 30% → 建议:分散客户结构 / 限制新增投入</div>}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 关联评估历史 */}
|
||||
<div style={{ marginBottom: `${space(3)}px` }}>
|
||||
<div style={{ ...typographyStyle('title'), fontWeight: 700, color: colorVar('color.text.primary'), marginBottom: `${space(2)}px` }}>关联评估历史({detailAssessments.length})</div>
|
||||
{detailAssessments.length === 0 ? (
|
||||
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>暂无关联评估</p>
|
||||
) : (
|
||||
<div style={{ maxHeight: 200, overflowY: 'auto', border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.md}px` }}>
|
||||
{detailAssessments.map((a) => (
|
||||
<div key={a.id} onClick={() => { setDetail(null); navigate(`/assessments/${a.id}`); }} style={{ padding: `${space(2)}px ${space(3)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center', ...typographyStyle('caption') }}>
|
||||
<span style={{ color: colorVar('color.text.primary') }}>{a.projectDescription.slice(0, 30)}</span>
|
||||
<div style={{ display: 'flex', gap: `${space(2)}px`, alignItems: 'center' }}>
|
||||
{a.riskGrade !== undefined && <span style={{ padding: '1px 6px', borderRadius: '999px', backgroundColor: colorVar('color.bg.surface'), fontWeight: 600 }}>{a.riskGrade}</span>}
|
||||
<span style={{ color: colorVar('color.text.secondary') }}>{a.createdAt.slice(0, 10)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 回款记录(自动计算平均逾期天数) */}
|
||||
<PaymentsPanel customerId={detail.id} onChanged={() => { load(); }} />
|
||||
|
||||
{/* 评估联动说明 */}
|
||||
<div style={{ padding: `${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px`, marginBottom: `${space(3)}px`, ...typographyStyle('caption'), color: colorVar('color.text.secondary'), display: 'flex', alignItems: 'flex-start', gap: 6 }}>
|
||||
<Icon name="lightbulb" size={14} color={colorVar('color.brand.primary')} />
|
||||
<span><strong>评估联动</strong>:新建评估时【客户】名称匹配「{detail.name}」→ 坏账准备金按 {((CREDIT_BAD_DEBT[detail.creditRating] ?? 0.02) * 100).toFixed(1)}% 计提{detail.avgOverdueDays > 30 ? ';标注逾期预警' : ''}。</span>
|
||||
</div>
|
||||
|
||||
{detail.notes && (
|
||||
<div style={{ padding: `${space(2)}px ${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px`, marginBottom: `${space(3)}px` }}>
|
||||
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>备注:</span>{detail.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: `${space(2)}px`, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => handleEdit(detail)} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${colorVar('color.brand.primary')}`, background: 'transparent', color: colorVar('color.brand.primary'), cursor: 'pointer', fontWeight: 600 }}>编辑</button>
|
||||
<button onClick={() => { deleteCustomerApi(detail.id).then(load); setDetail(null); }} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: '#BE123C', color: '#fff', cursor: 'pointer', fontWeight: 600 }}>删除</button>
|
||||
<button onClick={() => setDetail(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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 子组件
|
||||
* ------------------------------------------------------------------ */
|
||||
const tds: React.CSSProperties = { padding: `${space(2)}px ${space(3)}px`, borderBottom: '1px solid var(--color-border-default)', fontSize: '14px' };
|
||||
|
||||
function linkBtnS(color: string): React.CSSProperties {
|
||||
return { cursor: 'pointer', border: 'none', background: 'none', color, fontWeight: 600, fontSize: '12px' };
|
||||
}
|
||||
|
||||
function FormField({ label, children }: { label: string; children: React.ReactNode }): JSX.Element {
|
||||
return <div><label style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), display: 'block', marginBottom: 4 }}>{label}</label>{children}</div>;
|
||||
}
|
||||
|
||||
function MetricCard({ icon, label, value, accent, sub }: { icon: 'user' | 'warning' | 'clock'; label: string; value: string; accent: string; sub?: string }): JSX.Element {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(3)}px`, padding: `${space(4)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.lg}px`, boxShadow: '0 1px 2px rgba(16,24,40,0.04)' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: 10, backgroundColor: `${accent}1A`, color: accent, flexShrink: 0 }}>
|
||||
<Icon name={icon} size={20} />
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{label}</span>
|
||||
<span style={{ ...typographyStyle('heading'), fontWeight: 800, color: accent, lineHeight: 1.1 }}>{value}</span>
|
||||
{sub !== undefined && <span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), fontSize: '11px' }}>{sub}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MCard({ label, value, sub, color }: { label: string; value: string; sub: string; color?: string | undefined }): JSX.Element {
|
||||
return (
|
||||
<div style={{ padding: `${space(2)}px`, borderRadius: `${RADIUS.md}px`, backgroundColor: colorVar('color.bg.surface'), textAlign: 'center' }}>
|
||||
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{label}</div>
|
||||
<div style={{ ...typographyStyle('title'), fontWeight: 700, color: color ?? colorVar('color.text.primary'), margin: '2px 0' }}>{value}</div>
|
||||
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), fontSize: '11px' }}>{sub}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 回款记录面板:录入应收/到期/回款,自动重算平均逾期天数(驱动逾期红线)。 */
|
||||
function PaymentsPanel({ customerId, onChanged }: { readonly customerId: string; readonly onChanged: () => void }): JSX.Element {
|
||||
const [items, setItems] = useState<CustomerPayment[]>([]);
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
const [paidDate, setPaidDate] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [avg, setAvg] = useState<number | null>(null);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
fetchPayments(customerId).then(setItems).catch(() => setItems([]));
|
||||
}, [customerId]);
|
||||
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 handleAdd(): Promise<void> {
|
||||
if (dueDate === '') return;
|
||||
const res = await addPayment(customerId, { invoiceAmount: Number(amount) || 0, dueDate, paidDate: paidDate || null });
|
||||
setAvg(res.avgOverdueDays);
|
||||
setDueDate(''); setPaidDate(''); setAmount('');
|
||||
reload();
|
||||
onChanged();
|
||||
}
|
||||
async function handleDelete(pid: number): Promise<void> {
|
||||
const res = await deletePaymentApi(customerId, pid);
|
||||
setAvg(res.avgOverdueDays);
|
||||
reload();
|
||||
onChanged();
|
||||
}
|
||||
|
||||
function overdueOf(p: CustomerPayment): number {
|
||||
const end = p.paidDate !== null ? new Date(p.paidDate) : new Date();
|
||||
return Math.max(0, Math.round((end.getTime() - new Date(p.dueDate).getTime()) / 86400000));
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: `${space(3)}px` }}>
|
||||
<div style={{ ...typographyStyle('title'), fontWeight: 700, color: colorVar('color.text.primary'), marginBottom: `${space(2)}px` }}>
|
||||
回款记录({items.length}){avg !== null ? ` · 已重算平均逾期 ${avg} 天` : ''}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: `${space(2)}px`, flexWrap: 'wrap', alignItems: 'center', marginBottom: `${space(2)}px` }}>
|
||||
<input type="date" style={inputStyle} value={dueDate} onChange={(e) => setDueDate(e.target.value)} title="到期日" />
|
||||
<input type="date" style={inputStyle} value={paidDate} onChange={(e) => setPaidDate(e.target.value)} title="实际回款日(留空=未回款)" />
|
||||
<input style={{ ...inputStyle, width: 120 }} value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="发票金额(选填)" inputMode="decimal" />
|
||||
<button type="button" onClick={handleAdd} disabled={dueDate === ''} style={{ padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: colorVar('color.brand.primary'), color: '#fff', cursor: dueDate === '' ? 'not-allowed' : 'pointer', opacity: dueDate === '' ? 0.6 : 1, fontWeight: 600 }}>+ 录入</button>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>暂无回款记录。录入到期日/回款日后系统自动重算平均逾期天数。</p>
|
||||
) : (
|
||||
<div style={{ maxHeight: 180, overflowY: 'auto', border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.md}px` }}>
|
||||
{items.map((p) => {
|
||||
const od = overdueOf(p);
|
||||
return (
|
||||
<div key={p.id} style={{ padding: `${space(1)}px ${space(3)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, display: 'flex', justifyContent: 'space-between', alignItems: 'center', ...typographyStyle('caption') }}>
|
||||
<span style={{ color: colorVar('color.text.primary') }}>到期 {p.dueDate} · {p.paidDate !== null ? `回款 ${p.paidDate}` : '未回款'}{p.invoiceAmount > 0 ? ` · ${p.invoiceAmount.toLocaleString('zh-CN')}元` : ''}</span>
|
||||
<div style={{ display: 'flex', gap: `${space(2)}px`, alignItems: 'center' }}>
|
||||
<span style={{ color: od > 0 ? '#BE123C' : '#15803D', fontWeight: 600 }}>逾期 {od} 天</span>
|
||||
<button type="button" onClick={() => handleDelete(p.id)} 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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
/**
|
||||
* Dashboard — 评估历史列表与按角色的待办工作台。
|
||||
*
|
||||
* 列表与待办均走**服务端 SQL 分页**(page/pageSize/status/q),统计卡走 summary 聚合接口,
|
||||
* 不再把全量数据载入前端,支持大数据量。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, Card, Table } from '../design-system/index.js';
|
||||
import type { TableColumn } from '../design-system/index.js';
|
||||
import { Icon } from '../design-system/index.js';
|
||||
import {
|
||||
colorVar,
|
||||
FONT_FAMILY,
|
||||
RADIUS,
|
||||
space,
|
||||
typographyStyle,
|
||||
} from '../design-system/components/styles.js';
|
||||
import { fetchAssessmentsPage, fetchSummary, archiveAssessment, applyCalibration, listDrafts, deleteDraftApi, API_BASE } from '../api/client.js';
|
||||
import type { AssessmentListItem, WorkflowStatus, DraftItem } from '../api/client.js';
|
||||
import { useAuthStore } from '../stores/authStore.js';
|
||||
import { GuideBanner } from '../app/Guidance.js';
|
||||
import { RiskBadge } from '../charts/index.js';
|
||||
|
||||
const STATUS_LABEL: Record<WorkflowStatus, string> = {
|
||||
draft: '草稿待申报',
|
||||
pending_risk_review: '待风控审核',
|
||||
risk_reviewed: '风控已审核',
|
||||
pending_management_approval: '待管理层审批',
|
||||
approved: '已通过',
|
||||
rejected: '已驳回',
|
||||
abandoned: '已放弃',
|
||||
};
|
||||
|
||||
const STATUS_STYLE: Record<WorkflowStatus, { bg: string; fg: string }> = {
|
||||
draft: { bg: 'rgba(100, 116, 139, 0.12)', fg: '#64748B' },
|
||||
pending_risk_review: { bg: 'rgba(180, 83, 9, 0.12)', fg: '#B45309' },
|
||||
risk_reviewed: { bg: 'rgba(37, 99, 235, 0.12)', fg: '#2563EB' },
|
||||
pending_management_approval: { bg: 'rgba(124, 58, 237, 0.12)', fg: '#7C3AED' },
|
||||
approved: { bg: 'rgba(16, 128, 61, 0.12)', fg: '#15803D' },
|
||||
rejected: { bg: 'rgba(190, 18, 60, 0.12)', fg: '#BE123C' },
|
||||
abandoned: { bg: 'rgba(100, 116, 139, 0.14)', fg: '#475569' },
|
||||
};
|
||||
|
||||
type StatusFilter = 'all' | WorkflowStatus;
|
||||
|
||||
const STATUS_FILTER_OPTIONS: ReadonlyArray<{ value: StatusFilter; label: string }> = [
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'draft', label: STATUS_LABEL.draft },
|
||||
{ value: 'pending_risk_review', label: STATUS_LABEL.pending_risk_review },
|
||||
{ value: 'risk_reviewed', label: STATUS_LABEL.risk_reviewed },
|
||||
{ value: 'pending_management_approval', label: STATUS_LABEL.pending_management_approval },
|
||||
{ value: 'approved', label: STATUS_LABEL.approved },
|
||||
{ value: 'rejected', label: STATUS_LABEL.rejected },
|
||||
{ value: 'abandoned', label: STATUS_LABEL.abandoned },
|
||||
];
|
||||
|
||||
/** 角色 → 待办状态。 */
|
||||
const TODO_STATUS: Record<string, WorkflowStatus> = {
|
||||
'商务/销售': 'rejected',
|
||||
'风控': 'pending_risk_review',
|
||||
'管理层': 'risk_reviewed',
|
||||
};
|
||||
|
||||
/** 承接建议等级配色。 */
|
||||
const REC_BG: Record<string, string> = {
|
||||
accept: 'rgba(16,128,61,0.12)',
|
||||
conditional: 'rgba(194,65,12,0.12)',
|
||||
caution: 'rgba(194,65,12,0.12)',
|
||||
reject: 'rgba(190,18,60,0.12)',
|
||||
};
|
||||
const REC_FG: Record<string, string> = {
|
||||
accept: '#15803D',
|
||||
conditional: '#C2410C',
|
||||
caution: '#C2410C',
|
||||
reject: '#BE123C',
|
||||
};
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export function Dashboard(): JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuthStore();
|
||||
const role = user?.role ?? '商务/销售';
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 历史列表(服务端分页)
|
||||
const [items, setItems] = useState<AssessmentListItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [archivedView, setArchivedView] = useState<'active' | 'archived'>('active');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// 待办列表
|
||||
const [todoItems, setTodoItems] = useState<AssessmentListItem[]>([]);
|
||||
// 草稿箱(销售:未运行/未提交的向导进度,服务端持久化)
|
||||
const [drafts, setDrafts] = useState<DraftItem[]>([]);
|
||||
// 统计
|
||||
const [summary, setSummary] = useState<{ total: number; byStatus: Record<string, number>; archived?: number }>({ total: 0, byStatus: {} });
|
||||
// 告警
|
||||
const [expiring, setExpiring] = useState<Array<{ id: string; project: string }>>([]);
|
||||
const [overdue, setOverdue] = useState<Array<{ id: string; project: string; overdueHours: number }>>([]);
|
||||
const [rejectStats, setRejectStats] = useState<Array<{ reasonType: string; count: number }>>([]);
|
||||
const [accuracy, setAccuracy] = useState<{ count: number; avgPredictedPct: number | null; avgActualPct: number | null; avgDeviationPct: number | null; bias: string | null }>({ count: 0, avgPredictedPct: null, avgActualPct: null, avgDeviationPct: null, bias: null });
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setSearch(searchInput);
|
||||
setPage(1);
|
||||
}, 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
const loadList = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetchAssessmentsPage({ page, pageSize, status: statusFilter, q: search, archived: archivedView })
|
||||
.then((res) => {
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [page, pageSize, statusFilter, search, archivedView]);
|
||||
|
||||
useEffect(() => {
|
||||
loadList();
|
||||
}, [loadList]);
|
||||
|
||||
const loadAux = useCallback(() => {
|
||||
fetchSummary().then(setSummary).catch(() => undefined);
|
||||
const todoStatus = TODO_STATUS[role];
|
||||
if (todoStatus !== undefined) {
|
||||
fetchAssessmentsPage({ page: 1, pageSize: 50, status: todoStatus })
|
||||
.then((res) => setTodoItems(res.items))
|
||||
.catch(() => setTodoItems([]));
|
||||
} else {
|
||||
setTodoItems([]);
|
||||
}
|
||||
// 告警数据
|
||||
fetch(`${API_BASE}/api/assessments/expiring`).then((r) => r.json()).then(setExpiring).catch(() => setExpiring([]));
|
||||
fetch(`${API_BASE}/api/assessments/overdue`).then((r) => r.json()).then(setOverdue).catch(() => setOverdue([]));
|
||||
fetch(`${API_BASE}/api/reject-reason-stats`).then((r) => r.json()).then(setRejectStats).catch(() => setRejectStats([]));
|
||||
fetch(`${API_BASE}/api/accuracy`).then((r) => r.json()).then(setAccuracy).catch(() => undefined);
|
||||
// 草稿箱(仅销售展示):列出当前用户的向导草稿。
|
||||
if (role === '商务/销售') {
|
||||
listDrafts(user?.username ?? undefined).then(setDrafts).catch(() => setDrafts([]));
|
||||
} else {
|
||||
setDrafts([]);
|
||||
}
|
||||
}, [role, user?.username]);
|
||||
|
||||
/** 删除一条草稿并刷新草稿箱。 */
|
||||
const removeDraft = useCallback(async (id: string): Promise<void> => {
|
||||
try { await deleteDraftApi(id); } catch { /* ignore */ }
|
||||
setDrafts((ds) => ds.filter((d) => d.id !== id));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadAux();
|
||||
}, [loadAux]);
|
||||
|
||||
const handleArchive = useCallback(
|
||||
(id: string, archived: boolean) => {
|
||||
archiveAssessment(id, archived, user?.username)
|
||||
.then(() => {
|
||||
loadList();
|
||||
loadAux();
|
||||
})
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : '操作失败'));
|
||||
},
|
||||
[loadList, loadAux, user],
|
||||
);
|
||||
|
||||
const columns: ReadonlyArray<TableColumn<AssessmentListItem>> = [
|
||||
{
|
||||
key: 'project',
|
||||
header: '项目描述',
|
||||
render: (r) => {
|
||||
// 提取【项目】后的名称,去掉【客户】部分
|
||||
const nameMatch = r.projectDescription.match(/【项目】([^||\n]+)/);
|
||||
const name = nameMatch !== null && nameMatch[1] !== undefined ? nameMatch[1].trim() : r.projectDescription.slice(0, 20);
|
||||
const display = name.length > 18 ? `${name.slice(0, 18)}…` : name;
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', maxWidth: 200 }}>
|
||||
<span style={{ fontWeight: 700, color: colorVar('color.text.primary'), whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{display}
|
||||
</span>
|
||||
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), whiteSpace: 'nowrap' }}>发起人:{r.assessorId}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'type', header: '业务类型', field: 'businessType' },
|
||||
{ key: 'industry', header: '行业', field: 'industry' },
|
||||
{ key: 'score', header: '风险分', align: 'right', render: (r) => (r.riskScore !== undefined ? r.riskScore.toFixed(1) : '-') },
|
||||
{
|
||||
key: 'grade',
|
||||
header: '风险分级',
|
||||
render: (r) => (r.riskGrade !== undefined ? <RiskBadge grade={r.riskGrade as '低' | '中' | '高' | '极高'} /> : '-'),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: '状态',
|
||||
render: (r) => (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 10px',
|
||||
borderRadius: '999px',
|
||||
backgroundColor: STATUS_STYLE[r.status].bg,
|
||||
color: STATUS_STYLE[r.status].fg,
|
||||
...typographyStyle('caption'),
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{STATUS_LABEL[r.status]}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'acceptability', header: '可接受性', render: (r) => r.acceptability ?? '-' },
|
||||
{
|
||||
key: 'recommendation',
|
||||
header: '承接建议',
|
||||
render: (r) =>
|
||||
r.recommendation ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 10px',
|
||||
borderRadius: '999px',
|
||||
...typographyStyle('caption'),
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
backgroundColor: REC_BG[r.recommendation.level] ?? 'transparent',
|
||||
color: REC_FG[r.recommendation.level] ?? colorVar('color.text.secondary'),
|
||||
}}
|
||||
>
|
||||
{r.recommendation.title}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: colorVar('color.text.secondary') }}>-</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'latest',
|
||||
header: '最近处理',
|
||||
render: (r) => {
|
||||
const latest = r.auditLog.at(-1);
|
||||
return latest === undefined ? (
|
||||
<span style={{ color: colorVar('color.text.secondary'), whiteSpace: 'nowrap' }}>待处理</span>
|
||||
) : (
|
||||
<div style={{ whiteSpace: 'nowrap', maxWidth: 140, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
<span style={{ ...typographyStyle('caption') }}>{latest.action.length > 10 ? latest.action.slice(0, 10) + '…' : latest.action}</span>
|
||||
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
|
||||
{latest.role} · {formatDateTime(latest.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'created', header: '创建时间', render: (r) => formatDateTime(r.createdAt) },
|
||||
{
|
||||
key: 'action',
|
||||
header: '操作',
|
||||
render: (r) => {
|
||||
const canArchive = r.status === 'approved' || r.status === 'abandoned';
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: `${space(1)}px`, whiteSpace: 'nowrap' }}>
|
||||
<Button size="sm" variant="ghost" onClick={() => navigate(`/assessments/${r.id}`)}>
|
||||
查看
|
||||
</Button>
|
||||
{archivedView === 'archived' ? (
|
||||
<Button size="sm" variant="ghost" onClick={() => handleArchive(r.id, false)}>
|
||||
取消归档
|
||||
</Button>
|
||||
) : (
|
||||
<span title={canArchive ? '归档' : '仅「最终通过」或「已放弃」可归档'} style={{ display: 'inline-flex' }}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={!canArchive}
|
||||
onClick={() => handleArchive(r.id, true)}
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const todoCount = summary.byStatus[TODO_STATUS[role] ?? ''] ?? 0;
|
||||
const summaryItems = [
|
||||
{ label: '全部评估', value: summary.total, tone: colorVar('color.brand.primary') },
|
||||
{ label: '我的待办', value: todoCount, tone: colorVar('color.risk.high') },
|
||||
{ label: '待风控', value: summary.byStatus.pending_risk_review ?? 0, tone: colorVar('color.risk.medium') },
|
||||
{ label: '已通过', value: summary.byStatus.approved ?? 0, tone: colorVar('color.risk.low') },
|
||||
];
|
||||
|
||||
const pageTitle = role === '风控' ? '待办审核' : role === '管理层' ? '待办审批' : '评估历史';
|
||||
const roleDescription =
|
||||
role === '风控'
|
||||
? '聚焦待复核项目,补充风险判断并留下处理痕迹。'
|
||||
: role === '管理层'
|
||||
? '查看全部历史与高风险事项,完成最终审批决策。'
|
||||
: '发起新评估,跟踪退回事项,并查看全量历史记录。';
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: FONT_FAMILY }}>
|
||||
{role === '商务/销售' && (
|
||||
<GuideBanner id="role-sales" tone="brand">
|
||||
<strong>你是 商务/销售。</strong>主线三步:① 点右上角 <strong>+ 新建评估</strong> 录入项目 → ② 在详情页点 <strong>申报</strong> 报送风控 → ③ 被驳回的项目可<strong>编辑后重报</strong>。下方为你的评估历史与被退回事项。
|
||||
</GuideBanner>
|
||||
)}
|
||||
{role === '风控' && (
|
||||
<GuideBanner id="role-risk" tone="brand">
|
||||
<strong>你是 风控。</strong>下方 <strong>待处理</strong> 是需要你审核的项目;打开后审阅风险与红线,给出<strong>审核通过 / 驳回</strong>结论。红线"需人工核实"项可在详情页人工裁定。
|
||||
</GuideBanner>
|
||||
)}
|
||||
{role === '管理层' && (
|
||||
<GuideBanner id="role-mgmt" tone="brand">
|
||||
<strong>你是 管理层。</strong>下方 <strong>待处理</strong> 待你终审(通过/驳回/放弃);顶部导航可维护 <strong>费率 / 红线 / 客户档案</strong>。看板含到期、超时与预测准确度提醒。
|
||||
</GuideBanner>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: `${space(3)}px`,
|
||||
marginBottom: `${space(5)}px`,
|
||||
padding: `${space(5)}px`,
|
||||
background: `linear-gradient(135deg, ${colorVar('color.bg.elevated')} 0%, ${colorVar('color.bg.surface')} 100%)`,
|
||||
border: `1px solid ${colorVar('color.border.default')}`,
|
||||
borderRadius: `${RADIUS.lg}px`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(2)}px` }}>
|
||||
<span style={{ ...typographyStyle('caption'), color: colorVar('color.brand.primary'), fontWeight: 700 }}>{role} 工作台</span>
|
||||
<h1 style={{ ...typographyStyle('heading'), margin: 0, color: colorVar('color.text.primary') }}>{pageTitle}</h1>
|
||||
<p style={{ margin: 0, color: colorVar('color.text.secondary'), maxWidth: 620 }}>{roleDescription}</p>
|
||||
</div>
|
||||
{role === '商务/销售' && (
|
||||
<Button onClick={() => navigate('/new')} size="lg"><span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}><Icon name="plus" size={16} color="currentColor" /> 新建评估</span></Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: `${space(3)}px`, marginBottom: `${space(4)}px` }}>
|
||||
{summaryItems.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
style={{
|
||||
padding: `${space(4)}px`,
|
||||
backgroundColor: colorVar('color.bg.elevated'),
|
||||
border: `1px solid ${colorVar('color.border.default')}`,
|
||||
borderRadius: `${RADIUS.lg}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: `${space(2)}px`,
|
||||
}}
|
||||
>
|
||||
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{item.label}</span>
|
||||
<span style={{ ...typographyStyle('heading'), color: item.tone, fontWeight: 800 }}>{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 告警卡片 */}
|
||||
{(expiring.length > 0 || overdue.length > 0 || rejectStats.length > 0 || accuracy.count > 0) && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: `${space(3)}px`, marginBottom: `${space(4)}px` }}>
|
||||
{accuracy.count > 0 && accuracy.bias !== null && (() => {
|
||||
const consistent = accuracy.bias === '基本一致';
|
||||
const accent = consistent ? '#15803D' : '#B45309';
|
||||
const dev = accuracy.avgDeviationPct ?? 0;
|
||||
return (
|
||||
<div style={{ padding: `${space(4)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.lg}px`, boxShadow: '0 1px 2px rgba(16,24,40,0.04)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px`, marginBottom: `${space(3)}px` }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 8, backgroundColor: `${accent}1A`, color: accent }}>
|
||||
<Icon name="trending-up" size={18} />
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{ ...typographyStyle('body'), fontWeight: 700, color: colorVar('color.text.primary') }}>预测准确度</span>
|
||||
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>基于 {accuracy.count} 个已回填项目</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: `${space(3)}px`, marginBottom: `${space(3)}px` }}>
|
||||
<div style={{ flex: 1, padding: `${space(2)}px ${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px` }}>
|
||||
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>预测净利率</div>
|
||||
<div style={{ ...typographyStyle('body'), fontWeight: 800, color: colorVar('color.text.primary') }}>{accuracy.avgPredictedPct}%</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: `${space(2)}px ${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px` }}>
|
||||
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>实际净利率</div>
|
||||
<div style={{ ...typographyStyle('body'), fontWeight: 800, color: colorVar('color.text.primary') }}>{accuracy.avgActualPct}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px` }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: `2px ${space(2)}px`, borderRadius: '999px', backgroundColor: `${accent}1A`, color: accent, ...typographyStyle('caption'), fontWeight: 700 }}>
|
||||
<Icon name={consistent ? 'check-circle' : 'alert'} size={13} />
|
||||
系统性偏差 {dev > 0 ? '+' : ''}{accuracy.avgDeviationPct} pp · {accuracy.bias}
|
||||
</span>
|
||||
</div>
|
||||
{!consistent && (
|
||||
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), marginTop: `${space(2)}px`, lineHeight: 1.6 }}>
|
||||
{accuracy.bias === '预测偏乐观' ? '建议下调报价预期或上调成本假设进行校准。' : '成本假设或偏保守,可适当下调目标基准。'}
|
||||
</div>
|
||||
)}
|
||||
{role === '管理层' && !consistent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
applyCalibration()
|
||||
.then((r) => { setError(null); setNotice(`已应用校准:目标净利率基准 ${(r.previousBase * 100).toFixed(1)}% → ${(r.appliedBase * 100).toFixed(1)}%`); loadAux(); })
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : '校准失败'));
|
||||
}}
|
||||
style={{ marginTop: `${space(3)}px`, display: 'inline-flex', alignItems: 'center', gap: 6, padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${accent}`, background: 'transparent', color: accent, cursor: 'pointer', fontFamily: FONT_FAMILY, ...typographyStyle('caption'), fontWeight: 600 }}
|
||||
>
|
||||
<Icon name="settings" size={14} /> 应用校准建议
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{expiring.length > 0 && (
|
||||
<div style={{ padding: `${space(4)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.lg}px`, boxShadow: '0 1px 2px rgba(16,24,40,0.04)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px`, marginBottom: `${space(2)}px` }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 8, backgroundColor: 'rgba(180,83,9,0.1)', color: '#B45309' }}>
|
||||
<Icon name="clock" size={18} />
|
||||
</span>
|
||||
<span style={{ ...typographyStyle('body'), fontWeight: 700, color: colorVar('color.text.primary') }}>即将到期({expiring.length})</span>
|
||||
</div>
|
||||
{expiring.slice(0, 3).map((e) => (
|
||||
<div key={e.id} style={{ ...typographyStyle('caption'), color: colorVar('color.text.primary'), padding: '3px 0' }}>{e.project.slice(0, 25)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{overdue.length > 0 && (
|
||||
<div style={{ padding: `${space(4)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.lg}px`, boxShadow: '0 1px 2px rgba(16,24,40,0.04)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px`, marginBottom: `${space(2)}px` }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 8, backgroundColor: 'rgba(190,18,60,0.1)', color: '#BE123C' }}>
|
||||
<Icon name="alert" size={18} />
|
||||
</span>
|
||||
<span style={{ ...typographyStyle('body'), fontWeight: 700, color: colorVar('color.text.primary') }}>审批超时({overdue.length})</span>
|
||||
</div>
|
||||
{overdue.slice(0, 3).map((e) => (
|
||||
<div key={e.id} style={{ ...typographyStyle('caption'), color: colorVar('color.text.primary'), padding: '3px 0' }}>{e.project.slice(0, 20)} · 超时 {e.overdueHours}h</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rejectStats.length > 0 && (() => {
|
||||
const maxCount = Math.max(...rejectStats.map((s) => s.count), 1);
|
||||
return (
|
||||
<div style={{ padding: `${space(4)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.lg}px`, boxShadow: '0 1px 2px rgba(16,24,40,0.04)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px`, marginBottom: `${space(3)}px` }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 8, backgroundColor: 'rgba(190,18,60,0.1)', color: '#BE123C' }}>
|
||||
<Icon name="ban" size={18} />
|
||||
</span>
|
||||
<span style={{ ...typographyStyle('body'), fontWeight: 700, color: colorVar('color.text.primary') }}>驳回 Top 原因</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(2)}px` }}>
|
||||
{rejectStats.slice(0, 4).map((s) => (
|
||||
<div key={s.reasonType} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', ...typographyStyle('caption'), color: colorVar('color.text.primary') }}>
|
||||
<span>{s.reasonType}</span>
|
||||
<span style={{ fontWeight: 700, color: '#BE123C' }}>{s.count} 次</span>
|
||||
</div>
|
||||
<div style={{ height: 6, borderRadius: 999, backgroundColor: colorVar('color.bg.surface'), overflow: 'hidden' }}>
|
||||
<div style={{ width: `${(s.count / maxCount) * 100}%`, height: '100%', borderRadius: 999, backgroundColor: '#BE123C', opacity: 0.85 }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error !== null && (
|
||||
<div style={{ padding: `${space(3)}px`, backgroundColor: 'rgba(190,18,60,0.08)', color: colorVar('color.risk.critical'), borderRadius: `${RADIUS.md}px`, marginBottom: `${space(3)}px` }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notice !== null && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: `${space(3)}px`, backgroundColor: 'rgba(16,128,61,0.08)', color: '#15803D', borderRadius: `${RADIUS.md}px`, marginBottom: `${space(3)}px`, fontWeight: 600 }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}><Icon name="check-circle" size={16} /> {notice}</span>
|
||||
<button type="button" onClick={() => setNotice(null)} style={{ display: 'inline-flex', border: 'none', background: 'transparent', color: '#15803D', cursor: 'pointer' }} aria-label="关闭"><Icon name="close" size={16} /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{todoItems.length > 0 && (
|
||||
<div style={{ marginBottom: `${space(4)}px` }}>
|
||||
<Card title={`待处理 (${todoCount})`}>
|
||||
<Table columns={columns} data={todoItems} getRowKey={(row) => row.id} caption={`${role} 待处理列表`} emptyMessage="当前没有需要你处理的评估" />
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{role === '商务/销售' && drafts.length > 0 && (
|
||||
<div style={{ marginBottom: `${space(4)}px` }}>
|
||||
<Card title={`草稿箱 (${drafts.length})`}>
|
||||
<p style={{ margin: `0 0 ${space(2)}px`, ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
|
||||
未运行/未提交的填写进度(服务端保存,跨设备)。「继续」回到向导接着填,运行评估后自动清除。
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(1)}px` }}>
|
||||
{drafts.map((d) => (
|
||||
<div key={d.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: `${space(2)}px`, padding: `${space(2)}px ${space(3)}px`, border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.md}px` }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
|
||||
<span style={{ ...typographyStyle('body'), color: colorVar('color.text.primary'), fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<Icon name={d.sourceAssessmentId !== null ? 'edit' : 'file'} size={15} color={colorVar('color.brand.primary')} />
|
||||
{d.sourceAssessmentId !== null ? '编辑草稿' : '新建草稿'}:{d.projectName ?? '未命名项目'}
|
||||
</span>
|
||||
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
|
||||
更新于 {formatDateTime(d.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: `${space(1)}px`, whiteSpace: 'nowrap' }}>
|
||||
<Button size="sm" onClick={() => navigate(`/new?draft=${encodeURIComponent(d.id)}`)}>继续</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { void removeDraft(d.id); }}>删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card
|
||||
title={
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(1)}px` }}>
|
||||
<span>评估历史</span>
|
||||
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), fontWeight: 400 }}>
|
||||
服务端分页 · 共 {total} 条
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: `${space(2)}px`, marginBottom: `${space(3)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, paddingBottom: `${space(2)}px` }}>
|
||||
{(['active', 'archived'] as const).map((v) => {
|
||||
const active = archivedView === v;
|
||||
return (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setArchivedView(v);
|
||||
setPage(1);
|
||||
}}
|
||||
style={{
|
||||
padding: `${space(1)}px ${space(3)}px`,
|
||||
borderRadius: '999px',
|
||||
border: `1px solid ${active ? colorVar('color.brand.primary') : colorVar('color.border.default')}`,
|
||||
backgroundColor: active ? colorVar('color.brand.primary') : 'transparent',
|
||||
color: active ? '#fff' : colorVar('color.text.secondary'),
|
||||
cursor: 'pointer',
|
||||
fontFamily: FONT_FAMILY,
|
||||
...typographyStyle('caption'),
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{v === 'active' ? '进行中' : `已归档${summary.archived ? ` (${summary.archived})` : ''}`}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'space-between', gap: `${space(3)}px`, marginBottom: `${space(3)}px` }}>
|
||||
<input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="搜索项目、业务类型、行业或发起人"
|
||||
style={{
|
||||
minWidth: 280,
|
||||
flex: '1 1 320px',
|
||||
padding: `${space(2)}px ${space(3)}px`,
|
||||
border: `1px solid ${colorVar('color.border.default')}`,
|
||||
borderRadius: `${RADIUS.md}px`,
|
||||
backgroundColor: colorVar('color.bg.canvas'),
|
||||
color: colorVar('color.text.primary'),
|
||||
fontFamily: FONT_FAMILY,
|
||||
...typographyStyle('body'),
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value as StatusFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
style={{
|
||||
minWidth: 160,
|
||||
padding: `${space(2)}px ${space(3)}px`,
|
||||
border: `1px solid ${colorVar('color.border.default')}`,
|
||||
borderRadius: `${RADIUS.md}px`,
|
||||
backgroundColor: colorVar('color.bg.canvas'),
|
||||
color: colorVar('color.text.primary'),
|
||||
fontFamily: FONT_FAMILY,
|
||||
...typographyStyle('body'),
|
||||
}}
|
||||
>
|
||||
{STATUS_FILTER_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 状态图例 */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: `${space(2)}px`, marginBottom: `${space(3)}px` }}>
|
||||
{(['draft', 'pending_risk_review', 'risk_reviewed', 'approved', 'rejected', 'abandoned'] as WorkflowStatus[]).map((s) => (
|
||||
<span key={s} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', backgroundColor: STATUS_STYLE[s].fg, display: 'inline-block' }} />
|
||||
{STATUS_LABEL[s]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: `${space(4)}px`, color: colorVar('color.text.secondary') }}>加载中…</div>
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={items}
|
||||
getRowKey={(row) => row.id}
|
||||
caption="全部评估记录列表"
|
||||
emptyMessage={total === 0 ? (archivedView === 'archived' ? '暂无已归档的评估' : (role === '商务/销售' ? '还没有评估。点击右上角「+ 新建评估」开始第一单。' : '暂无评估记录')) : '没有匹配筛选条件的评估'}
|
||||
/>
|
||||
<Pager
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onPage={setPage}
|
||||
onPageSize={(s) => {
|
||||
setPageSize(s);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 服务端分页器。 */
|
||||
function Pager({
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
pageSize,
|
||||
onPage,
|
||||
onPageSize,
|
||||
}: {
|
||||
readonly page: number;
|
||||
readonly totalPages: number;
|
||||
readonly total: number;
|
||||
readonly pageSize: number;
|
||||
readonly onPage: (p: number) => void;
|
||||
readonly onPageSize: (s: number) => void;
|
||||
}): JSX.Element {
|
||||
const btn = (label: string, to: number, disabled: boolean): JSX.Element => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onPage(to)}
|
||||
style={{
|
||||
padding: `${space(1)}px ${space(2)}px`,
|
||||
borderRadius: `${RADIUS.sm}px`,
|
||||
border: `1px solid ${colorVar('color.border.default')}`,
|
||||
background: 'transparent',
|
||||
color: disabled ? colorVar('color.text.secondary') : colorVar('color.text.primary'),
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
fontFamily: FONT_FAMILY,
|
||||
...typographyStyle('caption'),
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: `${space(2)}px`, marginTop: `${space(3)}px` }}>
|
||||
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
|
||||
共 {total} 条,第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px` }}>
|
||||
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>每页</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => onPageSize(Number(e.target.value))}
|
||||
style={{
|
||||
padding: `${space(1)}px ${space(2)}px`,
|
||||
borderRadius: `${RADIUS.sm}px`,
|
||||
border: `1px solid ${colorVar('color.border.default')}`,
|
||||
backgroundColor: colorVar('color.bg.canvas'),
|
||||
color: colorVar('color.text.primary'),
|
||||
fontFamily: FONT_FAMILY,
|
||||
...typographyStyle('caption'),
|
||||
}}
|
||||
>
|
||||
{[10, 20, 50].map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
{btn('首页', 1, page <= 1)}
|
||||
{btn('上一页', page - 1, page <= 1)}
|
||||
{btn('下一页', page + 1, page >= totalPages)}
|
||||
{btn('末页', totalPages, page >= totalPages)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 登录页面 — 3 个测试角色账号。
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
colorVar,
|
||||
FONT_FAMILY,
|
||||
RADIUS,
|
||||
SHADOW,
|
||||
space,
|
||||
typographyStyle,
|
||||
} from '../design-system/components/styles.js';
|
||||
import { useAuthStore, TEST_ACCOUNTS } from '../stores/authStore.js';
|
||||
|
||||
export function Login(): JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
const { login, error, clearError } = useAuthStore();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent): void => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
const ok = login(username, password);
|
||||
if (ok) {
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
const pageStyle: React.CSSProperties = {
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: `${space(5)}px`,
|
||||
padding: `${space(6)}px ${space(4)}px`,
|
||||
backgroundColor: colorVar('color.bg.canvas'),
|
||||
fontFamily: FONT_FAMILY,
|
||||
};
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
fontFamily: FONT_FAMILY,
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
padding: `${space(7)}px ${space(6)}px`,
|
||||
backgroundColor: colorVar('color.bg.elevated'),
|
||||
borderRadius: `${RADIUS.lg}px`,
|
||||
border: `1px solid ${colorVar('color.border.default')}`,
|
||||
boxShadow: SHADOW.md,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: `${space(2)}px ${space(3)}px`,
|
||||
border: `1px solid ${colorVar('color.border.default')}`,
|
||||
borderRadius: `${RADIUS.md}px`,
|
||||
fontFamily: FONT_FAMILY,
|
||||
...typographyStyle('body'),
|
||||
backgroundColor: colorVar('color.bg.canvas'),
|
||||
color: colorVar('color.text.primary'),
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: `${space(3)}px`,
|
||||
backgroundColor: colorVar('color.brand.primary'),
|
||||
color: colorVar('color.text.onAccent'),
|
||||
border: 'none',
|
||||
borderRadius: `${RADIUS.md}px`,
|
||||
cursor: 'pointer',
|
||||
...typographyStyle('body'),
|
||||
fontWeight: 600,
|
||||
letterSpacing: '-0.01em',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={pageStyle}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: `${space(3)}px` }}>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: `${RADIUS.lg}px`,
|
||||
background: `linear-gradient(135deg, ${colorVar('color.brand.primary')}, #7C83F0)`,
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 700,
|
||||
fontSize: '24px',
|
||||
boxShadow: SHADOW.sm,
|
||||
}}
|
||||
>
|
||||
风
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', display: 'flex', flexDirection: 'column', gap: `${space(1)}px` }}>
|
||||
<h1 style={{ ...typographyStyle('heading'), margin: 0, letterSpacing: '-0.02em', color: colorVar('color.text.primary') }}>
|
||||
外包项目风险评估
|
||||
</h1>
|
||||
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
|
||||
智能风险评估平台
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={cardStyle}>
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: `${space(3)}px` }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: `${space(1)}px`, color: colorVar('color.text.secondary'), ...typographyStyle('caption') }}>
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: `${space(1)}px`, color: colorVar('color.text.secondary'), ...typographyStyle('caption') }}>
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error !== null && (
|
||||
<div style={{ color: colorVar('color.risk.critical'), ...typographyStyle('caption'), textAlign: 'center' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" style={buttonStyle}>
|
||||
登录
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: `${space(5)}px`, paddingTop: `${space(4)}px`, borderTop: `1px solid ${colorVar('color.border.default')}` }}>
|
||||
<p style={{ margin: `0 0 ${space(3)}px`, color: colorVar('color.text.secondary'), ...typographyStyle('caption'), textAlign: 'center' }}>
|
||||
点击角色快速登录
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(2)}px` }}>
|
||||
{TEST_ACCOUNTS.map((a) => (
|
||||
<button
|
||||
key={a.username}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUsername(a.username);
|
||||
setPassword(a.password);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: `${space(2)}px ${space(3)}px`,
|
||||
backgroundColor: colorVar('color.bg.surface'),
|
||||
border: `1px solid ${colorVar('color.border.default')}`,
|
||||
borderRadius: `${RADIUS.md}px`,
|
||||
cursor: 'pointer',
|
||||
fontFamily: FONT_FAMILY,
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
color: colorVar('color.text.primary'),
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600 }}>{a.role}</span>
|
||||
<span style={{ color: colorVar('color.text.secondary'), ...typographyStyle('caption') }}>
|
||||
{a.username} / {a.password}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* 费率管理页面 — 地域费率套维护(与评估引擎对齐)。
|
||||
*
|
||||
* 专业设计:
|
||||
* - 以「地域费率套」为单位维护(一套 = 五险单位费率 + 公积金 + 增值税 + 附加税)
|
||||
* - 展示引擎内置默认费率(全国/上海/北京/广东)作为对照基准
|
||||
* - 与默认对比:偏离默认值的项高亮
|
||||
* - 复核流程:编辑即重置待复核,复核后驱动评估盈利测算
|
||||
* - 实时显示社保单位合计、用工成本加载估算
|
||||
*/
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* 合规红线规则管理 — 规则引擎风格,与评估引擎对齐。
|
||||
*
|
||||
* 设计:
|
||||
* - 后台维护的已启用红线会在评估时自动注入到模板 redlines(真正驱动评估)
|
||||
* - 严重等级(一般/严重/致命)+ 颜色编码
|
||||
* - 生效条件:地域 + 业务类型组合
|
||||
* - 详情弹窗展示完整信息 + 引擎对照
|
||||
* - 显示模板内置红线作为对照基准
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { colorVar, FONT_FAMILY, RADIUS, SHADOW, space, typographyStyle } from '../design-system/components/styles.js';
|
||||
import { Card, Icon } from '../design-system/index.js';
|
||||
import { fetchRedlineRules, createRedlineRule, deleteRedlineRuleApi, type RedlineRuleItem } from '../api/client.js';
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 严重等级
|
||||
* ------------------------------------------------------------------ */
|
||||
const SEVERITY = [
|
||||
{ value: '致命', label: '致命(一票否决)', color: '#BE123C', bg: 'rgba(190,18,60,0.08)' },
|
||||
{ value: '严重', label: '严重(需整改)', color: '#B45309', bg: 'rgba(180,83,9,0.08)' },
|
||||
{ value: '一般', label: '一般(关注)', color: '#2563EB', bg: 'rgba(37,99,235,0.08)' },
|
||||
] as const;
|
||||
|
||||
function inferSeverity(consequence: string): (typeof SEVERITY)[number] {
|
||||
if (/一票否决|立即停止|严禁|不得承接|违法/.test(consequence)) return SEVERITY[0];
|
||||
if (/整改|限期|必须|超限/.test(consequence)) return SEVERITY[1];
|
||||
return SEVERITY[2];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 页面
|
||||
* ------------------------------------------------------------------ */
|
||||
const REGIONS = ['', '全国', '上海', '北京', '广东', '深圳', '江苏', '浙江', '河北', '四川'];
|
||||
const BIZ_TYPES = ['', '岗位外包', '劳务派遣', '业务/服务外包', 'BPO', '项目制外包'];
|
||||
|
||||
/** 可自动判定的关联度量。 */
|
||||
const METRIC_OPTIONS = [
|
||||
{ value: '', label: '(人工核实,不自动判定)' },
|
||||
{ value: 'netMargin', label: '月净利率(%)' },
|
||||
{ value: 'grossMargin', label: '月毛利率(%)' },
|
||||
{ value: 'avgOverdueDays', label: '客户平均逾期天数(天)' },
|
||||
{ value: 'concentration', label: '单客户集中度(%)' },
|
||||
{ value: 'dispatchRatio', label: '派遣用工比例(%)' },
|
||||
{ value: 'belowMinWageCount', label: '低于最低工资的岗位数' },
|
||||
{ value: 'ind:qualification', label: '指标·资质与合规 等级(1-5)' },
|
||||
{ value: 'ind:data-security', label: '指标·数据安全 等级(1-5)' },
|
||||
{ value: 'ind:position-nature', label: '指标·岗位三性 等级(1-5)' },
|
||||
{ value: 'ind:dispatch-ratio', label: '指标·派遣比例 等级(1-5)' },
|
||||
{ value: 'ind:delivery-standard', label: '指标·交付SLA 等级(1-5)' },
|
||||
{ value: 'ind:injury-risk', label: '指标·工伤风险 等级(1-5)' },
|
||||
{ value: 'ind:customer-credit', label: '指标·客户资信 等级(1-5)' },
|
||||
] as const;
|
||||
const OP_OPTIONS = ['<', '<=', '>', '>='] as const;
|
||||
const METRIC_LABEL: Record<string, string> = {
|
||||
netMargin: '月净利率(%)', grossMargin: '月毛利率(%)', avgOverdueDays: '客户平均逾期天数(天)', concentration: '单客户集中度(%)',
|
||||
};
|
||||
/** 度量键 → 中文(含指标等级)。 */
|
||||
function metricLabelOf(key: string): string {
|
||||
const opt = METRIC_OPTIONS.find((o) => o.value === key);
|
||||
return opt?.label ?? METRIC_LABEL[key] ?? key;
|
||||
}
|
||||
/** 是否指标等级类度量(阈值按 1-5 等级)。 */
|
||||
function isLevelMetric(key: string): boolean {
|
||||
return key.startsWith('ind:');
|
||||
}
|
||||
|
||||
const EMPTY_FORM = { id: '', title: '', triggerCondition: '', consequence: '', region: '', businessType: '', regulationRef: '', severity: '严重' as string, linkedMetric: '', compareOp: '>' as string, threshold: '', linkedMetric2: '', compareOp2: '>' as string, threshold2: '' };
|
||||
|
||||
export function RedlineManagement(): JSX.Element {
|
||||
const [rules, setRules] = useState<RedlineRuleItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [detail, setDetail] = useState<RedlineRuleItem | null>(null);
|
||||
const [filterRegion, setFilterRegion] = useState('');
|
||||
const [filterBiz, setFilterBiz] = useState('');
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetchRedlineRules().then(setRules).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
if (!form.id || !form.title || !form.triggerCondition || !form.consequence) return;
|
||||
await createRedlineRule({
|
||||
id: form.id, title: form.title,
|
||||
triggerCondition: form.triggerCondition,
|
||||
consequence: form.consequence,
|
||||
region: form.region || null,
|
||||
businessType: form.businessType || null,
|
||||
enabled: true, version: 1,
|
||||
regulationRef: form.regulationRef || null,
|
||||
linkedMetric: form.linkedMetric ? form.linkedMetric : null,
|
||||
compareOp: form.linkedMetric ? (form.compareOp as '>=' | '<=' | '>' | '<') : null,
|
||||
threshold: form.linkedMetric && form.threshold !== '' ? Number(form.threshold) : null,
|
||||
linkedMetric2: form.linkedMetric && form.linkedMetric2 ? form.linkedMetric2 : null,
|
||||
compareOp2: form.linkedMetric && form.linkedMetric2 ? (form.compareOp2 as '>=' | '<=' | '>' | '<') : null,
|
||||
threshold2: form.linkedMetric && form.linkedMetric2 && form.threshold2 !== '' ? Number(form.threshold2) : null,
|
||||
});
|
||||
setForm(EMPTY_FORM); setShowForm(false); setEditing(false);
|
||||
load();
|
||||
}
|
||||
|
||||
function handleEdit(r: RedlineRuleItem): void {
|
||||
setForm({ id: r.id, title: r.title, triggerCondition: r.triggerCondition, consequence: r.consequence, region: r.region ?? '', businessType: r.businessType ?? '', regulationRef: r.regulationRef ?? '', severity: inferSeverity(r.consequence).value, linkedMetric: r.linkedMetric ?? '', compareOp: r.compareOp ?? '>', threshold: r.threshold != null ? String(r.threshold) : '', linkedMetric2: r.linkedMetric2 ?? '', compareOp2: r.compareOp2 ?? '>', threshold2: r.threshold2 != null ? String(r.threshold2) : '' });
|
||||
setEditing(true); setShowForm(true); setDetail(null);
|
||||
}
|
||||
|
||||
const filtered = rules.filter((r) => {
|
||||
if (filterRegion && (r.region ?? '') !== filterRegion && r.region !== null) return false;
|
||||
if (filterBiz && (r.businessType ?? '') !== filterBiz && r.businessType !== null) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: `${space(2)}px ${space(3)}px`,
|
||||
border: `1px solid ${colorVar('color.border.default')}`,
|
||||
borderRadius: `${RADIUS.md}px`,
|
||||
fontFamily: FONT_FAMILY, ...typographyStyle('body'),
|
||||
backgroundColor: colorVar('color.bg.canvas'), color: colorVar('color.text.primary'),
|
||||
width: '100%',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: FONT_FAMILY, maxWidth: 1200, margin: '0 auto' }}>
|
||||
{/* 页头 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: `${space(4)}px` }}>
|
||||
<div>
|
||||
<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` }}>
|
||||
定义合规底线。已启用红线在评估时自动注入引擎,触发即「一票否决」→ 不可接受。按地域/业务类型条件生效。
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => { setShowForm(!showForm); if (!showForm) { setForm(EMPTY_FORM); setEditing(false); } }} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: colorVar('color.brand.primary'), color: '#fff', cursor: 'pointer', fontWeight: 600 }}>
|
||||
{showForm ? '收起' : '+ 新增红线'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 新增/编辑表单 */}
|
||||
{showForm && (
|
||||
<Card title={editing ? '编辑红线规则' : '新增红线规则'}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: `${space(3)}px` }}>
|
||||
<FormField label="规则ID(唯一标识)"><input style={inputStyle} value={form.id} disabled={editing} onChange={(e) => setForm((f) => ({ ...f, id: e.target.value }))} placeholder="如 dispatch-ratio-limit" /></FormField>
|
||||
<FormField label="规则标题"><input style={inputStyle} value={form.title} onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))} placeholder="如 劳务派遣比例超限" /></FormField>
|
||||
<FormField label="触发条件(什么情况下命中)"><input style={inputStyle} value={form.triggerCondition} onChange={(e) => setForm((f) => ({ ...f, triggerCondition: e.target.value }))} placeholder="如 派遣人数/总用工 > 10%" /></FormField>
|
||||
<FormField label="触发后果(命中后怎么办)"><input style={inputStyle} value={form.consequence} onChange={(e) => setForm((f) => ({ ...f, consequence: e.target.value }))} placeholder="如 一票否决,不予承接" /></FormField>
|
||||
<FormField label="适用地域"><select style={inputStyle} value={form.region} onChange={(e) => setForm((f) => ({ ...f, region: e.target.value }))}>{REGIONS.map((r) => <option key={r} value={r}>{r || '全国通用'}</option>)}</select></FormField>
|
||||
<FormField label="适用业务类型"><select style={inputStyle} value={form.businessType} onChange={(e) => setForm((f) => ({ ...f, businessType: e.target.value }))}>{BIZ_TYPES.map((b) => <option key={b} value={b}>{b || '全部业务'}</option>)}</select></FormField>
|
||||
<FormField label="关联法规条文"><input style={inputStyle} value={form.regulationRef} onChange={(e) => setForm((f) => ({ ...f, regulationRef: e.target.value }))} placeholder="如 《劳务派遣暂行规定》第四条" /></FormField>
|
||||
</div>
|
||||
<div style={{ marginTop: `${space(3)}px`, padding: `${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px` }}>
|
||||
<div style={{ ...typographyStyle('caption'), fontWeight: 700, color: colorVar('color.text.primary'), marginBottom: `${space(2)}px` }}>
|
||||
自动判定条件(可选)— 绑定度量后,评估时引擎自动判定命中;留空则为人工核实「待核实」
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: `${space(3)}px` }}>
|
||||
<FormField label="关联度量">
|
||||
<select style={inputStyle} value={form.linkedMetric} onChange={(e) => setForm((f) => ({ ...f, linkedMetric: e.target.value }))}>
|
||||
{METRIC_OPTIONS.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="比较运算符">
|
||||
<select style={inputStyle} value={form.compareOp} disabled={!form.linkedMetric} onChange={(e) => setForm((f) => ({ ...f, compareOp: e.target.value }))}>
|
||||
{OP_OPTIONS.map((op) => <option key={op} value={op}>{op}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label={isLevelMetric(form.linkedMetric) ? '等级阈值(1-5)' : form.linkedMetric === 'avgOverdueDays' ? '阈值(天)' : '阈值(百分数,如 0 / 5 / 50)'}>
|
||||
<input style={inputStyle} value={form.threshold} disabled={!form.linkedMetric} inputMode="decimal" onChange={(e) => setForm((f) => ({ ...f, threshold: e.target.value }))} placeholder={isLevelMetric(form.linkedMetric) ? '如 5' : form.linkedMetric === 'avgOverdueDays' ? '如 90' : '如 0'} />
|
||||
</FormField>
|
||||
</div>
|
||||
{form.linkedMetric && (
|
||||
<div style={{ ...typographyStyle('caption'), color: colorVar('color.brand.primary'), marginTop: `${space(2)}px` }}>
|
||||
预览:当「{metricLabelOf(form.linkedMetric)} {form.compareOp} {form.threshold || '?'}{form.linkedMetric2 ? ` 且 ${metricLabelOf(form.linkedMetric2)} ${form.compareOp2} ${form.threshold2 || '?'}` : ''}」时自动命中。
|
||||
</div>
|
||||
)}
|
||||
{form.linkedMetric && (
|
||||
<div style={{ marginTop: `${space(3)}px` }}>
|
||||
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), marginBottom: `${space(2)}px` }}>
|
||||
第二条件(可选,与主条件「并且」组合,两者都满足才命中)
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: `${space(3)}px` }}>
|
||||
<FormField label="关联度量">
|
||||
<select style={inputStyle} value={form.linkedMetric2} onChange={(e) => setForm((f) => ({ ...f, linkedMetric2: e.target.value }))}>
|
||||
<option value="">(不设第二条件)</option>
|
||||
{METRIC_OPTIONS.filter((m) => m.value !== '').map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="比较运算符">
|
||||
<select style={inputStyle} value={form.compareOp2} disabled={!form.linkedMetric2} onChange={(e) => setForm((f) => ({ ...f, compareOp2: e.target.value }))}>
|
||||
{OP_OPTIONS.map((op) => <option key={op} value={op}>{op}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label={isLevelMetric(form.linkedMetric2) ? '等级阈值(1-5)' : form.linkedMetric2 === 'avgOverdueDays' ? '阈值(天)' : '阈值(百分数)'}>
|
||||
<input style={inputStyle} value={form.threshold2} disabled={!form.linkedMetric2} inputMode="decimal" onChange={(e) => setForm((f) => ({ ...f, threshold2: e.target.value }))} placeholder="如 0" />
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: `${space(2)}px`, marginTop: `${space(3)}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 }}>{editing ? '保存修改' : '添加'}</button>
|
||||
{editing && <button onClick={() => { setForm(EMPTY_FORM); setEditing(false); setShowForm(false); }} 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 style={{ display: 'flex', gap: `${space(3)}px`, marginTop: `${space(3)}px`, marginBottom: `${space(3)}px`, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<select style={{ ...inputStyle, width: 'auto', minWidth: 130 }} value={filterRegion} onChange={(e) => setFilterRegion(e.target.value)}>
|
||||
<option value="">全部地域</option>
|
||||
{REGIONS.filter(Boolean).map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
<select style={{ ...inputStyle, width: 'auto', minWidth: 140 }} value={filterBiz} onChange={(e) => setFilterBiz(e.target.value)}>
|
||||
<option value="">全部业务类型</option>
|
||||
{BIZ_TYPES.filter(Boolean).map((b) => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
|
||||
共 {filtered.length} 条规则{filterRegion ? ` · ${filterRegion}` : ''}{filterBiz ? ` · ${filterBiz}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 规则卡片 */}
|
||||
{loading ? <p style={{ color: colorVar('color.text.secondary') }}>加载中…</p> : filtered.length === 0 ? (
|
||||
<div style={{ padding: `${space(5)}px`, textAlign: 'center', color: colorVar('color.text.secondary') }}>
|
||||
暂无红线规则。已启用的红线会在评估时自动注入引擎判定。
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(380px, 1fr))', gap: `${space(3)}px` }}>
|
||||
{filtered.map((r) => {
|
||||
const sev = inferSeverity(r.consequence);
|
||||
return (
|
||||
<div key={r.id} onClick={() => setDetail(r)} style={{ padding: `${space(4)}px`, backgroundColor: colorVar('color.bg.elevated'), borderRadius: `${RADIUS.lg}px`, border: `1px solid ${colorVar('color.border.default')}`, borderLeft: `4px solid ${sev.color}`, cursor: 'pointer' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: `${space(2)}px` }}>
|
||||
<span style={{ ...typographyStyle('title'), fontWeight: 700, color: colorVar('color.text.primary') }}>{r.title}</span>
|
||||
<span style={{ padding: '2px 10px', borderRadius: '999px', backgroundColor: sev.bg, color: sev.color, ...typographyStyle('caption'), fontWeight: 700 }}>{sev.value}</span>
|
||||
</div>
|
||||
<div style={{ ...typographyStyle('body'), color: colorVar('color.text.secondary'), marginBottom: `${space(2)}px` }}>
|
||||
<strong>触发:</strong>{r.triggerCondition}
|
||||
</div>
|
||||
<div style={{ ...typographyStyle('caption'), color: sev.color, marginBottom: `${space(2)}px` }}>
|
||||
<strong>后果:</strong>{r.consequence}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: `${space(2)}px`, flexWrap: 'wrap' }}>
|
||||
<Tag>{r.region ?? '全国'}</Tag>
|
||||
<Tag>{r.businessType ?? '全部业务'}</Tag>
|
||||
{r.linkedMetric ? (
|
||||
<span style={{ ...typographyStyle('caption'), color: colorVar('color.brand.primary'), fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<Icon name="settings" size={13} /> 自动判定({metricLabelOf(r.linkedMetric)} {r.compareOp} {r.threshold}{r.linkedMetric2 ? ` 且 ${metricLabelOf(r.linkedMetric2)} ${r.compareOp2} ${r.threshold2}` : ''})
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="user" size={13} /> 人工核实</span>
|
||||
)}
|
||||
<span style={{ ...typographyStyle('caption'), color: r.enabled ? '#15803D' : colorVar('color.text.secondary'), display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: r.enabled ? '#15803D' : colorVar('color.text.secondary'), display: 'inline-block' }} />
|
||||
{r.enabled ? '启用(驱动评估)' : '停用'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
{detail !== null && (
|
||||
<div style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} onClick={() => setDetail(null)}>
|
||||
<div style={{ backgroundColor: colorVar('color.bg.elevated'), borderRadius: `${RADIUS.lg}px`, padding: `${space(5)}px`, maxWidth: 600, width: '90%', boxShadow: SHADOW.lg }} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: `${space(4)}px` }}>
|
||||
<h2 style={{ ...typographyStyle('heading'), margin: 0, color: colorVar('color.text.primary') }}>{detail.title}</h2>
|
||||
<span style={{ padding: '3px 12px', borderRadius: '999px', backgroundColor: inferSeverity(detail.consequence).bg, color: inferSeverity(detail.consequence).color, fontWeight: 700, ...typographyStyle('caption') }}>{inferSeverity(detail.consequence).value}</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: `${space(3)}px`, marginBottom: `${space(3)}px` }}>
|
||||
<DetailField label="规则ID" value={detail.id} />
|
||||
<DetailField label="状态" value={detail.enabled ? '已启用(参与评估)' : '已停用'} />
|
||||
<DetailField label="适用地域" value={detail.region ?? '全国通用'} />
|
||||
<DetailField label="适用业务" value={detail.businessType ?? '全部业务类型'} />
|
||||
</div>
|
||||
<DetailField label="触发条件" value={detail.triggerCondition} full />
|
||||
<DetailField label="触发后果" value={detail.consequence} full highlight />
|
||||
<DetailField
|
||||
label="自动判定条件"
|
||||
value={detail.linkedMetric ? `当 ${metricLabelOf(detail.linkedMetric)} ${detail.compareOp} ${detail.threshold}${detail.linkedMetric2 ? ` 且 ${metricLabelOf(detail.linkedMetric2)} ${detail.compareOp2} ${detail.threshold2}` : ''} 时自动命中` : '人工核实(无可计算条件,评估时标「待核实」)'}
|
||||
full
|
||||
/>
|
||||
{detail.regulationRef && <DetailField label="关联法规" value={detail.regulationRef} full />}
|
||||
<div style={{ padding: `${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px`, margin: `${space(3)}px 0`, ...typographyStyle('caption'), color: colorVar('color.text.secondary'), display: 'flex', alignItems: 'flex-start', gap: 6 }}>
|
||||
<Icon name="lightbulb" size={14} color={colorVar('color.brand.primary')} />
|
||||
<span>该红线已启用时,评估引擎在跑评时会自动将其注入该地域/业务类型的项目红线列表。若触发条件解析器尚未为该红线配置可计算谓词,则标注为「待核实」(不计命中)。</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: `${space(2)}px`, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => { handleEdit(detail); }} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${colorVar('color.brand.primary')}`, background: 'transparent', color: colorVar('color.brand.primary'), cursor: 'pointer', fontWeight: 600 }}>编辑</button>
|
||||
<button onClick={() => { deleteRedlineRuleApi(detail.id).then(load); setDetail(null); }} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: '#BE123C', color: '#fff', cursor: 'pointer', fontWeight: 600 }}>删除</button>
|
||||
<button onClick={() => setDetail(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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormField({ label, children }: { label: string; children: React.ReactNode }): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<label style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), display: 'block', marginBottom: 4 }}>{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailField({ label, value, full, highlight }: { label: string; value: string; full?: boolean; highlight?: boolean }): JSX.Element {
|
||||
return (
|
||||
<div style={{ ...(full ? { gridColumn: '1 / -1' } : {}), marginBottom: full ? `${space(2)}px` : 0 }}>
|
||||
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), marginBottom: 2 }}>{label}</div>
|
||||
<div style={{ ...typographyStyle('body'), color: highlight ? '#BE123C' : colorVar('color.text.primary'), fontWeight: highlight ? 600 : 400 }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tag({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
return (
|
||||
<span style={{ padding: '1px 8px', borderRadius: '999px', backgroundColor: colorVar('color.bg.surface'), color: colorVar('color.text.secondary'), ...typographyStyle('caption') }}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 方案对比区:在盈利分析下方并列展示已保存的多套报价方案。
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { colorVar, FONT_FAMILY, RADIUS, space, typographyStyle } from '../design-system/components/styles.js';
|
||||
import { fetchScenarios, type ScenarioItem } from '../api/client.js';
|
||||
|
||||
export function ScenarioCompare({ assessmentId }: { readonly assessmentId: string }): JSX.Element | null {
|
||||
const [scenarios, setScenarios] = useState<ScenarioItem[]>([]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetchScenarios(assessmentId).then(setScenarios).catch(() => setScenarios([]));
|
||||
}, [assessmentId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
if (scenarios.length === 0) return null;
|
||||
|
||||
const pct = (v: number): string => `${(v * 100).toFixed(1)}%`;
|
||||
const money = (v: number): string => `${v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元`;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: `${space(3)}px`, padding: `${space(3)}px`, backgroundColor: colorVar('color.bg.elevated'), borderRadius: `${RADIUS.lg}px`, border: `1px solid ${colorVar('color.border.default')}` }}>
|
||||
<div style={{ ...typographyStyle('title'), fontWeight: 700, color: colorVar('color.text.primary'), marginBottom: `${space(2)}px` }}>
|
||||
报价方案对比({scenarios.length} 套)
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', ...typographyStyle('caption'), fontFamily: FONT_FAMILY }}>
|
||||
<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'), whiteSpace: 'nowrap' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scenarios.map((s) => {
|
||||
const m = s.result.monthly;
|
||||
const netColor = m.netMargin >= 0.05 ? colorVar('color.risk.low') : m.netMargin >= 0 ? colorVar('color.risk.high') : colorVar('color.risk.critical');
|
||||
return (
|
||||
<tr key={s.id}>
|
||||
<td style={{ padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, fontWeight: 600 }}>{s.label}</td>
|
||||
<td style={{ padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}` }}>{s.result.pricingModel}</td>
|
||||
<td style={{ padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}` }}>{s.result.totalHeadcount}</td>
|
||||
<td style={{ padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}` }}>{money(m.revenueNet)}</td>
|
||||
<td style={{ padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}` }}>{money(m.totalCost)}</td>
|
||||
<td style={{ padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, color: netColor, fontWeight: 600 }}>{money(m.netProfit)}</td>
|
||||
<td style={{ padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, color: netColor, fontWeight: 600 }}>{pct(m.netMargin)}</td>
|
||||
<td style={{ padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}` }}>{money(s.result.cashflow?.maxAdvance ?? 0)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user