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

@@ -21,6 +21,7 @@ const STUDENT_NOTE_VISIBILITIES = ['tenant_staff', 'class_staff', 'author_only']
const STUDENT_FOLLOWUP_TYPES = ['learning', 'service', 'sales', 'renewal', 'risk', 'custom'];
const STUDENT_FOLLOWUP_PRIORITIES = ['low', 'normal', 'high', 'urgent'];
const STUDENT_FOLLOWUP_STATUSES = ['open', 'in_progress', 'done', 'cancelled'];
const FOLLOWUP_REPORT_RANGES: Record<string, number> = { '7d': 7, '30d': 30, '90d': 90 };
const MAX_BULK_STUDENTS = 200;
const MAX_BULK_CLASS_ASSIGNMENTS = 500;
const MAX_BULK_CRM_PUSH = 100;
@@ -28,6 +29,14 @@ const CRM_ASSIGNABLE_ROLES = ['tenant_owner', 'tenant_admin', 'tenant_operator',
const STUDENT_AVATAR_FIELD_KEYS = ['avatarUrl', 'avatar_url', 'avatar', 'headimgurl', 'headImgUrl', 'figureurl', 'figureurl_qq_1', 'figureurl_qq_2'];
const STUDENT_PRIMARY_ROLE_FIELD_KEYS = ['primaryRole', 'primary_role'];
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const DATE_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const shanghaiDateFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
function nullableString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -85,6 +94,16 @@ function intValue(value: unknown, fallback: number) {
return Number.isFinite(numberValue) ? Math.trunc(numberValue) : fallback;
}
function numberValue(value: unknown, fallback = 0) {
const parsed = Number(value ?? fallback);
return Number.isFinite(parsed) ? parsed : fallback;
}
function ratio(numerator: number, denominator: number) {
if (denominator <= 0) return 0;
return Number((numerator / denominator).toFixed(4));
}
function boolValue(value: unknown, fallback: boolean) {
return typeof value === 'boolean' ? value : fallback;
}
@@ -139,6 +158,69 @@ function optionalTimestampString(value: unknown, fieldName: string) {
return new Date(time).toISOString();
}
function shanghaiDateKey(date: Date) {
const parts = Object.fromEntries(
shanghaiDateFormatter.formatToParts(date).map(part => [part.type, part.value]),
);
return `${parts.year}-${parts.month}-${parts.day}`;
}
function addDaysKey(dateKey: string, days: number) {
const date = new Date(`${dateKey}T00:00:00+08:00`);
date.setUTCDate(date.getUTCDate() + days);
return shanghaiDateKey(date);
}
function shanghaiDayStartIso(dateKey: string) {
return new Date(`${dateKey}T00:00:00+08:00`).toISOString();
}
function isValidShanghaiDateKey(dateKey: string) {
if (!DATE_KEY_PATTERN.test(dateKey)) return false;
const date = new Date(`${dateKey}T00:00:00+08:00`);
return Number.isFinite(date.getTime()) && shanghaiDateKey(date) === dateKey;
}
function daysBetweenInclusive(startDate: string, endDate: string) {
const start = Date.parse(`${startDate}T00:00:00+08:00`);
const end = Date.parse(`${endDate}T00:00:00+08:00`);
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return 0;
return Math.floor((end - start) / 86_400_000) + 1;
}
function parseFollowupReportRange(timeRangeValue: string, startDateValue: string, endDateValue: string) {
if (startDateValue || endDateValue) {
if (!isValidShanghaiDateKey(startDateValue) || !isValidShanghaiDateKey(endDateValue)) {
throw new HttpError(400, 'startDate and endDate must use YYYY-MM-DD', 'INVALID_DATE_RANGE');
}
const days = daysBetweenInclusive(startDateValue, endDateValue);
if (days <= 0) throw new HttpError(400, 'endDate must be greater than or equal to startDate', 'INVALID_DATE_RANGE');
if (days > 180) throw new HttpError(400, 'Follow-up report range supports at most 180 days', 'DATE_RANGE_TOO_LARGE');
return {
timeRange: 'custom',
days,
startDate: startDateValue,
endDate: endDateValue,
startAt: shanghaiDayStartIso(startDateValue),
endAt: shanghaiDayStartIso(addDaysKey(endDateValue, 1)),
};
}
const timeRange = timeRangeValue || '30d';
const days = FOLLOWUP_REPORT_RANGES[timeRange];
if (!days) throw new HttpError(400, 'Unsupported follow-up report time range', 'INVALID_FOLLOWUP_REPORT_RANGE');
const endDate = shanghaiDateKey(new Date());
const startDate = addDaysKey(endDate, -(days - 1));
return {
timeRange,
days,
startDate,
endDate,
startAt: shanghaiDayStartIso(startDate),
endAt: shanghaiDayStartIso(addDaysKey(endDate, 1)),
};
}
function classCodeValue(value: unknown) {
const code = nullableString(value);
if (!code) return null;
@@ -1372,6 +1454,317 @@ export async function tenantStudentFollowupsRoute(ctx: RequestContext) {
return { items: items.map(item => maskStudentFields(auth, item)) };
}
export async function tenantStudentFollowupReportRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'students:followups:read');
const range = parseFollowupReportRange(
stringParam(ctx, 'timeRange'),
stringParam(ctx, 'startDate'),
stringParam(ctx, 'endDate'),
);
const limit = intParam(ctx, 'limit', 10, 50);
const assignedTo = stringParam(ctx, 'assignedToUserId');
const followupType = stringParam(ctx, 'followupType');
const classId = stringParam(ctx, 'classId');
const scopedIds = await scopedClassIds(auth);
if (classId) await ensureReadableClass(auth, classId);
if (followupType && !STUDENT_FOLLOWUP_TYPES.includes(followupType)) {
throw new HttpError(400, `Invalid follow-up type: ${followupType}`, 'INVALID_FOLLOWUP_TYPE');
}
const params: unknown[] = [auth.tenantId, range.startAt, range.endAt];
const filters = ['sf.tenant_id = $1', '$2::timestamptz <= $3::timestamptz'];
if (assignedTo) {
params.push(assignedTo);
filters.push(`sf.assigned_to_user_id = $${params.length}::uuid`);
}
if (followupType) {
params.push(followupType);
filters.push(`sf.followup_type = $${params.length}`);
}
if (classId) {
params.push(classId);
filters.push(`sf.class_id = $${params.length}::uuid`);
}
if (scopedIds) {
params.push(scopedIds);
filters.push(`exists (
select 1 from public.tenant_class_members scoped_cm
where scoped_cm.tenant_id = sf.tenant_id
and scoped_cm.user_id = sf.student_user_id
and scoped_cm.class_id = any($${params.length}::uuid[])
and scoped_cm.member_type = 'student'
and scoped_cm.status = 'active'
)`);
}
const whereSql = filters.join(' and ');
const limitedParams = [...params, limit];
const [
summary,
byStatus,
byType,
byPriority,
byAssignee,
byClass,
dailyTrend,
overdueItems,
crmQueueSummary,
] = await Promise.all([
query<Record<string, unknown>>(
`
select
count(*) filter (where sf.created_at >= $2::timestamptz and sf.created_at < $3::timestamptz)::int as total,
count(*) filter (where sf.status = 'open' and sf.created_at >= $2::timestamptz and sf.created_at < $3::timestamptz)::int as open,
count(*) filter (where sf.status = 'in_progress' and sf.created_at >= $2::timestamptz and sf.created_at < $3::timestamptz)::int as "inProgress",
count(*) filter (where sf.status = 'done' and sf.created_at >= $2::timestamptz and sf.created_at < $3::timestamptz)::int as done,
count(*) filter (where sf.status = 'cancelled' and sf.created_at >= $2::timestamptz and sf.created_at < $3::timestamptz)::int as cancelled,
count(*) filter (where sf.priority in ('high', 'urgent') and sf.created_at >= $2::timestamptz and sf.created_at < $3::timestamptz)::int as "highPriority",
count(*) filter (where sf.metadata->'crmPush' is not null and sf.created_at >= $2::timestamptz and sf.created_at < $3::timestamptz)::int as "crmPushTasks",
count(*) filter (where sf.due_at is not null and sf.due_at < now() and sf.status in ('open', 'in_progress'))::int as "overdueBacklog",
count(*) filter (where sf.status in ('open', 'in_progress'))::int as "openBacklog",
count(*) filter (where sf.completed_at >= $2::timestamptz and sf.completed_at < $3::timestamptz)::int as "completedInRange",
coalesce(avg(extract(epoch from (sf.completed_at - sf.created_at)) / 3600) filter (
where sf.completed_at >= $2::timestamptz and sf.completed_at < $3::timestamptz and sf.completed_at is not null
), 0)::numeric as "avgCompleteHours"
from public.tenant_student_followups sf
where ${whereSql}
`,
params,
),
query<Record<string, unknown>>(
`
select sf.status, count(*)::int as count
from public.tenant_student_followups sf
where ${whereSql}
and sf.created_at >= $2::timestamptz
and sf.created_at < $3::timestamptz
group by sf.status
order by case sf.status
when 'open' then 1
when 'in_progress' then 2
when 'done' then 3
when 'cancelled' then 4
else 9
end
`,
params,
),
query<Record<string, unknown>>(
`
select sf.followup_type as "followupType", count(*)::int as count
from public.tenant_student_followups sf
where ${whereSql}
and sf.created_at >= $2::timestamptz
and sf.created_at < $3::timestamptz
group by sf.followup_type
order by count desc, "followupType" asc
`,
params,
),
query<Record<string, unknown>>(
`
select sf.priority, count(*)::int as count
from public.tenant_student_followups sf
where ${whereSql}
and sf.created_at >= $2::timestamptz
and sf.created_at < $3::timestamptz
group by sf.priority
order by case sf.priority
when 'urgent' then 1
when 'high' then 2
when 'normal' then 3
when 'low' then 4
else 9
end
`,
params,
),
query<Record<string, unknown>>(
`
select sf.assigned_to_user_id as "assignedToUserId",
coalesce(assignee.name, assignee.username, '未指派') as "assignedToName",
tm.role as "assignedToRole",
count(*)::int as total,
count(*) filter (where sf.status in ('open', 'in_progress'))::int as open,
count(*) filter (where sf.status = 'done')::int as done,
count(*) filter (where sf.due_at is not null and sf.due_at < now() and sf.status in ('open', 'in_progress'))::int as overdue,
count(*) filter (where sf.metadata->'crmPush' is not null)::int as "crmPushTasks",
coalesce(avg(extract(epoch from (sf.completed_at - sf.created_at)) / 3600) filter (where sf.completed_at is not null), 0)::numeric as "avgCompleteHours",
max(coalesce(sf.completed_at, sf.updated_at, sf.created_at)) as "latestActivityAt"
from public.tenant_student_followups sf
left join public.platform_users assignee on assignee.id = sf.assigned_to_user_id
left join public.tenant_memberships tm on tm.tenant_id = sf.tenant_id
and tm.user_id = sf.assigned_to_user_id
and tm.status = 'active'
and tm.role = any($${limitedParams.length + 1}::text[])
where ${whereSql}
and sf.created_at >= $2::timestamptz
and sf.created_at < $3::timestamptz
group by sf.assigned_to_user_id, assignee.name, assignee.username, tm.role
order by total desc, overdue desc, "latestActivityAt" desc
limit $${limitedParams.length}
`,
[...limitedParams, CRM_ASSIGNABLE_ROLES],
),
query<Record<string, unknown>>(
`
select sf.class_id as "classId",
coalesce(tc.name, '未绑定班级') as "className",
tc.code as "classCode",
count(*)::int as total,
count(*) filter (where sf.status in ('open', 'in_progress'))::int as open,
count(*) filter (where sf.status = 'done')::int as done,
count(*) filter (where sf.due_at is not null and sf.due_at < now() and sf.status in ('open', 'in_progress'))::int as overdue,
max(coalesce(sf.completed_at, sf.updated_at, sf.created_at)) as "latestActivityAt"
from public.tenant_student_followups sf
left join public.tenant_classes tc on tc.tenant_id = sf.tenant_id and tc.id = sf.class_id
where ${whereSql}
and sf.created_at >= $2::timestamptz
and sf.created_at < $3::timestamptz
group by sf.class_id, tc.name, tc.code
order by total desc, overdue desc, "latestActivityAt" desc
limit $${limitedParams.length}
`,
limitedParams,
),
query<Record<string, unknown>>(
`
with days as (
select generate_series(
$2::timestamptz,
$3::timestamptz - interval '1 day',
interval '1 day'
) as day_start
),
created_daily as (
select (sf.created_at at time zone 'Asia/Shanghai')::date::text as date,
count(*)::int as created,
count(*) filter (where sf.metadata->'crmPush' is not null)::int as "crmPushCreated",
count(*) filter (where sf.priority in ('high', 'urgent'))::int as "highPriority"
from public.tenant_student_followups sf
where ${whereSql}
and sf.created_at >= $2::timestamptz
and sf.created_at < $3::timestamptz
group by (sf.created_at at time zone 'Asia/Shanghai')::date
),
completed_daily as (
select (sf.completed_at at time zone 'Asia/Shanghai')::date::text as date,
count(*)::int as completed
from public.tenant_student_followups sf
where ${whereSql}
and sf.completed_at >= $2::timestamptz
and sf.completed_at < $3::timestamptz
and sf.status = 'done'
group by (sf.completed_at at time zone 'Asia/Shanghai')::date
)
select (d.day_start at time zone 'Asia/Shanghai')::date::text as date,
coalesce(cd.created, 0)::int as created,
coalesce(cd."crmPushCreated", 0)::int as "crmPushCreated",
coalesce(cd."highPriority", 0)::int as "highPriority",
coalesce(done.completed, 0)::int as completed
from days d
left join created_daily cd on cd.date = (d.day_start at time zone 'Asia/Shanghai')::date::text
left join completed_daily done on done.date = (d.day_start at time zone 'Asia/Shanghai')::date::text
order by date asc
`,
params,
),
query<Record<string, unknown>>(
`
select sf.id, sf.student_user_id as "studentUserId",
student.name as "studentName", student.phone as "studentPhone",
sf.assigned_to_user_id as "assignedToUserId",
coalesce(assignee.name, assignee.username) as "assignedToName",
sf.class_id as "classId", tc.name as "className",
sf.title, sf.followup_type as "followupType", sf.priority,
sf.status, sf.due_at as "dueAt",
extract(epoch from (now() - sf.due_at)) / 3600 as "overdueHours",
sf.created_at as "createdAt", sf.updated_at as "updatedAt"
from public.tenant_student_followups sf
left join public.platform_users student on student.id = sf.student_user_id
left join public.platform_users assignee on assignee.id = sf.assigned_to_user_id
left join public.tenant_classes tc on tc.tenant_id = sf.tenant_id and tc.id = sf.class_id
where ${whereSql}
and sf.due_at is not null
and sf.due_at < now()
and sf.status in ('open', 'in_progress')
order by case sf.priority
when 'urgent' then 1
when 'high' then 2
when 'normal' then 3
else 4
end, sf.due_at asc
limit $${limitedParams.length}
`,
limitedParams,
),
query<Record<string, unknown>>(
`
select
count(*) filter (where q.created_at >= $2::timestamptz and q.created_at < $3::timestamptz)::int as total,
count(*) filter (where q.status in ('pending', 'retrying', 'processing') and q.created_at >= $2::timestamptz and q.created_at < $3::timestamptz)::int as pending,
count(*) filter (where q.status = 'sent' and q.created_at >= $2::timestamptz and q.created_at < $3::timestamptz)::int as sent,
count(*) filter (where q.status in ('failed', 'discarded') and q.created_at >= $2::timestamptz and q.created_at < $3::timestamptz)::int as failed,
count(distinct q.record_id) filter (where q.created_at >= $2::timestamptz and q.created_at < $3::timestamptz)::int as "studentCount"
from public.crm_webhook_queue q
join public.tenant_student_followups sf on sf.tenant_id = q.tenant_id
and sf.metadata->'crmPush'->>'idempotencyKey' = q.idempotency_key
where q.tenant_id = $1
and q.source = 'tenant.student.crm_push'
and ${whereSql}
`,
params,
),
]);
const summaryRow = summary[0] || {};
const total = intValue(summaryRow.total, 0);
const done = intValue(summaryRow.done, 0);
const completedInRange = intValue(summaryRow.completedInRange, 0);
return {
item: {
range,
filters: {
assignedToUserId: assignedTo || null,
followupType: followupType || null,
classId: classId || null,
scoped: scopedIds !== null,
},
summary: {
total,
open: intValue(summaryRow.open, 0),
inProgress: intValue(summaryRow.inProgress, 0),
done,
cancelled: intValue(summaryRow.cancelled, 0),
highPriority: intValue(summaryRow.highPriority, 0),
crmPushTasks: intValue(summaryRow.crmPushTasks, 0),
openBacklog: intValue(summaryRow.openBacklog, 0),
overdueBacklog: intValue(summaryRow.overdueBacklog, 0),
completedInRange,
completionRate: ratio(done, total),
completedInRangeRate: ratio(completedInRange, total),
avgCompleteHours: Number(numberValue(summaryRow.avgCompleteHours, 0).toFixed(2)),
},
crmQueue: crmQueueSummary[0] || { total: 0, pending: 0, sent: 0, failed: 0, studentCount: 0 },
byStatus,
byType,
byPriority,
byAssignee: byAssignee.map(item => ({
...item,
avgCompleteHours: Number(numberValue(item.avgCompleteHours, 0).toFixed(2)),
completionRate: ratio(intValue(item.done, 0), intValue(item.total, 0)),
})),
byClass,
dailyTrend,
overdueItems: overdueItems.map(item => maskStudentFields(auth, {
...item,
overdueHours: Number(numberValue(item.overdueHours, 0).toFixed(2)),
})),
},
};
}
export async function upsertTenantStudentFollowupRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'students:followups:write');

View File

@@ -7,6 +7,7 @@ import {
tenantClassesRoute,
tenantClassMembersRoute,
tenantStudentFollowupsRoute,
tenantStudentFollowupReportRoute,
tenantStudentNotesRoute,
tenantStudentsRoute,
tenantTeachersRoute,
@@ -102,6 +103,7 @@ export const tenantAdminRoutes: RouteDefinition[] = [
['GET', '/api/tenant-admin/students/notes', tenantStudentNotesRoute],
['PUT', '/api/tenant-admin/students/notes', upsertTenantStudentNoteRoute],
['GET', '/api/tenant-admin/students/followups', tenantStudentFollowupsRoute],
['GET', '/api/tenant-admin/students/followups/report', tenantStudentFollowupReportRoute],
['PUT', '/api/tenant-admin/students/followups', upsertTenantStudentFollowupRoute],
['GET', '/api/tenant-admin/teachers', tenantTeachersRoute],
['GET', '/api/tenant-admin/overview', tenantOverviewRoute],

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;