diff --git a/CLAUDE.md b/CLAUDE.md
index 5ba84c5..26dc12e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -71,12 +71,12 @@ pnpm deploy:server # 部署后端到生产环境
### 卡类型枚举
- `CardTypeCategory` (TIMES/DURATION/TRIAL) 定义在 `packages/shared/src/enums.ts`
-- 会员管理筛选使用特殊值 `NONE` 表示无卡/无有效会员(不在枚举中)
+- 会员管理筛选使用特殊值 `ACTIVE` 表示持有 ACTIVE 状态会员卡的会员用户(页面默认),`NONE` 表示无卡/无有效会员(两者不在卡种枚举中)
- 前端选项硬编码在 `src/pages/admin/members.vue` 的 `cardTypeOptions`,需与枚举保持同步
### 管理后台 API 模式
- `/admin/members` 支持 `page`, `limit`, `search`, `cardType` 参数
-- `cardType=NONE` → 无有效会员的用户;其他值对应 `CardTypeCategory`
+- `cardType=ACTIVE` → 持有任意 ACTIVE 状态会员卡;`cardType=NONE` → 无 ACTIVE 状态会员卡;省略参数查看全部用户;其他值对应 `CardTypeCategory`
- 预约统计(total/completed/cancelled)通过 `groupBy` 批量查询
### 筛选组件模式
@@ -91,3 +91,10 @@ pnpm deploy:server # 部署后端到生产环境
- 补录不创建预约或时段;只增加累计已完成节数,不推测上课日期、天数、时长,不参与月度统计、活跃网格或邀请奖励。
- 可选择从本人有限次会员卡扣次;补录与扣次必须事务提交,保存实际扣次快照,撤销只返还实际扣次。请求标识用于幂等重试。
- Prisma 迁移按 `YYYYMMDDHHmmss_description/migration.sql` 存放;补录表采用增量迁移,回退说明维护在 `docs/lesson-supplement.md`,不删除审计记录。
+
+### 月度教学统计
+- 统计归属 admin 模块,服务放 `admin/teaching-analytics.service.ts`,测试放 `admin/__tests__`;共享契约放 `shared/src/types/teaching-analytics.ts`,页面为 `pages/admin/analytics.vue`。
+- 按 TimeSlot.date 所属自然月查询,不按预约创建或核销日期;日期列以 UTC 日历值读取,中国时间用于判断课程结束。
+- 当前无老师归属字段,统计范围是工作室;operatorId 不是授课老师。COMPLETED 是系统完成状态,不代表签到。
+- 已上课程按时段去重,时长按已完成时段累加;上课人次按 COMPLETED 预约计数,学员按 userId 去重。取消、未出席、待确认、已确认独立计数。无日期补录不参与。
+- 月历、学员排行和会员卡分布统计已完成记录;明细可组合日期、学员、状态筛选。新增测试目录只放该服务的 *.spec.ts。
diff --git a/docs/monthly-teaching-analytics.md b/docs/monthly-teaching-analytics.md
new file mode 100644
index 0000000..d2e34d6
--- /dev/null
+++ b/docs/monthly-teaching-analytics.md
@@ -0,0 +1,47 @@
+# 月度教学统计
+
+入口:管理中心 → 统计分析。页面为 `packages/app/src/pages/admin/analytics.vue`,接口为 `GET /api/admin/teaching-analytics?month=YYYY-MM`。
+
+## 统计口径
+
+| 指标 | 定义 |
+| --- | --- |
+| 已上课程 | COMPLETED 预约关联的时段数,同一时段只计一次 |
+| 已完成上课人次 | COMPLETED 预约数 |
+| 本月上课学员 | 已完成预约的 userId 去重,不按姓名合并 |
+| 授课小时 | 已完成时段的排课分钟数之和除以 60,同一时段不重复累计 |
+| 上课天数 | 已完成课程日期去重 |
+| 人均上课次数 | 上课人次除以上课学员数,无学员时显示横线 |
+| 上月参考 | 上一个完整自然月的已上课程节数;当月未结束时明确提示,不给误导性环比 |
+| 待核对 | 已过中国时间课程结束时刻、仍待确认或已确认的预约 |
+
+月份以 TimeSlot.date 的日历日期为准,查询使用月初包含、下月初不包含的区间。预约创建时间、完成时间和取消时间不影响月份归属。取消、未出席、待确认和已确认不计入已上课程。
+
+当前模型没有老师归属、课程名称或课程类别,因此统计范围明确为工作室;不能把 operatorId 当作授课老师。用卡分布代表预约所用会员卡的当前卡种名称,不代表课程类别、实际消课收入或老师课酬。同名卡种合并显示。
+
+系统存在自动完成预约任务,COMPLETED 不等于现场签到,不提供签到率。没有课程日期的历史累计补录不计入本报告。
+
+## 页面与交互
+
+- 米白底、松绿色总览、宋体标题与衬线数字,延续原有课表风格。
+- 月份选择器、前后月切换、回到本月、手动刷新与下拉刷新;支持 2000—2099 年。
+- 月历显示每天已完成课程节数,点击日期定位明细,再次点击解除日期条件。
+- 学员排行支持姓名搜索,显示上课次数、天数与最近上课日期;点击查看该学员全月已完成明细。
+- 明细支持日期、学员、预约状态组合筛选;每个状态显示当前日期/学员条件内的记录数。清除筛选恢复全月全部记录。
+- 明细显示课程日期、起止时间、学员、用卡与状态,点击姓名进入会员详情。
+- 排行每次增加 10 人、明细每次增加 20 条,避免一次渲染过多节点。接口返回当月完整记录,不以列表显示上限截断统计。
+- 切月清空旧数据和筛选,请求序号防止慢响应覆盖新月份。错误提示与空月独立显示,失败不伪装成零数据。
+
+## 实现边界与验证
+
+接口继承管理中心的 JWT 和 ADMIN 权限保护。一次查询读取当月及上月必要关联字段,无逐学员查询;只向前端返回当月明细与两个月的汇总。未新增数据库表或迁移。
+
+服务测试覆盖课程去重、同名不同学员、状态排除、空月、跨年、闰年、月会员管理 页面中,默认是查看全部用户,这个查看效率太低了,我希望默认看到的是会员用户增加这么一个筛选, 至于新用户,我可以选择筛选无卡用户就行了,这样的体验会更加好一些参数验证、中国时间结束判断以及接口权限元数据。
+
+验证通过:共享包构建、后端构建、前端类型检查、微信小程序构建;新增统计测试 12 项通过。全量测试 231 项通过,原有 scheduler 测试 8 项因缺少 FlashSaleService mock 失败。
+
+使用真实 Vue 页面与 SCSS、模拟数据进行了 390px 手机宽度预览,验证学员钻取、日期筛选、快速切月、空月和错误状态。浏览器预览仅模拟小程序容器;微信真机、原生月份选择器以及线上数据联调仍需发布前验收。
+
+设计参考:[TeamUp 出勤报表](https://support.goteamup.com/en/articles/9327465-reports-class-attendances-all-attendances),借鉴按学员、日期和状态追溯出勤记录的交互,不复制其产品界面。
+
+上线需要部署后端并发布小程序,本次仅实现与本地验证,未执行生产部署。
diff --git a/packages/app/src/pages.json b/packages/app/src/pages.json
index dc7e828..03ec567 100644
--- a/packages/app/src/pages.json
+++ b/packages/app/src/pages.json
@@ -3,6 +3,7 @@
"autoscan": true
},
"pages": [
+ { "path": "pages/admin/analytics", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true } },
{
"path": "pages/home/index",
"style": {
diff --git a/packages/app/src/pages/admin/analytics.vue b/packages/app/src/pages/admin/analytics.vue
new file mode 100644
index 0000000..b108d87
--- /dev/null
+++ b/packages/app/src/pages/admin/analytics.vue
@@ -0,0 +1,279 @@
+
+
+
+
+ PILATES · MONTHLY REVIEW
+ 每一节课,都有迹可循。
+ 工作室月度统计 · 按课程日期归档
+
+
+
+
+ {{ month.replace('-', ' 年 ') }} 月 ⌄
+
+
+
+
+
+ 本月持续更新
+
+
+ 仅登录后的管理员可查看教学统计
+ 正在整理这个月的上课记录…
+ {{ error }}
+
+
+ 已上课程
+ {{ report.summary.sessions }}节
+ {{ report.previousMonth }} 全月 {{ report.previous.sessions }} 节 · {{ month === currentMonth ? '本月尚未结束' : '按完整自然月统计' }}
+
+
+
+ {{ report.summary.attendances }}人次已完成上课
+ {{ report.summary.students }}人本月上课学员
+
+
+ 这个月暂无预约记录,可切换月份查看历史教学情况。
+
+ 上课日历数字为已完成课程节数
+
+ {{ label }}
+
+
+
+
+
+
+ 学员上课排行已完成 · {{ students.length }} 人
+
+
+
+
+
+
+ 上课用卡分布已完成人次
+ {{ card.name }}{{ card.count }} 人次 · {{ Math.round(card.count / report.summary.attendances * 100) }}%
+
+
+
+ 上课明细{{ filteredRecords.length }} 条
+
+
+
+
+ {{ selectedDate || '全月' }}{{ selectedStudent ? ` · ${selectedStudent.name}` : '' }}{{ reviewOnly ? ' · 待核对' : '' }}
+
+
+
+
+ {{ statusName(row.status) }}
+ {{ row.date.slice(5).replace('-', '月') }}日 · {{ row.startTime }}–{{ row.endTime }}
+ {{ row.cardName }}{{ row.needsReview ? ' · 已过结束时间,请核对' : '' }}
+
+
+
+
+
+ 关于这份月报
+ 统计整个工作室,暂不区分老师。已上课程按已完成预约的时段去重;同一节课多人参加,只计 1 节课、多人次。授课时长按该时段排课时长计算。
+ “已完成”包含系统自动完成,不代表现场签到。其他状态不计入已上课程;没有日期的历史补录不计入月报。用卡分布按当前卡种名称归类。
+ 更新于 {{ updatedLabel }} · 下拉可刷新
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/pages/admin/index.vue b/packages/app/src/pages/admin/index.vue
index 10d2306..feb786b 100644
--- a/packages/app/src/pages/admin/index.vue
+++ b/packages/app/src/pages/admin/index.vue
@@ -27,6 +27,16 @@
+
+
+
+
+ ▥
+ 统计分析月度课次 · 学员出勤 · 上课明细
+
+ ›
+
+
@@ -47,7 +49,7 @@
- {{ searchQuery ? '未找到匹配的会员' : '暂无会员数据' }}
+ {{ searchQuery ? '未找到匹配的用户' : '当前筛选下暂无用户' }}
@@ -116,17 +118,25 @@ const hasMore = ref(false)
const LIMIT = 20
const cardTypeOptions = [
- { label: '全部', value: '' },
+ { label: '会员用户', value: 'ACTIVE' },
+ { label: '全部用户', value: '' },
{ label: '体验卡', value: 'TRIAL' },
{ label: '次卡', value: 'TIMES' },
{ label: '月卡', value: 'DURATION' },
- { label: '无卡', value: 'NONE' },
+ { label: '无卡用户', value: 'NONE' },
]
const cardTypeIndex = ref(0)
+let requestId = 0
let cardTypeDebounceTimer: ReturnType | null = null
function onCardTypeChange(e: { detail: { value: number } }) {
cardTypeIndex.value = Number(e.detail.value)
+ // Invalidate the old filter immediately, including during the debounce window.
+ requestId++
+ loading.value = true
+ members.value = []
+ total.value = 0
+ hasMore.value = false
if (cardTypeDebounceTimer) clearTimeout(cardTypeDebounceTimer)
cardTypeDebounceTimer = setTimeout(() => {
loadMembers(true)
@@ -135,25 +145,32 @@ function onCardTypeChange(e: { detail: { value: number } }) {
}
onUnmounted(() => {
+ requestId++
if (cardTypeDebounceTimer) clearTimeout(cardTypeDebounceTimer)
})
async function loadMembers(reset = false) {
- if (loading.value) return
+ if (loading.value && !reset) return
+ const id = ++requestId
+ const requestedPage = reset ? 1 : page.value + 1
if (reset) {
page.value = 1
members.value = []
+ total.value = 0
+ hasMore.value = false
}
loading.value = true
try {
const search = searchQuery.value.trim()
const cardType = cardTypeOptions[cardTypeIndex.value].value
const result = await adminStore.fetchMembers({
- page: page.value,
+ page: requestedPage,
limit: LIMIT,
...(search ? { search } : {}),
...(cardType ? { cardType } : {}),
})
+ if (id !== requestId) return
+ page.value = requestedPage
if (reset) {
members.value = [...result.items]
} else {
@@ -162,14 +179,15 @@ async function loadMembers(reset = false) {
total.value = result.total
hasMore.value = members.value.length < result.total
} catch {
- uni.showToast({ title: '加载失败', icon: 'none' })
+ if (id === requestId) uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
- loading.value = false
+ if (id === requestId) loading.value = false
}
}
async function refreshVisibleMembers() {
if (loading.value) return
+ const id = ++requestId
loading.value = true
try {
const search = searchQuery.value.trim()
@@ -181,14 +199,15 @@ async function refreshVisibleMembers() {
...(search ? { search } : {}),
...(cardType ? { cardType } : {}),
})
+ if (id !== requestId) return
members.value = [...result.items]
total.value = result.total
page.value = Math.max(1, Math.ceil(members.value.length / LIMIT) || 1)
hasMore.value = members.value.length < result.total
} catch {
- uni.showToast({ title: '加载失败', icon: 'none' })
+ if (id === requestId) uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
- loading.value = false
+ if (id === requestId) loading.value = false
}
}
@@ -203,7 +222,6 @@ function onClear() {
onReachBottom(() => {
if (!hasMore.value || loading.value) return
- page.value++
loadMembers(false)
})
@@ -244,19 +262,27 @@ onShow(() => {
position: relative;
}
+.search-field {
+ flex: 1;
+ min-width: 0;
+ position: relative;
+}
+
.search-input {
flex: 1;
+ min-width: 0;
height: 72rpx;
background: $bg-page;
border-radius: 36rpx;
- padding: 0 28rpx;
+ padding: 0 60rpx 0 24rpx;
font-size: 26rpx;
color: $text-primary;
}
.search-clear {
position: absolute;
- right: 260rpx;
+ right: 12rpx;
+ top: 14rpx;
width: 44rpx;
height: 44rpx;
display: flex;
@@ -297,7 +323,7 @@ onShow(() => {
.type-picker-text {
font-size: 24rpx;
color: $text-secondary;
- max-width: 80rpx;
+ max-width: 120rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
diff --git a/packages/app/src/stores/admin.ts b/packages/app/src/stores/admin.ts
index 2a1b5aa..c8c8119 100644
--- a/packages/app/src/stores/admin.ts
+++ b/packages/app/src/stores/admin.ts
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { get, post, put, del } from '../utils/request'
import type {
+ TeachingAnalytics,
CardType,
CreateCardTypeDto,
UpdateCardTypeDto,
@@ -287,7 +288,12 @@ export const useAdminStore = defineStore('admin', () => {
return del<{ deleted: boolean }>(`/admin/flash-sales/${id}`)
}
+ async function fetchTeachingAnalytics(month: string): Promise {
+ return get('/admin/teaching-analytics', { month })
+ }
+
return {
+ fetchTeachingAnalytics,
// State
cardTypes,
studioConfig,
diff --git a/packages/server/src/admin/__tests__/teaching-analytics.service.spec.ts b/packages/server/src/admin/__tests__/teaching-analytics.service.spec.ts
new file mode 100644
index 0000000..81756f0
--- /dev/null
+++ b/packages/server/src/admin/__tests__/teaching-analytics.service.spec.ts
@@ -0,0 +1,75 @@
+import 'reflect-metadata'
+import { BadRequestException } from '@nestjs/common'
+import { BookingStatus, UserRole } from '@mp-pilates/shared'
+import { PrismaService } from '../../prisma/prisma.service'
+import { TeachingAnalyticsService } from '../teaching-analytics.service'
+import { AdminController } from '../admin.controller'
+import { ROLES_KEY } from '../../auth/roles.decorator'
+
+function booking(id: string, userId: string, slotId: string, date: string, status = BookingStatus.COMPLETED) {
+ return {
+ id, userId, status, user: { nickname: '同名学员' },
+ timeSlot: { id: slotId, date: new Date(`${date}T00:00:00Z`), startTime: '09:00', endTime: '10:30' },
+ membership: { cardType: { name: '次卡' } },
+ }
+}
+
+describe('TeachingAnalyticsService', () => {
+ const findMany = jest.fn()
+ const service = new TeachingAnalyticsService({ booking: { findMany } } as unknown as PrismaService)
+ beforeEach(() => { jest.useFakeTimers().setSystemTime(new Date('2026-09-09T03:00:00Z')); findMany.mockReset() })
+ afterEach(() => jest.useRealTimers())
+
+ it.each(['2026-13', '2026-00', '2026-9', '', '2026-09-01', '1999-12', undefined])('rejects invalid month %s before querying', async month => {
+ await expect(service.getMonthly(month as string)).rejects.toBeInstanceOf(BadRequestException)
+ expect(findMany).not.toHaveBeenCalled()
+ })
+
+ it('counts shared sessions and duration once, students by identity, and excludes other statuses', async () => {
+ findMany.mockResolvedValue([
+ booking('previous', 'a', 'old', '2026-08-31'),
+ booking('1', 'a', 'one', '2026-09-01'), booking('2', 'b', 'one', '2026-09-01'),
+ booking('3', 'a', 'two', '2026-09-03'),
+ ...[BookingStatus.CANCELLED, BookingStatus.NO_SHOW, BookingStatus.CONFIRMED, BookingStatus.PENDING_CONFIRMATION]
+ .map((status, index) => booking(`other${index}`, 'c', `other${index}`, '2026-09-04', status)),
+ ])
+ const result = await service.getMonthly('2026-09')
+ expect(result.summary).toEqual({ sessions: 2, attendances: 3, students: 2, minutes: 180, teachingDays: 2 })
+ expect(result.previous.sessions).toBe(1)
+ expect(result.records).toHaveLength(7)
+ expect(result.records.every(row => row.date.startsWith('2026-09'))).toBe(true)
+ expect(findMany).toHaveBeenCalledTimes(1)
+ })
+
+ it.each([
+ ['2026-01', '2025-12-01', '2026-02-01'],
+ ['2024-02', '2024-01-01', '2024-03-01'],
+ ])('uses half-open course date boundaries for %s', async (month, from, to) => {
+ findMany.mockResolvedValue([])
+ const result = await service.getMonthly(month)
+ expect(findMany).toHaveBeenCalledWith(expect.objectContaining({
+ where: { timeSlot: { date: { gte: new Date(`${from}T00:00:00Z`), lt: new Date(`${to}T00:00:00Z`) } } },
+ }))
+ expect(result.previousMonth).toBe(from.slice(0, 7))
+ expect(result.summary).toEqual({ sessions: 0, attendances: 0, students: 0, minutes: 0, teachingDays: 0 })
+ })
+
+ it('flags only unfinished bookings past their China-time end, without converting status', async () => {
+ const future = booking('future', 'b', 'future', '2026-09-09', BookingStatus.CONFIRMED)
+ future.timeSlot.endTime = '11:30'
+ findMany.mockResolvedValue([
+ booking('past', 'a', 'past', '2026-09-09', BookingStatus.CONFIRMED), future,
+ booking('cancel', 'c', 'cancel', '2026-09-09', BookingStatus.CANCELLED),
+ ])
+ const result = await service.getMonthly('2026-09')
+ expect(result.records.map(row => row.needsReview)).toEqual([true, false, false])
+ expect(result.summary.sessions).toBe(0)
+ expect(result.records[0].status).toBe(BookingStatus.CONFIRMED)
+ })
+
+ it('inherits the admin-only controller role and authentication guards', () => {
+ expect(Reflect.getMetadata(ROLES_KEY, AdminController)).toEqual([UserRole.ADMIN])
+ const guards = Reflect.getMetadata('__guards__', AdminController) as Array<{ name: string }>
+ expect(guards.map(guard => guard.name)).toEqual(['JwtAuthGuard', 'RolesGuard'])
+ })
+})
diff --git a/packages/server/src/admin/admin.controller.ts b/packages/server/src/admin/admin.controller.ts
index ddfdaff..51e7a91 100644
--- a/packages/server/src/admin/admin.controller.ts
+++ b/packages/server/src/admin/admin.controller.ts
@@ -1,4 +1,5 @@
-import { Controller, Get, UseGuards } from '@nestjs/common'
+import { TeachingAnalyticsService } from './teaching-analytics.service'
+import { Controller, Get, Query, UseGuards } from '@nestjs/common'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { Roles } from '../auth/roles.decorator'
import { RolesGuard } from '../auth/roles.guard'
@@ -15,7 +16,12 @@ interface AdminStats {
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
export class AdminController {
- constructor(private readonly prisma: PrismaService) {}
+ constructor(private readonly prisma: PrismaService, private readonly analytics: TeachingAnalyticsService) {}
+
+ @Get('teaching-analytics')
+ getTeachingAnalytics(@Query('month') month: string) {
+ return this.analytics.getMonthly(month)
+ }
@Get('stats')
async getStats(): Promise {
diff --git a/packages/server/src/admin/admin.module.ts b/packages/server/src/admin/admin.module.ts
index 2694fa3..5c5c5ca 100644
--- a/packages/server/src/admin/admin.module.ts
+++ b/packages/server/src/admin/admin.module.ts
@@ -1,7 +1,9 @@
+import { TeachingAnalyticsService } from './teaching-analytics.service'
import { Module } from '@nestjs/common'
import { AdminController } from './admin.controller'
@Module({
controllers: [AdminController],
+ providers: [TeachingAnalyticsService],
})
export class AdminModule {}
\ No newline at end of file
diff --git a/packages/server/src/admin/teaching-analytics.service.ts b/packages/server/src/admin/teaching-analytics.service.ts
new file mode 100644
index 0000000..091a65b
--- /dev/null
+++ b/packages/server/src/admin/teaching-analytics.service.ts
@@ -0,0 +1,58 @@
+import { BadRequestException, Injectable } from '@nestjs/common'
+import { BookingStatus, type TeachingAnalytics, type TeachingAnalyticsRecord, type TeachingAnalyticsSummary } from '@mp-pilates/shared'
+import { PrismaService } from '../prisma/prisma.service'
+
+@Injectable()
+export class TeachingAnalyticsService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ async getMonthly(month: string): Promise {
+ if (typeof month !== 'string' || !/^(20\d{2})-(0[1-9]|1[0-2])$/.test(month)) {
+ throw new BadRequestException('月份格式应为 YYYY-MM,范围为 2000—2099 年')
+ }
+ const [year, number] = month.split('-').map(Number)
+ const start = new Date(Date.UTC(year, number - 1, 1))
+ const previousStart = new Date(Date.UTC(year, number - 2, 1))
+ const end = new Date(Date.UTC(year, number, 1))
+ const now = new Date()
+ const bookings = await this.prisma.booking.findMany({
+ where: { timeSlot: { date: { gte: previousStart, lt: end } } },
+ select: {
+ id: true, userId: true, status: true,
+ user: { select: { nickname: true } },
+ timeSlot: { select: { id: true, date: true, startTime: true, endTime: true } },
+ membership: { select: { cardType: { select: { name: true } } } },
+ },
+ orderBy: [{ timeSlot: { date: 'asc' } }, { timeSlot: { startTime: 'asc' } }, { id: 'asc' }],
+ })
+ const rows: TeachingAnalyticsRecord[] = bookings.map((booking) => {
+ const slot = booking.timeSlot
+ const date = slot.date.toISOString().slice(0, 10)
+ const unfinished = booking.status === BookingStatus.CONFIRMED || booking.status === BookingStatus.PENDING_CONFIRMATION
+ return {
+ id: booking.id, userId: booking.userId, nickname: booking.user.nickname,
+ slotId: slot.id, date, startTime: slot.startTime, endTime: slot.endTime,
+ cardName: booking.membership.cardType.name, status: booking.status as BookingStatus,
+ needsReview: unfinished && new Date(`${date}T${slot.endTime}:00+08:00`).getTime() < now.getTime(),
+ }
+ })
+ const records = rows.filter((row) => row.date >= start.toISOString().slice(0, 10))
+ return {
+ month, generatedAt: now.toISOString(), records,
+ summary: this.summarize(records), previousMonth: previousStart.toISOString().slice(0, 7),
+ previous: this.summarize(rows.filter((row) => row.date < start.toISOString().slice(0, 10))),
+ }
+ }
+
+ private summarize(rows: TeachingAnalyticsRecord[]): TeachingAnalyticsSummary {
+ const completed = rows.filter((row) => row.status === BookingStatus.COMPLETED)
+ const slots = new Map(completed.map((row) => [row.slotId, row]))
+ const minutes = [...slots.values()].reduce((total, slot) => {
+ const parse = (time: string): number => Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5))
+ return total + Math.max(0, parse(slot.endTime) - parse(slot.startTime))
+ }, 0)
+ return { sessions: slots.size, attendances: completed.length,
+ students: new Set(completed.map((row) => row.userId)).size,
+ teachingDays: new Set(completed.map((row) => row.date)).size, minutes }
+ }
+}
diff --git a/packages/server/src/user/__tests__/user.service.spec.ts b/packages/server/src/user/__tests__/user.service.spec.ts
index 2378a59..da0f4bf 100644
--- a/packages/server/src/user/__tests__/user.service.spec.ts
+++ b/packages/server/src/user/__tests__/user.service.spec.ts
@@ -1,6 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing'
import { BadRequestException, NotFoundException } from '@nestjs/common'
import { UserService } from '../user.service'
+import { UserController } from '../user.controller'
import { PrismaService } from '../../prisma/prisma.service'
import {
MembershipStatus,
@@ -457,6 +458,24 @@ describe('UserService', () => {
expect(mockPrisma.lessonSupplement.groupBy).toHaveBeenCalledWith({ by: ['userId'], where: { userId: { in: ['user-1'] }, revokedAt: null }, _sum: { quantity: true } })
})
+ describe('member filters', () => {
+ it.each([
+ ['ACTIVE', { memberships: { some: { status: MembershipStatus.ACTIVE } } }],
+ ['NONE', { NOT: { memberships: { some: { status: MembershipStatus.ACTIVE } } } }],
+ ['TIMES', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.TIMES } } } }],
+ ['DURATION', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.DURATION } } } }],
+ ['TRIAL', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.TRIAL } } } }],
+ [undefined, {}],
+ ])('passes %s through the controller and applies the same filter to list and count', async (filter, where) => {
+ mockPrisma.user.findMany.mockResolvedValue([])
+ mockPrisma.user.count.mockResolvedValue(0)
+ const controller = new UserController(service)
+ await controller.getMembers('2', '20', undefined, filter as string | undefined)
+ expect(mockPrisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ where, skip: 20, take: 20 }))
+ expect(mockPrisma.user.count).toHaveBeenCalledWith({ where })
+ })
+ })
+
describe('getMemberDetail', () => {
const cardType = {
id: 'ct-1',
diff --git a/packages/server/src/user/user.controller.ts b/packages/server/src/user/user.controller.ts
index 3f61fca..71812f6 100644
--- a/packages/server/src/user/user.controller.ts
+++ b/packages/server/src/user/user.controller.ts
@@ -76,7 +76,7 @@ export class UserController {
@Query('cardType') cardType?: string,
) {
const validCardType =
- cardType && cardType !== 'undefined' && (VALID_CARD_TYPES.has(cardType) || cardType === 'NONE')
+ cardType && cardType !== 'undefined' && (VALID_CARD_TYPES.has(cardType) || cardType === 'NONE' || cardType === 'ACTIVE')
? cardType
: undefined
return this.userService.getMembers(
diff --git a/packages/server/src/user/user.service.ts b/packages/server/src/user/user.service.ts
index 70497a3..6175e2e 100644
--- a/packages/server/src/user/user.service.ts
+++ b/packages/server/src/user/user.service.ts
@@ -382,9 +382,11 @@ export class UserService {
}
: {}
- // cardType filter: NONE = no active membership, otherwise filter by card type category
+ // ACTIVE and NONE are complementary membership-status filters.
if (cardType === 'NONE') {
where.NOT = { memberships: { some: { status: MembershipStatus.ACTIVE } } }
+ } else if (cardType === 'ACTIVE') {
+ where.memberships = { some: { status: MembershipStatus.ACTIVE } }
} else if (cardType && VALID_CARD_TYPES.has(cardType)) {
where.memberships = {
some: {
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 7c81529..f70d034 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -53,6 +53,9 @@ export type {
// Types
export type {
+ TeachingAnalytics,
+ TeachingAnalyticsRecord,
+ TeachingAnalyticsSummary,
User,
UserProfileResponse,
UpdateProfileDto,
diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts
index c7f94df..3877c7d 100644
--- a/packages/shared/src/types/index.ts
+++ b/packages/shared/src/types/index.ts
@@ -59,3 +59,5 @@ export type {
InviteActivitySummary,
} from './invite'
export { FlashSalePhase } from './flash-sale'
+
+export type { TeachingAnalytics, TeachingAnalyticsRecord, TeachingAnalyticsSummary } from './teaching-analytics'
diff --git a/packages/shared/src/types/teaching-analytics.ts b/packages/shared/src/types/teaching-analytics.ts
new file mode 100644
index 0000000..cde7162
--- /dev/null
+++ b/packages/shared/src/types/teaching-analytics.ts
@@ -0,0 +1,31 @@
+import type { BookingStatus } from '../enums'
+
+export interface TeachingAnalyticsRecord {
+ id: string
+ userId: string
+ nickname: string
+ slotId: string
+ date: string
+ startTime: string
+ endTime: string
+ cardName: string
+ status: BookingStatus
+ needsReview: boolean
+}
+
+export interface TeachingAnalyticsSummary {
+ sessions: number
+ attendances: number
+ students: number
+ minutes: number
+ teachingDays: number
+}
+
+export interface TeachingAnalytics {
+ month: string
+ generatedAt: string
+ summary: TeachingAnalyticsSummary
+ previousMonth: string
+ previous: TeachingAnalyticsSummary
+ records: TeachingAnalyticsRecord[]
+}