feat: 优化预定页面UI和交互
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -187,6 +187,7 @@ describe('BookingService', () => {
|
|||||||
count: jest.fn(),
|
count: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
|
groupBy: jest.fn(),
|
||||||
},
|
},
|
||||||
timeSlot: {
|
timeSlot: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
@@ -1170,8 +1171,10 @@ describe('BookingService', () => {
|
|||||||
membership: mockActiveMembership,
|
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.findMany as jest.Mock).mockResolvedValue(bookings)
|
||||||
;(prisma.booking.count as jest.Mock).mockResolvedValue(1)
|
|
||||||
|
|
||||||
const result = await service.getAllBookings(1, 10)
|
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<string, { id: string }[]> = {
|
||||||
|
[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', () => {
|
describe('getTeachingScheduleByDate', () => {
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ export interface CancelBookingResult {
|
|||||||
refunded: boolean
|
refunded: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AdminBookingRow = BookingWithRelations & {
|
||||||
|
user: { id: string; nickname: string; phone: string | null }
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildSlotStartMs(slotDate: Date, startTime: string): number {
|
function buildSlotStartMs(slotDate: Date, startTime: string): number {
|
||||||
@@ -735,23 +739,51 @@ export class BookingService {
|
|||||||
|
|
||||||
// ─── Get All Bookings (Admin) ─────────────────────────────────────────────
|
// ─── Get All Bookings (Admin) ─────────────────────────────────────────────
|
||||||
|
|
||||||
async getAllBookings(
|
// “全部”视图下的展示优先级:待处理的事置顶,历史记录沉底。
|
||||||
page = 1,
|
// NO_SHOW 排在已完成之后,与前端“已完成”统计口径一致。
|
||||||
limit = 10,
|
private static readonly STATUS_PRIORITY: readonly BookingStatus[] = [
|
||||||
status?: BookingStatus,
|
BookingStatus.PENDING_CONFIRMATION,
|
||||||
): Promise<PaginatedResult<BookingWithRelations & { user: { id: string; nickname: string; phone: string | null } }>> {
|
BookingStatus.CONFIRMED,
|
||||||
const where = status ? { status } : {}
|
BookingStatus.COMPLETED,
|
||||||
|
BookingStatus.NO_SHOW,
|
||||||
|
BookingStatus.CANCELLED,
|
||||||
|
]
|
||||||
|
|
||||||
const [bookings, total] = await Promise.all([
|
private readonly adminBookingInclude = {
|
||||||
this.prisma.booking.findMany({
|
|
||||||
where,
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, nickname: true, phone: true } },
|
user: { select: { id: true, nickname: true, phone: true } },
|
||||||
timeSlot: true,
|
timeSlot: true,
|
||||||
membership: { include: { cardType: true } },
|
membership: { include: { cardType: true } },
|
||||||
review: { select: { rating: true } },
|
review: { select: { rating: true } },
|
||||||
},
|
}
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
|
async getAllBookings(
|
||||||
|
page = 1,
|
||||||
|
limit = 10,
|
||||||
|
status?: BookingStatus,
|
||||||
|
): Promise<PaginatedResult<AdminBookingRow>> {
|
||||||
|
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: this.adminBookingInclude,
|
||||||
|
orderBy: this.bookingsOrderByForStatus(status),
|
||||||
skip: (page - 1) * limit,
|
skip: (page - 1) * limit,
|
||||||
take: limit,
|
take: limit,
|
||||||
}),
|
}),
|
||||||
@@ -759,9 +791,49 @@ export class BookingService {
|
|||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: bookings.map((b) => ({ ...b })) as unknown as (BookingWithRelations & {
|
data: bookings.map((b) => ({ ...b })) as unknown as AdminBookingRow[],
|
||||||
user: { id: string; nickname: string; phone: string | null }
|
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<unknown[]>[] = []
|
||||||
|
|
||||||
|
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,
|
total,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
Reference in New Issue
Block a user