Files
prop-ai-hr/mobile-uni/src/services/supervisor-view.ts
T

290 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
PracticeAlert,
PracticeAssignment,
PracticeRecord,
PracticeTeamMember
} from '@/types/api';
export type SupervisorMetricKey = 'pendingReviews' | 'pendingAssignments' | 'lowScores' | 'alerts';
export interface SupervisorMetric {
key: SupervisorMetricKey;
title: string;
value: number;
desc: string;
primary?: boolean;
}
export interface TeamProfile {
key: string;
extPartyId: string;
name: string;
displayName: string;
latestScore?: number;
latestScoreText: string;
latestScene: string;
lastPracticeAt: string;
pendingReview: number;
pendingAssignment: number;
reason: string;
risk: 'high' | 'medium' | 'low';
}
export interface SupervisorScenarioOption {
id: string;
name: string;
}
export const supervisorScenarioOptions: SupervisorScenarioOption[] = [
{ id: 'fee-parking', name: '催费沟通' },
{ id: 'complaint-water', name: '投诉接待' },
{ id: 'repair-elevator-delay', name: '电梯故障' }
];
export type SupervisorScenarioId = string;
export interface AsyncGenerationGuard {
capture: () => number;
begin: () => number;
invalidate: () => void;
isCurrent: (candidate: number) => boolean;
dispose: () => void;
}
export const createAsyncGenerationGuard = (): AsyncGenerationGuard => {
let generation = 0;
let disposed = false;
return {
capture: () => generation,
begin: () => ++generation,
invalidate: () => {
generation += 1;
},
isCurrent: (candidate) => !disposed && candidate === generation,
dispose: () => {
disposed = true;
generation += 1;
}
};
};
export const inferSupervisorScenarioId = (text: string): SupervisorScenarioId => {
if (/电梯/.test(text)) return 'repair-elevator-delay';
if (/投诉|报修|漏水|噪音|摔倒/.test(text)) return 'complaint-water';
return 'fee-parking';
};
interface TeamProfileDraft {
key: string;
extPartyId: string;
name: string;
hasReadableName: boolean;
latestScene: string;
latestPractice?: {
score: number;
scene: string;
time: string;
timeValue: number;
};
pendingLowScore?: number;
pendingReview: number;
pendingAssignment: number;
}
export const manualPracticeAssignments = (assignments: PracticeAssignment[]) =>
assignments.filter((item) => item.source !== 'daily');
export const actionablePracticeAlerts = (alerts: PracticeAlert[]) =>
alerts.filter((item) => item.type !== 'stable');
export const isReviewEditable = (status: string | null | undefined) => status === '待复盘';
export const isSupervisorAccessDenied = (error: unknown) => {
const message = error instanceof Error
? error.message
: typeof error === 'string' ? error : '';
return message.includes('无主管权限');
};
export const reviewCompletionAction = (previousRoute: string | undefined): 'back' | 'replace' =>
previousRoute?.replace(/^\/+/, '') === 'pages/supervisor/reviews/index' ? 'back' : 'replace';
const alertCount = (alerts: PracticeAlert[], type: string) => {
const match = alerts.find((item) => item.type === type)?.desc.match(/\d+/);
return match ? Number(match[0]) : 0;
};
export const buildSupervisorMetrics = (
reviews: PracticeRecord[],
assignments: PracticeAssignment[],
alerts: PracticeAlert[]
): SupervisorMetric[] => {
const pendingReviewAlertCount = alertCount(alerts, 'pending_review');
const pendingReviews = pendingReviewAlertCount
|| reviews.filter((item) => item.status === '待复盘').length;
const pendingAssignments = manualPracticeAssignments(assignments)
.filter((item) => item.status !== '已完成').length;
const lowScores = alertCount(alerts, 'low_score');
const actionableAlertCount = actionablePracticeAlerts(alerts).length;
return [
{
key: 'pendingReviews',
title: '当前待复盘',
value: pendingReviews,
desc: pendingReviews ? '优先处理低分和待处理记录' : '暂无待复盘记录',
primary: true
},
{
key: 'pendingAssignments',
title: '当前待训练',
value: pendingAssignments,
desc: pendingAssignments ? '仍有专项训练待完成' : '暂无未完成专项'
},
{
key: 'lowScores',
title: '低分预警',
value: lowScores,
desc: lowScores ? '训练低于 80 分' : '暂无低分预警'
},
{
key: 'alerts',
title: '团队预警',
value: actionableAlertCount,
desc: actionableAlertCount ? '请及时跟进风险项' : '暂无团队预警'
}
];
};
export const buildTeamProfiles = (
reviews: PracticeRecord[],
assignments: PracticeAssignment[],
members: PracticeTeamMember[] = []
): TeamProfile[] => {
const rows = new Map<string, TeamProfileDraft>();
const ensure = (rawKey: string | undefined, rawName: string, rawExtPartyId?: string) => {
const extPartyId = rawExtPartyId?.trim() || '';
const candidateName = rawName.trim();
const hasReadableName = candidateName.length > 0 && !/^[??]{3,}/.test(candidateName);
const key = rawKey?.trim() || extPartyId || candidateName || 'unknown';
const identity = extPartyId || key;
const name = hasReadableName ? candidateName : `员工 ${identitySuffix(identity)}`;
const current = rows.get(key);
if (current) {
if (hasReadableName && !current.hasReadableName) {
current.name = candidateName;
current.hasReadableName = true;
}
current.extPartyId = current.extPartyId || extPartyId || key;
return current;
}
const next: TeamProfileDraft = {
key,
extPartyId: extPartyId || key,
name,
hasReadableName,
latestScene: '',
pendingReview: 0,
pendingAssignment: 0
};
rows.set(key, next);
return next;
};
members.forEach((item) => {
ensure(item.extPartyId, item.name, item.extPartyId);
});
reviews.forEach((item) => {
const row = ensure(item.extPartyId || item.trainee, item.trainee, item.extPartyId);
if (isReviewEditable(item.status)) {
row.pendingReview += 1;
if (typeof item.score === 'number' && Number.isFinite(item.score) && item.score < 80) {
row.pendingLowScore = Math.min(row.pendingLowScore ?? item.score, item.score);
}
}
if (typeof item.score === 'number' && Number.isFinite(item.score)) {
const timeValue = practiceTimeValue(item.time);
if (!row.latestPractice || timeValue > row.latestPractice.timeValue) {
row.latestPractice = {
score: item.score,
scene: item.scene.trim(),
time: item.time,
timeValue
};
}
}
});
manualPracticeAssignments(assignments).forEach((item) => {
const row = ensure(item.extPartyId || item.traineeName, item.traineeName, item.extPartyId);
if (item.status !== '已完成') row.pendingAssignment += 1;
row.latestScene ||= item.scenarioName.trim();
});
const nameCounts = Array.from(rows.values()).reduce<Record<string, number>>((counts, row) => {
counts[row.name] = (counts[row.name] || 0) + 1;
return counts;
}, {});
const riskRank: Record<TeamProfile['risk'], number> = { high: 0, medium: 1, low: 2 };
return Array.from(rows.values())
.map<TeamProfile>((row) => {
const latestScore = row.latestPractice?.score;
const latestScene = row.latestPractice?.scene || row.latestScene;
const risk: TeamProfile['risk'] = row.pendingLowScore !== undefined
? 'high'
: row.pendingReview > 0
? 'medium'
: 'low';
const reason = risk === 'high'
? `${row.name} 有未复盘低分记录(${row.pendingLowScore}分),建议定向再练`
: risk === 'medium'
? `${row.name} 有 ${row.pendingReview} 条待复盘,建议完成点评后派专项`
: row.pendingAssignment > 0
? `${row.name} 有 ${row.pendingAssignment} 项待训练,建议跟进完成`
: `${row.name} 当前无待处理训练,可按需派发专项`;
return {
key: row.key,
extPartyId: row.extPartyId,
name: row.name,
displayName: nameCounts[row.name] > 1
? `${row.name} · ${identitySuffix(row.extPartyId)}`
: row.name,
latestScore,
latestScoreText: latestScore === undefined ? '暂无评分' : `${latestScore}分`,
latestScene,
lastPracticeAt: row.latestPractice?.time || '',
pendingReview: row.pendingReview,
pendingAssignment: row.pendingAssignment,
reason,
risk
};
})
.sort((a, b) =>
riskRank[a.risk] - riskRank[b.risk]
|| b.pendingReview - a.pendingReview
|| b.pendingAssignment - a.pendingAssignment
|| (a.latestScore ?? 100) - (b.latestScore ?? 100));
};
function identitySuffix(value: string) {
if (/^1[3-9]\d{9}$/.test(value)) return `尾号${value.slice(-4)}`;
if (value.length > 8) return `末尾${value.slice(-6)}`;
return value;
}
function practiceTimeValue(value: string) {
const parts = value.match(/\d+/g)?.map(Number) || [];
if (parts.length < 4 || parts.some((part) => !Number.isFinite(part))) {
return Number.NEGATIVE_INFINITY;
}
const normalized = parts.length >= 5 ? parts.slice(0, 6) : [0, ...parts.slice(0, 5)];
while (normalized.length < 6) normalized.push(0);
const [year, month, day, hour, minute, second] = normalized;
return (((((year * 13 + month) * 32 + day) * 24 + hour) * 60 + minute) * 60) + second;
}