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:
37
packages/server/src/time-slot/dto/publish-day-slots.dto.ts
Normal file
37
packages/server/src/time-slot/dto/publish-day-slots.dto.ts
Normal 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[]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user