Files
mp-pilates/QUICK_REFERENCE.md
richarjiang b6986ba30c 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>
2026-04-05 12:18:49 +08:00

15 KiB

Booking Page - Quick Reference & Code Snippets

🚀 Quick Start: Understanding the Flow

Where Slots Come From

// 1. Store calls API
packages/app/src/stores/booking.ts:17-27
async function fetchSlots(date: string) {
  loadingSlots.value = true
  try {
    // GET /time-slot/available?date=2026-04-05
    slots.value = await get<TimeSlotWithBookingStatus[]>(
      '/time-slot/available',
      { date }
    )
  } catch (err) {
    console.error('Fetch slots failed:', err)
    slots.value = []  // ⚠️ Clears on error!
  } finally {
    loadingSlots.value = false
  }
}

Where Time Periods Are Defined

// packages/shared/src/constants.ts:11-15
export const TIME_PERIODS = {
  MORNING: { label: '上午', start: '06:00', end: '12:00' },
  AFTERNOON: { label: '下午', start: '12:00', end: '18:00' },
  EVENING: { label: '晚上', start: '18:00', end: '22:00' },
} as const

Where Filtering Happens

// pages/booking/index.vue:94-103
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
  const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
  if (!selectedPeriod.value) return [...slots]
  
  const period = TIME_PERIODS[selectedPeriod.value]
  return slots.filter((slot) => {
    const t = slot.startTime  // "09:00", "10:00", etc
    return t >= period.start && t < period.end
  })
})

Slot Rendering

<!-- pages/booking/index.vue:34-42 -->
<view v-else class="slot-list">
  <SlotCard
    v-for="slot in filteredSlots"
    :key="slot.id"
    :slot="slot"
    @book="onBookTap"
    @cancel="onCancelTap"
  />
</view>

🔍 Finding Specific Things

Q: Where do the time slot types come from?

A: packages/shared/src/types/time-slot.ts

interface TimeSlotWithBookingStatus extends TimeSlot {
  readonly isBookedByMe: boolean      // true if user booked it
  readonly myBookingId: string | null // needed for cancellation
}

interface TimeSlot {
  readonly id: string          // UUID
  readonly date: string        // "2026-04-05"
  readonly startTime: string   // "09:00"
  readonly endTime: string     // "10:00"
  readonly capacity: number    // 1 (for private lessons)
  readonly bookedCount: number // 0 or 1
  readonly status: TimeSlotStatus  // OPEN|FULL|CLOSED
  readonly source: TimeSlotSource  // TEMPLATE|MANUAL
  readonly templateId: string | null
  readonly createdAt: string
  readonly updatedAt: string
}

Q: Where is the membership selection happening?

A: components/BookingConfirmPopup.vue:136-147

const selectedMembershipId = ref<string>('')

watch(
  [() => props.visible, () => props.memberships],
  ([visible, memberships]) => {
    if (visible && memberships.length > 0) {
      selectedMembershipId.value = memberships[0].id  // Auto-select first
    }
  },
  { immediate: true },
)

Q: Where are the button states determined?

A: components/SlotCard.vue:15-45

<!-- OPEN + not booked by me -->
<template v-if="slot.status === TimeSlotStatus.OPEN && !slot.isBookedByMe">
  <view class="btn btn-book" @tap.stop="emit('book', slot)">
    <text class="btn-text">可预约</text>
  </view>
</template>

<!-- OPEN + booked by me -->
<template v-else-if="slot.status === TimeSlotStatus.OPEN && slot.isBookedByMe">
  <view class="booked-row">
    <view class="badge-booked">
      <text class="badge-text">已预约</text>
    </view>
    <view class="btn-cancel" @tap.stop="emit('cancel', slot)">
      <text class="btn-cancel-text">取消</text>
    </view>
  </view>
</template>

<!-- FULL or CLOSED -->
<template v-else>
  <view class="btn btn-disabled">
    <text class="btn-text">
      {{ slot.status === TimeSlotStatus.FULL ? '已约满' : '已关闭' }}
    </text>
  </view>
</template>

Q: Where is the API request actually made?

A: utils/request.ts:22-59

export function request<T>(options: RequestOptions): Promise<T> {
  return new Promise((resolve, reject) => {
    const token = uni.getStorageSync('token') as string

    uni.request({
      url: `${BASE_URL}${options.url}`,  // BASE_URL = http://localhost:3000/api
      method: options.method || 'GET',
      data: options.data,
      header: {
        'Content-Type': 'application/json',
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
        ...options.header,
      },
      success: (res) => {
        if (res.statusCode === 401) {
          uni.removeStorageSync('token')
          uni.showToast({ title: '请重新登录', icon: 'none' })
          reject(new Error('Unauthorized'))
          return
        }
        if (res.statusCode >= 400) {
          const body = res.data as ApiResponse<unknown>
          reject(new Error(body?.message || `请求失败 (${res.statusCode})`))
          return
        }
        const body = res.data as ApiResponse<T>
        if (body.success) {
          resolve(body.data as T)  // ← Extract data from ApiResponse
        } else {
          reject(new Error(body.message || '请求失败'))
        }
      },
      fail: (err) => {
        reject(new Error(err.errMsg || '网络请求失败'))
      },
    })
  })
}

🐛 Debugging Tips

Tip 1: Check what's in the store

// In browser console while in booking page:
console.log('Slots:', JSON.stringify(uni.$u.pinia.state.value.booking.slots, null, 2))
console.log('Selected period:', uni.$u.pinia.state.value.booking.selectedPeriod)

Tip 2: Log slot filtering

// Add to pages/booking/index.vue filteredSlots computed:
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
  const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
  if (!selectedPeriod.value) {
    console.log('No period filter, showing all slots:', slots.length)
    return [...slots]
  }

  const period = TIME_PERIODS[selectedPeriod.value]
  console.log(`Filtering by ${selectedPeriod.value}:`, period)
  console.log('All slot times:', slots.map(s => s.startTime))
  
  const filtered = slots.filter((slot) => {
    const t = slot.startTime
    const matches = t >= period.start && t < period.end
    if (!matches) console.log(`${t} not in [${period.start}, ${period.end})`)
    return matches
  })
  
  console.log('Filtered result:', filtered.length)
  return filtered
})

Tip 3: Verify API response

// In stores/booking.ts fetchSlots():
async function fetchSlots(date: string) {
  loadingSlots.value = true
  try {
    console.log('Fetching slots for date:', date)
    slots.value = await get<TimeSlotWithBookingStatus[]>(
      '/time-slot/available',
      { date }
    )
    console.log('Received slots:', slots.value)
    console.log('Slot count:', slots.value.length)
    if (slots.value.length > 0) {
      console.log('First slot:', JSON.stringify(slots.value[0], null, 2))
    }
  } catch (err) {
    console.error('Fetch slots failed:', err)
    slots.value = []
  } finally {
    loadingSlots.value = false
  }
}

Tip 4: Check network requests

// Open WeChat DevTools → Network tab
// Look for GET request to /time-slot/available
// Check:
//   ✓ URL has ?date=YYYY-MM-DD
//   ✓ Authorization header exists
//   ✓ Response status 200
//   ✓ Response body has "success": true

Common Issues & Solutions

Issue 1: Slots not loading

Symptoms:

  • Page shows "当日暂无可约时段" (no slots)
  • No error message

Check list:

// 1. Is API endpoint correct?
// Check: /time-slot/available?date=2026-04-05
// Should return TimeSlotWithBookingStatus[]

// 2. Is date format correct?
// Page sends: formatDate(new Date()) → "2026-04-05"
// API expects: "YYYY-MM-DD"
console.log(formatDate(new Date()))  // Should output: "2026-04-05"

// 3. Is authentication working?
console.log('Token:', uni.getStorageSync('token'))

// 4. Check for errors in console
// If fetchSlots fails, slots.value becomes []

Solution:

// In bookingStore.fetchSlots(), add error state:
const error = ref<string | null>(null)

async function fetchSlots(date: string) {
  loadingSlots.value = true
  error.value = null  // Clear previous error
  try {
    slots.value = await get<TimeSlotWithBookingStatus[]>(
      '/time-slot/available',
      { date }
    )
  } catch (err) {
    console.error('Fetch slots failed:', err)
    error.value = err instanceof Error ? err.message : '加载失败'
    slots.value = []
  } finally {
    loadingSlots.value = false
  }
}

// Then in page template:
<view v-if="error" class="error-wrap">
  <text>{{ error }}</text>
  <view @tap="loadSlots(selectedDate)">重试</view>
</view>

Issue 2: Time period filtering not working

Symptoms:

  • Select "上午" (morning) but all slots still show
  • Or vice versa

Check:

// 1. Verify TIME_PERIODS constant
console.log('TIME_PERIODS:', TIME_PERIODS)

// 2. Check selectedPeriod value
console.log('Selected period:', selectedPeriod.value)

// 3. Verify slot.startTime format
// Should be "HH:MM" like "09:00", not "09:00:00"
bookingStore.slots.forEach(slot => {
  console.log('Slot time:', slot.startTime, 'format ok?', /^\d{2}:\d{2}$/.test(slot.startTime))
})

// 4. Test filtering manually
const slot = bookingStore.slots[0]
const period = TIME_PERIODS.MORNING
console.log(`${slot.startTime} >= ${period.start}?`, slot.startTime >= period.start)
console.log(`${slot.startTime} < ${period.end}?`, slot.startTime < period.end)

Solution:

// If time format is "09:00:00", slice it:
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
  const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
  if (!selectedPeriod.value) return [...slots]

  const period = TIME_PERIODS[selectedPeriod.value]
  return slots.filter((slot) => {
    // Ensure HH:MM format
    const t = slot.startTime.slice(0, 5)  // "09:00:00" → "09:00"
    return t >= period.start && t < period.end
  })
})

Issue 3: Booking button not responding

Symptoms:

  • Click "可预约" but nothing happens
  • No modal appears

Check:

// 1. Is slot.status correct?
console.log('Slot status:', slot.status)
// Should be "OPEN" to show book button

// 2. Is isBookedByMe false?
console.log('Is booked by me?', slot.isBookedByMe)
// Should be false to show book button

// 3. Is onBookTap being called?
// Add to pages/booking/index.vue:
async function onBookTap(slot: TimeSlotWithBookingStatus) {
  console.log('Book tapped for slot:', slot)  // ← Should log
  
  // Rest of code...
}

// 4. Is userStore.loggedIn true?
console.log('Logged in?', userStore.loggedIn)

Issue 4: Membership not showing in popup

Symptoms:

  • Booking popup appears but no membership card shown
  • "暂无可用会员卡" displayed

Check:

// 1. Are memberships loaded?
console.log('Memberships:', userStore.memberships)

// 2. Are any memberships ACTIVE?
console.log('Active memberships:', userStore.activeMemberships)
console.log('Has valid membership?', userStore.hasValidMembership)

// 3. Are memberships passed to popup?
// In pages/booking/index.vue:
<BookingConfirmPopup
  :memberships="userStore.activeMemberships as MembershipWithCardType[]"
  ...
/>
console.log('Popup passed memberships:', userStore.activeMemberships)

Solution:

// In onMounted:
onMounted(async () => {
  if (userStore.loggedIn && userStore.activeMemberships.length === 0) {
    console.log('Fetching memberships...')
    try {
      await userStore.fetchMemberships()
      console.log('Memberships loaded:', userStore.activeMemberships)
    } catch (err) {
      console.error('Failed to fetch memberships:', err)
      uni.showToast({ title: '加载会员卡失败', icon: 'none' })
    }
  }
  await loadSlots(selectedDate.value)
})

📊 Capacity Display Logic

How Capacity Color is Determined

// components/SlotCard.vue:69-81
const capacityLabel = computed(() => {
  const { bookedCount, capacity, status } = props.slot
  if (status === TimeSlotStatus.CLOSED) return '已关闭'
  return `${bookedCount}/${capacity} 人`
})

const capacityClass = computed(() => {
  const { bookedCount, capacity, status } = props.slot
  if (status === TimeSlotStatus.CLOSED) return 'cap-closed'
  if (status === TimeSlotStatus.FULL) return 'cap-full'
  if (bookedCount >= capacity * 0.8) return 'cap-almost'
  return 'cap-open'
})

// Color mapping in styles:
// cap-open:    #f0faf3 bg, #4caf50 text (green) - <80% booked
// cap-almost:  #fff8ed bg, #f59e0b text (orange) - ≥80% booked
// cap-full:    #fef0f0 bg, #ef4444 text (red) - status: FULL
// cap-closed:  #f5f5f5 bg, #999 text (gray) - status: CLOSED

Example Calculations

// Slot 1: capacity=1, bookedCount=0, status=OPEN
// 0/1 人 in green badge (0% booked)

// Slot 2: capacity=1, bookedCount=1, status=OPEN
// 1/1 人 in red badge (100% booked ≥ 80%)

// Slot 3: capacity=5, bookedCount=4, status=OPEN
// 4/5 人 in orange badge (80% booked ≥ 80%)

// Slot 4: capacity=5, bookedCount=3, status=OPEN
// 3/5 人 in green badge (60% booked < 80%)

🔗 API Contract Summary

GET /time-slot/available

Request:

GET /api/time-slot/available?date=2026-04-05
Authorization: Bearer <token>

Response (200 OK):

{
  "success": true,
  "data": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "date": "2026-04-05",
      "startTime": "09:00",
      "endTime": "10:00",
      "capacity": 1,
      "bookedCount": 0,
      "status": "OPEN",
      "source": "MANUAL",
      "templateId": null,
      "createdAt": "2026-04-01T10:00:00Z",
      "updatedAt": "2026-04-05T09:00:00Z",
      "isBookedByMe": false,
      "myBookingId": null
    }
  ],
  "message": null
}

Error (400):

{
  "success": false,
  "data": null,
  "message": "Invalid date format"
}

POST /booking

Request:

POST /api/booking
{
  "timeSlotId": "550e8400-e29b-41d4-a716-446655440000",
  "membershipId": "220e8400-e29b-41d4-a716-446655440111"
}

Response (201):

{
  "success": true,
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440222",
    "userId": "user-123",
    "timeSlotId": "550e8400-e29b-41d4-a716-446655440000",
    "membershipId": "220e8400-e29b-41d4-a716-446655440111",
    "status": "CONFIRMED",
    "bookedAt": "2026-04-05T10:30:00Z",
    "courseDate": "2026-04-05",
    "courseTime": "09:00",
    "instructorName": "instructor name",
    "isCompleted": false
  },
  "message": null
}

PUT /booking/:id/cancel

Request:

PUT /api/booking/550e8400-e29b-41d4-a716-446655440222/cancel

Response (200):

{
  "success": true,
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440222",
    "status": "CANCELLED",
    "cancelledAt": "2026-04-05T10:35:00Z"
  },
  "message": null
}

🎯 Next Steps for Debugging

  1. Verify API Endpoint

    • Open DevTools → Network
    • Check /time-slot/available?date=... request
    • Confirm response has "success": true
    • Confirm data array is not empty
  2. Check Store State

    • Add console.logs to bookingStore.fetchSlots()
    • Verify slots are set correctly
    • Check loadingSlots toggle
  3. Verify Computed Properties

    • Log filteredSlots in component
    • Check if filtering logic works
    • Verify slot.startTime format
  4. Test User Interaction

    • Click date item → verify onDateSelect fires
    • Click period tab → verify onPeriodChange fires
    • Click book button → verify onBookTap fires
    • Check modals appear
  5. Check Mobile-Specific Issues

    • Test in WeChat DevTools
    • Check rpx calculations
    • Verify touch events work