feat: add student followup report

This commit is contained in:
Codex
2026-06-30 16:42:23 +08:00
parent 7f97b52165
commit 7473c7a6d7
13 changed files with 567 additions and 22 deletions

View File

@@ -6,6 +6,7 @@ import {
bulkUpsertTenantStudents,
loadTenantClasses,
loadTenantStudentFollowups,
loadTenantStudentFollowupReport,
loadTenantStudentNotes,
loadTenantStudents,
loadTenantTeachers,
@@ -22,6 +23,7 @@ import {
type TenantStudentItem,
type TenantStudentNoteItem,
type TenantStudentCrmPushInput,
type TenantStudentFollowupReport,
type TenantTeacherItem,
type TenantMemberItem,
} from '@/services/tenantAdmin';
@@ -122,6 +124,7 @@ export default function TenantStudentsPage() {
const [bulkResult, setBulkResult] = useState<BulkOperationResult | null>(null);
const [assignResult, setAssignResult] = useState<BulkOperationResult | null>(null);
const [crmPushResult, setCrmPushResult] = useState<BulkOperationResult | null>(null);
const [followupReport, setFollowupReport] = useState<TenantStudentFollowupReport['item'] | null>(null);
const [scoped, setScoped] = useState(false);
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
@@ -144,13 +147,15 @@ export default function TenantStudentsPage() {
limit: 80,
}),
loadTenantStudentFollowups({ status: 'open', limit: 30 }).catch(() => ({ items: [] })),
]).then(([classPayload, teacherPayload, memberPayload, studentPayload, followupPayload]) => {
loadTenantStudentFollowupReport({ timeRange: '30d', classId: nextClassId || undefined, limit: 8 }).catch(() => ({ item: null })),
]).then(([classPayload, teacherPayload, memberPayload, studentPayload, followupPayload, reportPayload]) => {
setClasses(classPayload.items || []);
setTeachers(teacherPayload.items || []);
setCrmAssignees((memberPayload.items || []).filter(item => crmAssignableRoles.includes(String(item.role || ''))));
setStudents(studentPayload.items || []);
setScoped(studentPayload.scoped === true);
setFollowups(followupPayload.items || []);
setFollowupReport(reportPayload.item || null);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '学生数据加载失败'));
}
@@ -538,6 +543,38 @@ export default function TenantStudentsPage() {
{!students.length ? <View className='admin-empty'></View> : null}
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-grid'>
<View className='admin-metric'>
<Text className='admin-metric-label'>30 </Text>
<Text className='admin-metric-value'>{followupReport?.summary?.total || 0}</Text>
<Text className='admin-row-meta'> {followupReport?.summary?.done || 0} · CRM {followupReport?.summary?.crmPushTasks || 0}</Text>
</View>
<View className='admin-metric'>
<Text className='admin-metric-label'></Text>
<Text className='admin-metric-value'>{followupReport?.summary?.openBacklog || 0}</Text>
<Text className='admin-row-meta'> {followupReport?.summary?.overdueBacklog || 0} · {followupReport?.summary?.highPriority || 0}</Text>
</View>
<View className='admin-metric'>
<Text className='admin-metric-label'></Text>
<Text className='admin-metric-value'>{Math.round((followupReport?.summary?.completionRate || 0) * 100)}%</Text>
<Text className='admin-row-meta'> {followupReport?.summary?.avgCompleteHours || 0} </Text>
</View>
</View>
<View className='admin-list'>
{(followupReport?.byAssignee || []).slice(0, 4).map((item, index) => (
<View className='admin-row' key={`${item.assignedToUserId || 'none'}:${index}`}>
<Text className='admin-row-main'>{String(item.assignedToName || '未指派')} · {String(item.assignedToRole || '-')}</Text>
<Text className='admin-row-meta'> {String(item.total || 0)} · {String(item.open || 0)} · {String(item.done || 0)} · {String(item.overdue || 0)}</Text>
</View>
))}
</View>
{(followupReport?.overdueItems || []).slice(0, 3).map(item => (
<Text className='admin-row-meta break-line' key={item.id}> · {item.title || '跟进任务'} · {item.studentName || item.studentUserId || '-'} · {item.assignedToName || '未指派'}</Text>
))}
</View>
<View className='admin-section'>
<Text className='admin-section-title'>CRM </Text>
<View className='admin-form-grid'>

View File

@@ -138,6 +138,42 @@ export interface TenantStudentCrmPushInput {
idempotencyKey?: string;
}
export interface TenantStudentFollowupReport {
item?: {
range?: Record<string, unknown>;
filters?: Record<string, unknown>;
summary?: {
total?: number;
open?: number;
inProgress?: number;
done?: number;
cancelled?: number;
highPriority?: number;
crmPushTasks?: number;
openBacklog?: number;
overdueBacklog?: number;
completedInRange?: number;
completionRate?: number;
completedInRangeRate?: number;
avgCompleteHours?: number;
};
crmQueue?: {
total?: number;
pending?: number;
sent?: number;
failed?: number;
studentCount?: number;
};
byStatus?: Record<string, unknown>[];
byType?: Record<string, unknown>[];
byPriority?: Record<string, unknown>[];
byAssignee?: Record<string, unknown>[];
byClass?: Record<string, unknown>[];
dailyTrend?: Record<string, unknown>[];
overdueItems?: TenantStudentFollowupItem[];
};
}
export interface ImportJobItem {
id: string;
importType?: string;
@@ -1063,6 +1099,20 @@ export async function loadTenantStudentFollowups(query: {
});
}
export async function loadTenantStudentFollowupReport(query: {
timeRange?: '7d' | '30d' | '90d';
startDate?: string;
endDate?: string;
assignedToUserId?: string;
followupType?: string;
classId?: string;
limit?: number;
} = {}) {
return apiRequest<TenantStudentFollowupReport>('/api/tenant-admin/students/followups/report', {
query: { ...query, limit: query.limit || 10 },
});
}
export async function upsertTenantStudentFollowup(input: {
id?: string;
studentUserId: string;