审批流程:用户名改为常见人名 + 按销售归属的审批线指派(软约束)

- 用户名改为常见人名(张伟/王芳/李娜/刘洋/陈静/赵磊/孙莉/周强)
- 审批线模型:每个销售→指定风控+管理层审批人(含默认线兜底),resolveAssignees 纯函数+4单测
- 提交时按销售归属计算并落库指派(assessment_assignments 表)
- 软约束:待办默认只看分给我的(含未指派),同角色他人仍可代审;详情页显示指派审批人
- 审批流程页新增「审批人指派·审批线」配置区(启用/默认线/按销售配线)
- 配置 GET/PUT 扩展 assignment;getApprovalConfig 向后兼容回填
This commit is contained in:
freedakgmail
2026-06-13 18:26:48 +08:00
parent 757b9c4a69
commit 11997e6104
11 changed files with 364 additions and 16 deletions
+14
View File
@@ -214,6 +214,7 @@ export function AssessmentDetail(): JSX.Element {
}
const [status, setStatus] = useState<WorkflowStatus>('pending_risk_review');
const [auditLog, setAuditLog] = useState<AuditLogEntry[]>([]);
const [assignment, setAssignment] = useState<{ riskReviewerName: string | null; managerName: string | null } | null>(null);
const [actionLoading, setActionLoading] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [comment, setComment] = useState('');
@@ -248,6 +249,7 @@ export function AssessmentDetail(): JSX.Element {
if (cancelled) return;
setStatus(data.status);
setAuditLog(data.auditLog);
setAssignment(data.assignment ?? null);
setProfitability(data.profitability ?? null);
setProfitabilityInputs(data.profitabilityInputs ?? null);
setExpiresAt(data.expiresAt ?? null);
@@ -630,6 +632,18 @@ export function AssessmentDetail(): JSX.Element {
);
})()}
{/* 指派审批人(按审批线) */}
{assignment !== null && (assignment.riskReviewerName !== null || assignment.managerName !== null) && (
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(3)}px`, flexWrap: 'wrap', padding: `${space(2)}px ${space(3)}px`, marginBottom: `${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.md}px` }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, ...typographyStyle('caption'), color: colorVar('color.text.secondary'), fontWeight: 700 }}>
<Icon name="user" size={14} />
</span>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.primary') }}><strong>{assignment.riskReviewerName ?? '未指派'}</strong></span>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.primary') }}><strong>{assignment.managerName ?? '未指派'}</strong></span>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}></span>
</div>
)}
{/* 操作记录(时间线,默认折叠) */}
<Collapsible title="操作记录" subtitle={`审批流程全程留痕 · ${auditLog.length}`}>
<AuditTimeline auditLog={auditLog} meta={meta} />
+52 -9
View File
@@ -17,8 +17,8 @@ import {
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 { fetchAssessmentsPage, fetchSummary, archiveAssessment, applyCalibration, listDrafts, deleteDraftApi, fetchAssignments, API_BASE } from '../api/client.js';
import type { AssessmentListItem, WorkflowStatus, DraftItem, AssignmentRecord } from '../api/client.js';
import { useAuthStore } from '../stores/authStore.js';
import { GuideBanner } from '../app/Guidance.js';
import { RiskBadge } from '../charts/index.js';
@@ -104,6 +104,9 @@ export function Dashboard(): JSX.Element {
// 待办列表
const [todoItems, setTodoItems] = useState<AssessmentListItem[]>([]);
// 审批人指派(assessmentId → 记录);待办软过滤用。
const [assignments, setAssignments] = useState<Record<string, AssignmentRecord>>({});
const [onlyMine, setOnlyMine] = useState(true);
// 草稿箱(销售:未运行/未提交的向导进度,服务端持久化)
const [drafts, setDrafts] = useState<DraftItem[]>([]);
// 统计
@@ -160,6 +163,12 @@ export function Dashboard(): JSX.Element {
} else {
setDrafts([]);
}
// 审批人指派(风控/管理层待办软过滤用)。
if (role === '风控' || role === '管理层') {
fetchAssignments().then(setAssignments).catch(() => setAssignments({}));
} else {
setAssignments({});
}
}, [role, user?.username]);
/** 删除一条草稿并刷新草稿箱。 */
@@ -507,13 +516,47 @@ export function Dashboard(): JSX.Element {
</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>
)}
{todoItems.length > 0 && (() => {
const myName = user?.username;
const assignedToMe = (id: string): boolean => {
const a = assignments[id];
if (a === undefined) return false;
return role === '风控' ? a.riskReviewerName === myName : role === '管理层' ? a.managerName === myName : false;
};
const isAssigned = (id: string): boolean => {
const a = assignments[id];
if (a === undefined) return false;
return role === '风控' ? a.riskReviewerName !== null : role === '管理层' ? a.managerName !== null : false;
};
// 软约束:默认只看分给我的;未指派的也展示(避免遗漏)。
const shown = onlyMine ? todoItems.filter((t) => assignedToMe(t.id) || !isAssigned(t.id)) : todoItems;
const assignCol: TableColumn<AssessmentListItem> = {
key: 'assignee',
header: '指派审批人',
render: (r) => {
const a = assignments[r.id];
const name = a !== undefined ? (role === '管理层' ? a.managerName : a.riskReviewerName) : null;
if (name === null || name === undefined) return <span style={{ color: colorVar('color.text.secondary') }}></span>;
const mine = name === myName;
return <span style={{ ...typographyStyle('caption'), fontWeight: 600, color: mine ? '#15803D' : colorVar('color.text.primary') }}>{name}{mine ? '(我)' : ''}</span>;
},
};
const todoColumns = [...columns.slice(0, -1), assignCol, columns[columns.length - 1]!];
return (
<div style={{ marginBottom: `${space(4)}px` }}>
<Card title={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: `${space(2)}px` }}>
<span> ({shown.length})</span>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, ...typographyStyle('caption'), color: colorVar('color.text.secondary'), fontWeight: 400 }}>
<input type="checkbox" checked={onlyMine} onChange={(e) => setOnlyMine(e.target.checked)} />
</label>
</div>
}>
<Table columns={todoColumns} data={shown} getRowKey={(row) => row.id} caption={`${role} 待处理列表`} emptyMessage={onlyMine ? '没有分给你的待处理项(可取消勾选查看全部)' : '当前没有需要你处理的评估'} />
</Card>
</div>
);
})()}
{role === '商务/销售' && drafts.length > 0 && (
<div style={{ marginBottom: `${space(4)}px` }}>
+66 -3
View File
@@ -9,8 +9,9 @@ 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 {
fetchApprovalConfig, saveApprovalConfig,
fetchApprovalConfig, saveApprovalConfig, listUsers,
type ApprovalConfig, type ApprovalRule, type ApprovalCondition, type ApprovalField, type ApprovalOp,
type ApprovalLine, type UserItem,
} from '../api/client.js';
const FIELD_META: Record<ApprovalField, { label: string; kind: 'grade' | 'number' | 'accept' | 'bool' | 'biz'; unit?: string }> = {
@@ -39,6 +40,7 @@ function defaultValueFor(field: ApprovalField): string | number | boolean {
export function WorkflowManagement(): JSX.Element {
const [config, setConfig] = useState<ApprovalConfig | null>(null);
const [users, setUsers] = useState<UserItem[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -46,7 +48,10 @@ export function WorkflowManagement(): JSX.Element {
const load = useCallback(() => {
setLoading(true);
fetchApprovalConfig().then(setConfig).catch((e: unknown) => setError(e instanceof Error ? e.message : '加载失败')).finally(() => setLoading(false));
Promise.all([fetchApprovalConfig(), listUsers().catch(() => [])])
.then(([cfg, us]) => { setConfig(cfg); setUsers(us); })
.catch((e: unknown) => setError(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
@@ -82,6 +87,21 @@ export function WorkflowManagement(): JSX.Element {
setConfig((c) => c ? { ...c, rules: c.rules.map((r, i) => i !== ri ? r : { ...r, conditions: r.conditions.filter((_, j) => j !== ci) }) } : c);
}
/* ---- 审批人指派(审批线) ---- */
function patchAssign(p: Partial<ApprovalConfig['assignment']>): void {
setConfig((c) => (c ? { ...c, assignment: { ...c.assignment, ...p } } : c));
}
function addLine(): void {
const firstSales = users.find((u) => u.role === '商务/销售');
setConfig((c) => c ? { ...c, assignment: { ...c.assignment, lines: [...c.assignment.lines, { salesId: firstSales?.id ?? '', riskReviewerId: null, managerId: null }] } } : c);
}
function patchLine(idx: number, p: Partial<ApprovalLine>): void {
setConfig((c) => c ? { ...c, assignment: { ...c.assignment, lines: c.assignment.lines.map((l, i) => i === idx ? { ...l, ...p } : l) } } : c);
}
function removeLine(idx: number): void {
setConfig((c) => c ? { ...c, assignment: { ...c.assignment, lines: c.assignment.lines.filter((_, i) => i !== idx) } } : c);
}
async function handleSave(): Promise<void> {
if (config === null) return;
setSaving(true); setError(null);
@@ -96,6 +116,12 @@ export function WorkflowManagement(): JSX.Element {
backgroundColor: colorVar('color.bg.canvas'), color: colorVar('color.text.primary'),
};
const lbl: React.CSSProperties = { ...typographyStyle('caption'), color: colorVar('color.text.secondary'), marginBottom: 2 };
function userOpts(role: UserItem['role']): JSX.Element[] {
const opts = users.filter((u) => u.role === role && u.active).map((u) => <option key={u.id} value={u.id}>{u.username}{u.displayName ?? role}</option>);
return [<option key="" value=""></option>, ...opts];
}
function valueEditor(cd: ApprovalCondition, onChange: (v: string | number | boolean) => void): JSX.Element {
const kind = FIELD_META[cd.field].kind;
if (kind === 'grade') return <select style={input} value={String(cd.value)} onChange={(e) => onChange(e.target.value)}>{GRADES.map((g) => <option key={g} value={g}>{g}</option>)}</select>;
@@ -150,7 +176,44 @@ export function WorkflowManagement(): JSX.Element {
</div>
</Card>
{/* 规则列表 */}
{/* 审批人指派(审批线,软约束) */}
<div style={{ marginTop: `${space(4)}px` }}>
<Card title={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span> · 线</span>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, ...typographyStyle('caption'), color: colorVar('color.text.secondary'), fontWeight: 400 }}>
<input type="checkbox" checked={config.assignment.enabled} onChange={(e) => patchAssign({ enabled: e.target.checked })} />
</label>
</div>
}>
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), margin: `0 0 ${space(3)}px`, lineHeight: 1.7 }}>
<strong></strong>线
{!config.assignment.enabled && <span style={{ color: '#B45309' }}></span>}
</p>
{/* 默认审批线 */}
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr 1fr', gap: `${space(2)}px`, alignItems: 'center', padding: `${space(2)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px`, marginBottom: `${space(2)}px` }}>
<span style={{ ...typographyStyle('caption'), fontWeight: 700, color: colorVar('color.text.secondary') }}>线</span>
<div><div style={lbl}></div><select style={{ ...input, width: '100%' }} value={config.assignment.defaultRiskReviewerId ?? ''} onChange={(e) => patchAssign({ defaultRiskReviewerId: e.target.value || null })}>{userOpts('风控')}</select></div>
<div><div style={lbl}></div><select style={{ ...input, width: '100%' }} value={config.assignment.defaultManagerId ?? ''} onChange={(e) => patchAssign({ defaultManagerId: e.target.value || null })}>{userOpts('管理层')}</select></div>
</div>
{/* 每个销售一条线 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(2)}px` }}>
{config.assignment.lines.map((ln, i) => (
<div key={i} style={{ display: 'grid', gridTemplateColumns: '120px 1fr 1fr 32px', gap: `${space(2)}px`, alignItems: 'end' }}>
<div><div style={lbl}></div><select style={{ ...input, width: '100%' }} value={ln.salesId} onChange={(e) => patchLine(i, { salesId: e.target.value })}>{userOpts('商务/销售')}</select></div>
<div><div style={lbl}></div><select style={{ ...input, width: '100%' }} value={ln.riskReviewerId ?? ''} onChange={(e) => patchLine(i, { riskReviewerId: e.target.value || null })}>{userOpts('风控')}</select></div>
<div><div style={lbl}></div><select style={{ ...input, width: '100%' }} value={ln.managerId ?? ''} onChange={(e) => patchLine(i, { managerId: e.target.value || null })}>{userOpts('管理层')}</select></div>
<button onClick={() => removeLine(i)} title="删除审批线" style={{ ...iconBtn, color: colorVar('color.risk.critical') }}><Icon name="close" size={15} /></button>
</div>
))}
</div>
<button onClick={addLine} style={{ marginTop: `${space(2)}px`, display: 'inline-flex', alignItems: 'center', gap: 4, border: 'none', background: 'none', color: colorVar('color.brand.primary'), cursor: 'pointer', ...typographyStyle('caption'), fontWeight: 600 }}>
<Icon name="plus" size={13} /> 线
</button>
</Card>
</div>
<div style={{ marginTop: `${space(4)}px`, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h2 style={{ ...typographyStyle('title'), margin: 0, color: colorVar('color.text.primary') }}>{config.rules.length}</h2>
<button onClick={addRule} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${colorVar('color.brand.primary')}`, background: 'transparent', color: colorVar('color.brand.primary'), cursor: 'pointer', fontWeight: 600, ...typographyStyle('caption') }}>