forked from wangziqi/gongxue-base
feat: add student followup report
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user