feat(admin): implement full day-by-day schedule editor with live preview

## Features

### Admin Schedule Page (`packages/app/src/pages/admin/schedule.vue`)
- Interactive date-based slot editor for managing daily schedules
- Real-time slot editing: start/end times, capacity adjustments
- Slot deletion with conflict warnings when bookings exist
- Add new slots with modal dialog
- Live booking status display (booked count, people names)
- Publish/Save changes with sync feedback
- Revert unsaved changes with confirmation
- Skeleton loading states and empty state handling
- Responsive design with optimized mobile UX

### Backend Enhancements
- **New DTO** (`PublishDaySlotsDto`): Structured slot publishing with validation
  - Date string validation
  - Slot array with existing slot IDs for updates
  - Time and capacity validation per slot

- **Schedule Preview API** (`getSchedulePreview`):
  - Check for existing published slots
  - Fallback to active WeekTemplates for unpublished dates
  - Unified response format with isPublished flag

- **Publish Slots API** (`publishDaySlots`):
  - Atomic transaction for consistency
  - Update existing slots with new times/capacity
  - Create new slots from template data
  - Delete unpublished slots or set to CLOSED if bookings exist
  - Prevent capacity reduction below existing bookings
  - Returns all published slots for feedback

### State Management
- Enhanced admin store with schedule state
- Support for pending/unsaved slot changes
- Optimistic UI updates with server sync

### Documentation
- Comprehensive scheduling system architecture docs
- Quick reference for admin workflows
- Flow diagrams and state transitions
- Implementation guide for future maintenance

## Breaking Changes
None

## Testing Recommendations
- Create slots for future dates via schedule editor
- Verify booking prevention for locked/full slots
- Test capacity adjustments with existing bookings
- Confirm template-based schedule generation
- Verify transaction rollback on publish failures

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
richarjiang
2026-04-05 12:18:49 +08:00
parent 9c5dd4a911
commit b6986ba30c
29 changed files with 7810 additions and 19 deletions

View File

@@ -0,0 +1,37 @@
import {
IsString,
IsOptional,
IsInt,
IsArray,
IsDateString,
Min,
ValidateNested,
} from 'class-validator'
import { Type } from 'class-transformer'
export class PublishDaySlotItemDto {
@IsOptional()
@IsString()
readonly existingSlotId?: string
@IsString()
readonly startTime!: string
@IsString()
readonly endTime!: string
@IsInt()
@Min(1)
@Type(() => Number)
readonly capacity!: number
}
export class PublishDaySlotsDto {
@IsDateString()
readonly date!: string
@IsArray()
@ValidateNested({ each: true })
@Type(() => PublishDaySlotItemDto)
readonly slots!: PublishDaySlotItemDto[]
}

View File

@@ -20,6 +20,7 @@ import { SlotGeneratorService } from './slot-generator.service'
import { QuerySlotsDto } from './dto/query-slots.dto'
import { CreateManualSlotDto } from './dto/create-manual-slot.dto'
import { UpdateWeekTemplateDto } from './dto/week-template.dto'
import { PublishDaySlotsDto } from './dto/publish-day-slots.dto'
// ---------------------------------------------------------------------------
// Member endpoints
@@ -89,4 +90,17 @@ export class AdminTimeSlotController {
generateSlots() {
return this.slotGeneratorService.generateSlots()
}
// Schedule preview & publish
@Get('schedule/preview')
getSchedulePreview(@Query('date') date: string) {
return this.timeSlotService.getSchedulePreview(date)
}
@Post('schedule/publish')
@HttpCode(HttpStatus.OK)
publishDaySlots(@Body() dto: PublishDaySlotsDto) {
return this.timeSlotService.publishDaySlots(dto)
}
}

View File

@@ -1,9 +1,10 @@
import { Injectable, NotFoundException } from '@nestjs/common'
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'
import { TimeSlotStatus, BookingStatus, DEFAULT_SLOT_CAPACITY } from '@mp-pilates/shared'
import { TimeSlotSource } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import type { TimeSlotWithBookingStatus } from '@mp-pilates/shared'
import type { TimeSlotWithBookingStatus, ScheduleSlotPreview } from '@mp-pilates/shared'
import type { CreateManualSlotDto } from './dto/create-manual-slot.dto'
import type { PublishDaySlotsDto } from './dto/publish-day-slots.dto'
@Injectable()
export class TimeSlotService {
@@ -125,7 +126,7 @@ export class TimeSlotService {
return this.prisma.$transaction(async (tx) => {
await tx.weekTemplate.deleteMany()
const created = await tx.weekTemplate.createMany({
await tx.weekTemplate.createMany({
data: items.map((item) => ({
dayOfWeek: item.dayOfWeek,
startTime: item.startTime,
@@ -135,7 +136,166 @@ export class TimeSlotService {
})),
})
return created
return tx.weekTemplate.findMany({
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
})
})
}
// ── Schedule preview & publish ──────────────────────────────
/** Convert JS getDay() (0=Sun … 6=Sat) to ISO weekday (1=Mon … 7=Sun) */
private toIsoWeekday(jsDay: number): number {
return jsDay === 0 ? 7 : jsDay
}
/**
* Return a schedule preview for a given date.
* If TimeSlot records already exist → return them (isPublished: true).
* Otherwise → derive from active WeekTemplates (isPublished: false).
*/
async getSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
const parsedDate = new Date(date)
const startOfDay = new Date(parsedDate)
startOfDay.setUTCHours(0, 0, 0, 0)
const endOfDay = new Date(parsedDate)
endOfDay.setUTCHours(23, 59, 59, 999)
// 1. Check for existing TimeSlot records (all statuses)
const existingSlots = await this.prisma.timeSlot.findMany({
where: {
date: { gte: startOfDay, lte: endOfDay },
},
orderBy: { startTime: 'asc' },
})
if (existingSlots.length > 0) {
return existingSlots.map((slot) => ({
id: slot.id,
date: date,
startTime: slot.startTime,
endTime: slot.endTime,
capacity: slot.capacity,
bookedCount: slot.bookedCount,
status: slot.status as TimeSlotStatus,
source: slot.source as TimeSlotSource,
templateId: slot.templateId,
isPublished: true,
}))
}
// 2. No existing slots — derive from WeekTemplate
const isoWeekday = this.toIsoWeekday(parsedDate.getUTCDay())
const templates = await this.prisma.weekTemplate.findMany({
where: { dayOfWeek: isoWeekday, isActive: true },
orderBy: { startTime: 'asc' },
})
return templates.map((tpl) => ({
id: null,
date: date,
startTime: tpl.startTime,
endTime: tpl.endTime,
capacity: tpl.capacity,
bookedCount: 0,
status: TimeSlotStatus.OPEN,
source: TimeSlotSource.TEMPLATE,
templateId: tpl.id,
isPublished: false,
}))
}
/**
* Publish (create/update/remove) time slots for a specific date.
* - Slots with existingSlotId → update
* - New slots → create
* - Existing DB slots not referenced → delete (or CLOSE if they have bookings)
*/
async publishDaySlots(dto: PublishDaySlotsDto) {
const parsedDate = new Date(dto.date)
parsedDate.setUTCHours(0, 0, 0, 0)
const startOfDay = new Date(parsedDate)
const endOfDay = new Date(parsedDate)
endOfDay.setUTCHours(23, 59, 59, 999)
return this.prisma.$transaction(async (tx) => {
// 1. Get existing slots for this date
const existing = await tx.timeSlot.findMany({
where: { date: { gte: startOfDay, lte: endOfDay } },
})
const existingMap = new Map(existing.map((s) => [s.id, s]))
const keptIds = new Set<string>()
const results: Array<{
id: string
date: Date
startTime: string
endTime: string
capacity: number
bookedCount: number
status: string
source: string
}> = []
// 2. Process each slot in the request
for (const item of dto.slots) {
if (item.existingSlotId && existingMap.has(item.existingSlotId)) {
// Update existing slot
const existingSlot = existingMap.get(item.existingSlotId)!
const safeCapacity = Math.max(item.capacity, existingSlot.bookedCount)
const updated = await tx.timeSlot.update({
where: { id: item.existingSlotId },
data: {
startTime: item.startTime,
endTime: item.endTime,
capacity: safeCapacity,
},
})
keptIds.add(item.existingSlotId)
results.push(updated)
} else {
// Create new slot
const created = await tx.timeSlot.create({
data: {
date: parsedDate,
startTime: item.startTime,
endTime: item.endTime,
capacity: item.capacity,
source: TimeSlotSource.MANUAL,
status: TimeSlotStatus.OPEN,
},
})
results.push(created)
}
}
// 3. Handle orphaned existing slots (not in request)
for (const slot of existing) {
if (!keptIds.has(slot.id)) {
if (slot.bookedCount > 0) {
// Has bookings → close instead of delete
await tx.timeSlot.update({
where: { id: slot.id },
data: { status: TimeSlotStatus.CLOSED },
})
} else {
await tx.timeSlot.delete({ where: { id: slot.id } })
}
}
}
return results.map((slot) => ({
id: slot.id,
date: slot.date.toISOString().split('T')[0],
startTime: slot.startTime,
endTime: slot.endTime,
capacity: slot.capacity,
bookedCount: slot.bookedCount,
status: slot.status,
source: slot.source,
}))
})
}
}

File diff suppressed because one or more lines are too long