审批流程:用户名改为常见人名 + 按销售归属的审批线指派(软约束)
- 用户名改为常见人名(张伟/王芳/李娜/刘洋/陈静/赵磊/孙莉/周强) - 审批线模型:每个销售→指定风控+管理层审批人(含默认线兜底),resolveAssignees 纯函数+4单测 - 提交时按销售归属计算并落库指派(assessment_assignments 表) - 软约束:待办默认只看分给我的(含未指派),同角色他人仍可代审;详情页显示指派审批人 - 审批流程页新增「审批人指派·审批线」配置区(启用/默认线/按销售配线) - 配置 GET/PUT 扩展 assignment;getApprovalConfig 向后兼容回填
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* 审批人指派:记录每个评估被指派的风控审批人与管理层审批人(按审批线在提交时计算)。
|
||||
* 软约束——仅作展示/过滤与审计,不强制拦截同角色其他人代审。
|
||||
*/
|
||||
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
CREATE TABLE IF NOT EXISTS assessment_assignments (
|
||||
assessment_id TEXT PRIMARY KEY,
|
||||
risk_reviewer_id TEXT,
|
||||
risk_reviewer_name TEXT,
|
||||
manager_id TEXT,
|
||||
manager_name TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`DROP TABLE IF EXISTS assessment_assignments;`);
|
||||
};
|
||||
@@ -4,12 +4,17 @@
|
||||
import type pg from 'pg';
|
||||
import { DEFAULT_APPROVAL_CONFIG, type ApprovalConfig } from '../strategy/approvalRules.js';
|
||||
|
||||
/** 读取审批配置;不存在则返回默认配置。 */
|
||||
/** 读取审批配置;不存在则返回默认配置。缺失字段用默认值回填(向后兼容旧配置)。 */
|
||||
export async function getApprovalConfig(pool: pg.Pool): Promise<ApprovalConfig> {
|
||||
const res = await pool.query('SELECT config FROM approval_config WHERE id = 1');
|
||||
const row = (res.rows as Array<{ config?: unknown }>)[0];
|
||||
if (!row || row.config == null) return DEFAULT_APPROVAL_CONFIG;
|
||||
return row.config as ApprovalConfig;
|
||||
const stored = row.config as Partial<ApprovalConfig>;
|
||||
return {
|
||||
...DEFAULT_APPROVAL_CONFIG,
|
||||
...stored,
|
||||
assignment: { ...DEFAULT_APPROVAL_CONFIG.assignment, ...(stored.assignment ?? {}) },
|
||||
} as ApprovalConfig;
|
||||
}
|
||||
|
||||
/** 保存审批配置(upsert 单行)。 */
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 审批人指派持久化:评估 → 指定风控/管理层审批人。
|
||||
*/
|
||||
import type pg from 'pg';
|
||||
|
||||
export interface AssignmentRecord {
|
||||
assessmentId: string;
|
||||
riskReviewerId: string | null;
|
||||
riskReviewerName: string | null;
|
||||
managerId: string | null;
|
||||
managerName: string | null;
|
||||
}
|
||||
|
||||
function mapRow(r: Record<string, unknown>): AssignmentRecord {
|
||||
return {
|
||||
assessmentId: String(r.assessment_id),
|
||||
riskReviewerId: r.risk_reviewer_id != null ? String(r.risk_reviewer_id) : null,
|
||||
riskReviewerName: r.risk_reviewer_name != null ? String(r.risk_reviewer_name) : null,
|
||||
managerId: r.manager_id != null ? String(r.manager_id) : null,
|
||||
managerName: r.manager_name != null ? String(r.manager_name) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 写入/更新某评估的指派。 */
|
||||
export async function setAssignment(pool: pg.Pool, a: AssignmentRecord): Promise<void> {
|
||||
await pool.query(
|
||||
`INSERT INTO assessment_assignments(assessment_id, risk_reviewer_id, risk_reviewer_name, manager_id, manager_name, updated_at)
|
||||
VALUES($1,$2,$3,$4,$5,now())
|
||||
ON CONFLICT(assessment_id) DO UPDATE SET
|
||||
risk_reviewer_id=EXCLUDED.risk_reviewer_id, risk_reviewer_name=EXCLUDED.risk_reviewer_name,
|
||||
manager_id=EXCLUDED.manager_id, manager_name=EXCLUDED.manager_name, updated_at=now()`,
|
||||
[a.assessmentId, a.riskReviewerId, a.riskReviewerName, a.managerId, a.managerName],
|
||||
);
|
||||
}
|
||||
|
||||
/** 取某评估的指派。 */
|
||||
export async function getAssignment(pool: pg.Pool, assessmentId: string): Promise<AssignmentRecord | null> {
|
||||
const res = await pool.query('SELECT * FROM assessment_assignments WHERE assessment_id=$1', [assessmentId]);
|
||||
const r = (res.rows as Array<Record<string, unknown>>)[0];
|
||||
return r ? mapRow(r) : null;
|
||||
}
|
||||
|
||||
/** 全部指派(assessmentId → 记录),供列表/看板合并。 */
|
||||
export async function loadAllAssignments(pool: pg.Pool): Promise<Record<string, AssignmentRecord>> {
|
||||
const res = await pool.query('SELECT * FROM assessment_assignments');
|
||||
const out: Record<string, AssignmentRecord> = {};
|
||||
for (const r of res.rows as Array<Record<string, unknown>>) {
|
||||
const rec = mapRow(r);
|
||||
out[rec.assessmentId] = rec;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export * from './minWages.js';
|
||||
export * from './drafts.js';
|
||||
export * from './users.js';
|
||||
export * from './approvalConfig.js';
|
||||
export * from './assignments.js';
|
||||
export * from './settings.js';
|
||||
export * from './regionRates.js';
|
||||
export * from './rejectReasons.js';
|
||||
|
||||
+54
-1
@@ -75,6 +75,9 @@ import {
|
||||
getApprovalConfig,
|
||||
saveApprovalConfig,
|
||||
ensureApprovalConfig,
|
||||
setAssignment,
|
||||
getAssignment,
|
||||
loadAllAssignments,
|
||||
getSetting,
|
||||
setSetting,
|
||||
loadAllRegionRates,
|
||||
@@ -104,7 +107,7 @@ import { buildMetricRedline, checkRedlines, type ComputableRedlineConfig, type R
|
||||
import { generate, exportReport } from '../report/index.js';
|
||||
import { analyzeProfitability, type ProfitabilityInputs } from '../cost/index.js';
|
||||
import { NATIONAL_DEFAULT_RATES, REGION_RATES } from '../cost/index.js';
|
||||
import { combineRecommendation, DEFAULT_TARGET_NET_MARGIN, buildOperatingControls, decide, hasRedlineHit, targetMarginForGrade, evaluateApproval, DEFAULT_APPROVAL_CONFIG, type ApprovalConfig, type ApprovalRule, type ApprovalCondition } from '../strategy/index.js';
|
||||
import { combineRecommendation, DEFAULT_TARGET_NET_MARGIN, buildOperatingControls, decide, hasRedlineHit, targetMarginForGrade, evaluateApproval, resolveAssignees, DEFAULT_APPROVAL_CONFIG, type ApprovalConfig, type ApprovalRule, type ApprovalCondition, type ApprovalLine } from '../strategy/index.js';
|
||||
import type { RiskLevel } from '../domain/common.js';
|
||||
import type { KnownData } from '../question/index.js';
|
||||
import type { Region } from '../domain/region.js';
|
||||
@@ -1326,11 +1329,31 @@ app.put('/api/approval-config', requireRole('系统管理员'), async (c) => {
|
||||
value: cd.value,
|
||||
})) : [],
|
||||
})),
|
||||
assignment: {
|
||||
enabled: body.assignment?.enabled === true,
|
||||
defaultRiskReviewerId: body.assignment?.defaultRiskReviewerId ?? null,
|
||||
defaultManagerId: body.assignment?.defaultManagerId ?? null,
|
||||
lines: Array.isArray(body.assignment?.lines)
|
||||
? body.assignment.lines
|
||||
.filter((l: ApprovalLine) => typeof l.salesId === 'string' && l.salesId !== '')
|
||||
.map((l: ApprovalLine) => ({
|
||||
salesId: l.salesId,
|
||||
riskReviewerId: l.riskReviewerId ?? null,
|
||||
managerId: l.managerId ?? null,
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
};
|
||||
await saveApprovalConfig(pool, cfg);
|
||||
return c.json(cfg);
|
||||
});
|
||||
|
||||
/** 全部评估的审批人指派(assessmentId → 风控/管理层审批人),供看板与详情合并展示。 */
|
||||
app.get('/api/assignments', async (c) => {
|
||||
if (pool === null) return c.json({});
|
||||
return c.json(await loadAllAssignments(pool));
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/region-rates/engine-defaults
|
||||
* 引擎内置默认费率(全国 + 上海/北京/广东),作为维护对照基准。
|
||||
@@ -1670,6 +1693,8 @@ app.get('/api/assessments/:id', async (c) => {
|
||||
const ev = (er.rows[0] as { expires_at?: unknown } | undefined)?.expires_at;
|
||||
if (ev != null) expiresAt = ev instanceof Date ? ev.toISOString() : String(ev);
|
||||
}
|
||||
// 审批人指派(按审批线,软约束)。
|
||||
const assignment = pool !== null ? await getAssignment(pool, id).catch(() => null) : null;
|
||||
return c.json({
|
||||
assessment: record.assessment,
|
||||
report: record.report,
|
||||
@@ -1684,6 +1709,7 @@ app.get('/api/assessments/:id', async (c) => {
|
||||
redlineTitles,
|
||||
profitabilityInputs: profitabilityInputsById.get(id) ?? null,
|
||||
expiresAt,
|
||||
assignment,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2221,6 +2247,32 @@ app.post('/api/assessments/:id/redline-verdict', requireRole('风控', '管理
|
||||
return c.json({ redlineId: body.redlineId, status: body.status, acceptability: a.acceptability });
|
||||
});
|
||||
|
||||
/** 申报:销售将「草稿待申报」评估报送风控审核。 */
|
||||
/**
|
||||
* 按审批线为评估指派审批人(软约束,仅展示/过滤/审计)。
|
||||
* 据提交销售解析审批线 → 指定风控/管理层;写入 assessment_assignments。
|
||||
*/
|
||||
async function assignApprovers(id: string, salesUsername: string): Promise<void> {
|
||||
if (pool === null) return;
|
||||
const cfg = await getApprovalConfig(pool);
|
||||
if (!cfg.assignment.enabled) return;
|
||||
const sales = await getUserByUsername(pool, salesUsername);
|
||||
const { riskReviewerId, managerId } = resolveAssignees(cfg.assignment, sales?.id);
|
||||
const users = await listUsers(pool);
|
||||
const nameOf = (uid: string | null): string | null => {
|
||||
if (uid === null) return null;
|
||||
const u = users.find((x) => x.id === uid);
|
||||
return u ? u.username : null;
|
||||
};
|
||||
await setAssignment(pool, {
|
||||
assessmentId: id,
|
||||
riskReviewerId,
|
||||
riskReviewerName: nameOf(riskReviewerId),
|
||||
managerId,
|
||||
managerName: nameOf(managerId),
|
||||
});
|
||||
}
|
||||
|
||||
/** 申报:销售将「草稿待申报」评估报送风控审核。 */
|
||||
app.post('/api/assessments/:id/submit', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
@@ -2244,6 +2296,7 @@ app.post('/api/assessments/:id/submit', async (c) => {
|
||||
if (pool !== null) {
|
||||
await persistStatus(pool, id, 'pending_risk_review');
|
||||
await persistAudit(pool, id, submitEntry);
|
||||
await assignApprovers(id, body.user).catch(() => undefined);
|
||||
}
|
||||
return c.json({ status: 'pending_risk_review' });
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { evaluateApproval, DEFAULT_APPROVAL_CONFIG, type ApprovalConfig } from '../approvalRules.js';
|
||||
import { evaluateApproval, resolveAssignees, DEFAULT_APPROVAL_CONFIG, type ApprovalConfig } from '../approvalRules.js';
|
||||
|
||||
describe('审批规则引擎 evaluateApproval', () => {
|
||||
it('命中红线 → 需管理层', () => {
|
||||
@@ -63,3 +63,31 @@ describe('审批规则引擎 evaluateApproval', () => {
|
||||
expect(d.requireManagement).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('审批线指派 resolveAssignees', () => {
|
||||
const base = {
|
||||
enabled: true,
|
||||
defaultRiskReviewerId: 'r-default',
|
||||
defaultManagerId: 'm-default',
|
||||
lines: [
|
||||
{ salesId: 's1', riskReviewerId: 'r1', managerId: 'm1' },
|
||||
{ salesId: 's2', riskReviewerId: 'r2', managerId: null },
|
||||
],
|
||||
};
|
||||
|
||||
it('匹配到销售审批线 → 用线上指定审批人', () => {
|
||||
expect(resolveAssignees(base, 's1')).toEqual({ riskReviewerId: 'r1', managerId: 'm1' });
|
||||
});
|
||||
|
||||
it('线上某项为空 → 回退默认', () => {
|
||||
expect(resolveAssignees(base, 's2')).toEqual({ riskReviewerId: 'r2', managerId: 'm-default' });
|
||||
});
|
||||
|
||||
it('未匹配销售 → 用默认审批线', () => {
|
||||
expect(resolveAssignees(base, 'sX')).toEqual({ riskReviewerId: 'r-default', managerId: 'm-default' });
|
||||
});
|
||||
|
||||
it('未启用 → 不指派', () => {
|
||||
expect(resolveAssignees({ ...base, enabled: false }, 's1')).toEqual({ riskReviewerId: null, managerId: null });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,24 @@ export interface ApprovalRule {
|
||||
readonly conditions: readonly ApprovalCondition[];
|
||||
}
|
||||
|
||||
/** 审批人指派——单条审批线(某销售 → 指定风控/管理层)。 */
|
||||
export interface ApprovalLine {
|
||||
readonly salesId: string;
|
||||
readonly riskReviewerId: string | null;
|
||||
readonly managerId: string | null;
|
||||
}
|
||||
|
||||
/** 审批人指派配置(按销售归属)。 */
|
||||
export interface ApprovalAssignment {
|
||||
/** 是否启用指派(关闭时不指派,任何同角色都可处理)。 */
|
||||
readonly enabled: boolean;
|
||||
/** 默认审批线(未匹配到销售时兜底)。 */
|
||||
readonly defaultRiskReviewerId: string | null;
|
||||
readonly defaultManagerId: string | null;
|
||||
/** 按销售归属的审批线。 */
|
||||
readonly lines: readonly ApprovalLine[];
|
||||
}
|
||||
|
||||
/** 审批流程配置。 */
|
||||
export interface ApprovalConfig {
|
||||
/** 无规则命中时是否需要管理层审批(兜底)。 */
|
||||
@@ -47,6 +65,8 @@ export interface ApprovalConfig {
|
||||
readonly rejectTo: 'origin' | 'risk';
|
||||
/** 有序规则集(按顺序匹配,第一条命中生效)。 */
|
||||
readonly rules: readonly ApprovalRule[];
|
||||
/** 审批人指派(按销售归属审批线,软约束)。 */
|
||||
readonly assignment: ApprovalAssignment;
|
||||
}
|
||||
|
||||
/** 评估上下文(供规则匹配)。 */
|
||||
@@ -83,8 +103,27 @@ export const DEFAULT_APPROVAL_CONFIG: ApprovalConfig = {
|
||||
{ id: 'rule-bigcontract', name: '合同额≥100万必过管理层', enabled: true, requireManagement: true, conditions: [{ field: 'contractAmount', op: '>=', value: 1000000 }] },
|
||||
{ id: 'rule-lowrisk', name: '低风险且达标→风控终批', enabled: true, requireManagement: false, conditions: [{ field: 'riskGrade', op: '<=', value: '中' }] },
|
||||
],
|
||||
assignment: {
|
||||
enabled: false,
|
||||
defaultRiskReviewerId: null,
|
||||
defaultManagerId: null,
|
||||
lines: [],
|
||||
},
|
||||
};
|
||||
|
||||
/** 按销售归属解析该项目的指定审批人(风控/管理层);未匹配用默认线。 */
|
||||
export function resolveAssignees(
|
||||
assignment: ApprovalAssignment,
|
||||
salesId: string | undefined,
|
||||
): { riskReviewerId: string | null; managerId: string | null } {
|
||||
if (!assignment.enabled) return { riskReviewerId: null, managerId: null };
|
||||
const line = salesId !== undefined ? assignment.lines.find((l) => l.salesId === salesId) : undefined;
|
||||
return {
|
||||
riskReviewerId: line?.riskReviewerId ?? assignment.defaultRiskReviewerId,
|
||||
managerId: line?.managerId ?? assignment.defaultManagerId,
|
||||
};
|
||||
}
|
||||
|
||||
/** 取条件左值(从上下文)。riskGrade 转为序号以支持数值比较。 */
|
||||
function leftValue(field: ApprovalField, ctx: ApprovalContext): string | number | boolean | undefined {
|
||||
switch (field) {
|
||||
|
||||
@@ -471,6 +471,8 @@ export interface AssessmentDetailResponse {
|
||||
readonly profitabilityInputs?: ProfitabilityInputs | null;
|
||||
/** 评估有效期(到期需重新评估)。 */
|
||||
readonly expiresAt?: string | null;
|
||||
/** 审批人指派(按审批线,软约束)。 */
|
||||
readonly assignment?: { riskReviewerName: string | null; managerName: string | null } | null;
|
||||
}
|
||||
|
||||
/** 获取单条评估详情。 */
|
||||
@@ -861,12 +863,38 @@ export interface ApprovalRule {
|
||||
requireManagement: boolean;
|
||||
conditions: ApprovalCondition[];
|
||||
}
|
||||
export interface ApprovalLine {
|
||||
salesId: string;
|
||||
riskReviewerId: string | null;
|
||||
managerId: string | null;
|
||||
}
|
||||
export interface ApprovalAssignment {
|
||||
enabled: boolean;
|
||||
defaultRiskReviewerId: string | null;
|
||||
defaultManagerId: string | null;
|
||||
lines: ApprovalLine[];
|
||||
}
|
||||
export interface ApprovalConfig {
|
||||
defaultRequireManagement: boolean;
|
||||
slaRiskHours: number;
|
||||
slaMgmtHours: number;
|
||||
rejectTo: 'origin' | 'risk';
|
||||
rules: ApprovalRule[];
|
||||
assignment: ApprovalAssignment;
|
||||
}
|
||||
|
||||
/** 审批人指派记录。 */
|
||||
export interface AssignmentRecord {
|
||||
assessmentId: string;
|
||||
riskReviewerId: string | null;
|
||||
riskReviewerName: string | null;
|
||||
managerId: string | null;
|
||||
managerName: string | null;
|
||||
}
|
||||
|
||||
/** 全部评估的审批人指派(assessmentId → 记录)。 */
|
||||
export async function fetchAssignments(): Promise<Record<string, AssignmentRecord>> {
|
||||
return request<Record<string, AssignmentRecord>>('GET', '/api/assignments');
|
||||
}
|
||||
|
||||
/** 读取审批流程配置。 */
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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` }}>
|
||||
|
||||
@@ -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') }}>
|
||||
|
||||
Reference in New Issue
Block a user