diff --git a/packages/app/src/pages/admin/bookings.vue b/packages/app/src/pages/admin/bookings.vue index f2afcea..32c693f 100644 --- a/packages/app/src/pages/admin/bookings.vue +++ b/packages/app/src/pages/admin/bookings.vue @@ -2,51 +2,41 @@ - - - - {{ stats.total }} - 全部 + + + + 课务台账 + {{ todayStamp }} - - {{ stats.pending }} - 待确认 - - - {{ stats.confirmed }} - 已确认 - - - {{ stats.completed }} - 已完成 + + + + + + {{ tab.count ?? 0 }} + + {{ tab.label }} + + - - - - {{ tab.label }} - - - - + - - - + + + @@ -57,65 +47,80 @@ - - 📋 + + + + - 暂无预约 - 当前筛选条件下没有预约记录 + 此刻没有预约 + {{ emptySubText }} - - + + + {{ getDayNumber(booking.timeSlot.date) }} + {{ getMonthLabel(booking.timeSlot.date) }} + + {{ getWeekdayLabel(booking.timeSlot.date) }} + + + + - {{ booking.user?.nickname || '匿名用户' }} {{ booking.user.phone }} - - {{ bookingStatusLabel(booking.status) }} + + + {{ bookingStatusLabel(booking.status) }} - - - {{ formatDateDisplay(booking.timeSlot.date) }} - {{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }} + + + + + + {{ booking.timeSlot.startTime.slice(0, 5) }} – {{ booking.timeSlot.endTime.slice(0, 5) }} + + + + + + {{ booking.membership?.cardType?.name || '—' }} + - - - 使用卡种 - {{ booking.membership?.cardType?.name }} - - - + - - 确认预约 + + 婉拒 - - 取消 + + 确认预约 - - 核销完成 + + 未到 - - 标记未到 + + 核销完成 @@ -126,7 +131,7 @@ :key="idx" class="timeline-item" > - + {{ formatTimelineText(h) }} @@ -134,9 +139,17 @@ - + - 加载更多 + + 继续展开 + + + + + + — 已展示全部 {{ totalCount }} 条 — + @@ -147,40 +160,57 @@ diff --git a/packages/server/src/booking/__tests__/booking.service.spec.ts b/packages/server/src/booking/__tests__/booking.service.spec.ts index 2bb076c..14b186f 100644 --- a/packages/server/src/booking/__tests__/booking.service.spec.ts +++ b/packages/server/src/booking/__tests__/booking.service.spec.ts @@ -187,6 +187,7 @@ describe('BookingService', () => { count: jest.fn(), create: jest.fn(), update: jest.fn(), + groupBy: jest.fn(), }, timeSlot: { findUnique: jest.fn(), @@ -1170,8 +1171,10 @@ describe('BookingService', () => { membership: mockActiveMembership, }, ] + ;(prisma.booking.groupBy as jest.Mock).mockResolvedValue([ + { status: BookingStatus.CONFIRMED, _count: { _all: 1 } }, + ]) ;(prisma.booking.findMany as jest.Mock).mockResolvedValue(bookings) - ;(prisma.booking.count as jest.Mock).mockResolvedValue(1) const result = await service.getAllBookings(1, 10) @@ -1191,6 +1194,45 @@ describe('BookingService', () => { }), ) }) + + it('sorts the unfiltered list by status priority and pages across segments', async () => { + // 全部视图:待确认 → 已确认 → 已完成 → 已取消,未到归入已完成之后 + ;(prisma.booking.groupBy as jest.Mock).mockResolvedValue([ + { status: BookingStatus.PENDING_CONFIRMATION, _count: { _all: 1 } }, + { status: BookingStatus.CONFIRMED, _count: { _all: 2 } }, + { status: BookingStatus.COMPLETED, _count: { _all: 1 } }, + { status: BookingStatus.CANCELLED, _count: { _all: 1 } }, + ]) + const byStatus: Record = { + [BookingStatus.PENDING_CONFIRMATION]: [{ id: 'b-pending' }], + [BookingStatus.CONFIRMED]: [{ id: 'b-confirmed-1' }, { id: 'b-confirmed-2' }], + [BookingStatus.COMPLETED]: [{ id: 'b-completed' }], + [BookingStatus.CANCELLED]: [{ id: 'b-cancelled' }], + } + ;(prisma.booking.findMany as jest.Mock).mockImplementation( + (args: { where: { status: string } }) => byStatus[args.where.status] ?? [], + ) + + const page1 = await service.getAllBookings(1, 3) + expect(page1.total).toBe(5) + expect(page1.data.map((b) => b.id)).toEqual(['b-pending', 'b-confirmed-1', 'b-confirmed-2']) + + const page2 = await service.getAllBookings(2, 3) + expect(page2.data.map((b) => b.id)).toEqual(['b-completed', 'b-cancelled']) + + // 段内排序:已确认按上课时间正序,其余按创建时间倒序 + const confirmedCall = (prisma.booking.findMany as jest.Mock).mock.calls.find( + ([args]: [{ where: { status: string } }]) => args.where.status === BookingStatus.CONFIRMED, + ) + expect(confirmedCall[0].orderBy).toEqual([ + { timeSlot: { date: 'asc' } }, + { timeSlot: { startTime: 'asc' } }, + ]) + const pendingCall = (prisma.booking.findMany as jest.Mock).mock.calls.find( + ([args]: [{ where: { status: string } }]) => args.where.status === BookingStatus.PENDING_CONFIRMATION, + ) + expect(pendingCall[0].orderBy).toEqual({ createdAt: 'desc' }) + }) }) describe('getTeachingScheduleByDate', () => { diff --git a/packages/server/src/booking/booking.service.ts b/packages/server/src/booking/booking.service.ts index 99ff900..fdae2e8 100644 --- a/packages/server/src/booking/booking.service.ts +++ b/packages/server/src/booking/booking.service.ts @@ -41,6 +41,10 @@ export interface CancelBookingResult { refunded: boolean } +export type AdminBookingRow = BookingWithRelations & { + user: { id: string; nickname: string; phone: string | null } +} + // ─── Helpers ──────────────────────────────────────────────────────────────── function buildSlotStartMs(slotDate: Date, startTime: string): number { @@ -735,23 +739,51 @@ export class BookingService { // ─── Get All Bookings (Admin) ───────────────────────────────────────────── + // “全部”视图下的展示优先级:待处理的事置顶,历史记录沉底。 + // NO_SHOW 排在已完成之后,与前端“已完成”统计口径一致。 + private static readonly STATUS_PRIORITY: readonly BookingStatus[] = [ + BookingStatus.PENDING_CONFIRMATION, + BookingStatus.CONFIRMED, + BookingStatus.COMPLETED, + BookingStatus.NO_SHOW, + BookingStatus.CANCELLED, + ] + + private readonly adminBookingInclude = { + user: { select: { id: true, nickname: true, phone: true } }, + timeSlot: true, + membership: { include: { cardType: true } }, + review: { select: { rating: true } }, + } + async getAllBookings( page = 1, limit = 10, status?: BookingStatus, - ): Promise> { - const where = status ? { status } : {} + ): Promise> { + return status ? this.getBookingsPageByStatus(page, limit, status) : this.getAllBookingsPageByStatusPriority(page, limit) + } + // Confirmed-but-not-yet-completed sessions are the ones a teacher is about + // to run, so sort them by upcoming time (soonest first). For every other + // status (PENDING / COMPLETED / CANCELLED / NO_SHOW), creation order + // (newest first) is the more useful default. + private bookingsOrderByForStatus(status: BookingStatus) { + return status === BookingStatus.CONFIRMED + ? [ + { timeSlot: { date: 'asc' as const } }, + { timeSlot: { startTime: 'asc' as const } }, + ] + : { createdAt: 'desc' as const } + } + + private async getBookingsPageByStatus(page: number, limit: number, status: BookingStatus) { + const where = { status } const [bookings, total] = await Promise.all([ this.prisma.booking.findMany({ where, - include: { - user: { select: { id: true, nickname: true, phone: true } }, - timeSlot: true, - membership: { include: { cardType: true } }, - review: { select: { rating: true } }, - }, - orderBy: { createdAt: 'desc' }, + include: this.adminBookingInclude, + orderBy: this.bookingsOrderByForStatus(status), skip: (page - 1) * limit, take: limit, }), @@ -759,9 +791,49 @@ export class BookingService { ]) return { - data: bookings.map((b) => ({ ...b })) as unknown as (BookingWithRelations & { - user: { id: string; nickname: string; phone: string | null } - })[], + data: bookings.map((b) => ({ ...b })) as unknown as AdminBookingRow[], + total, + page, + limit, + } + } + + // Prisma 的 orderBy 表达不了 CASE 式的状态优先级,所以“全部”视图先按状态 + // groupBy 计数,再把分页窗口按优先级切进各状态段分别查询,最后按优先级拼接。 + private async getAllBookingsPageByStatusPriority(page: number, limit: number) { + const groups = await this.prisma.booking.groupBy({ + by: ['status'], + _count: { _all: true }, + }) + const counts = new Map(groups.map((g) => [g.status, g._count._all])) + const total = BookingService.STATUS_PRIORITY.reduce((sum, s) => sum + (counts.get(s) ?? 0), 0) + + const windowStart = (page - 1) * limit + const windowEnd = windowStart + limit + let segmentStart = 0 + const queries: Promise[] = [] + + for (const status of BookingService.STATUS_PRIORITY) { + const segmentCount = counts.get(status) ?? 0 + if (segmentCount === 0) continue + const segmentEnd = segmentStart + segmentCount + if (segmentEnd > windowStart && segmentStart < windowEnd) { + queries.push( + this.prisma.booking.findMany({ + where: { status }, + include: this.adminBookingInclude, + orderBy: this.bookingsOrderByForStatus(status), + skip: Math.max(0, windowStart - segmentStart), + take: Math.min(segmentEnd, windowEnd) - Math.max(segmentStart, windowStart), + }), + ) + } + segmentStart = segmentEnd + } + + const segments = await Promise.all(queries) + return { + data: segments.flat() as unknown as AdminBookingRow[], total, page, limit,