feat(app): 新增个人中心课表视图
This commit is contained in:
@@ -82,15 +82,25 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
badge: bookingBadge,
|
||||
requireAuth: true,
|
||||
},
|
||||
...(props.inviteShareEligible
|
||||
...(props.isAdmin
|
||||
? [{
|
||||
key: 'invite',
|
||||
key: 'teaching-schedule',
|
||||
type: 'item' as const,
|
||||
title: '邀请好友',
|
||||
path: '/pages/profile/invite',
|
||||
title: '我的课表',
|
||||
path: '/pages/profile/teaching-schedule',
|
||||
requireAuth: true,
|
||||
}]
|
||||
: []),
|
||||
// 临时隐藏邀请好友入口,后续恢复时直接取消这段注释即可。
|
||||
// ...(props.inviteShareEligible
|
||||
// ? [{
|
||||
// key: 'invite',
|
||||
// type: 'item' as const,
|
||||
// title: '邀请好友',
|
||||
// path: '/pages/profile/invite',
|
||||
// requireAuth: true,
|
||||
// }]
|
||||
// : []),
|
||||
{
|
||||
key: 'info',
|
||||
type: 'item',
|
||||
@@ -236,6 +246,29 @@ function handleTap(item: MenuItem) {
|
||||
}
|
||||
}
|
||||
|
||||
&--teaching-schedule {
|
||||
background: rgba(93, 140, 138, 0.12);
|
||||
&::before {
|
||||
content: '';
|
||||
width: 24rpx;
|
||||
height: 22rpx;
|
||||
border: 2.5rpx solid #476d72;
|
||||
border-radius: 6rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
transform: translate(-30%, -18%) rotate(45deg);
|
||||
border-top: 2.5rpx solid #476d72;
|
||||
border-left: 2.5rpx solid #476d72;
|
||||
}
|
||||
}
|
||||
|
||||
&--invite {
|
||||
background: rgba(255, 122, 69, 0.12);
|
||||
&::before {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "普拉提约课",
|
||||
"appid": "",
|
||||
"appid": "wx3e7a133d2305fa2c",
|
||||
"description": "普拉提工作室约课小程序",
|
||||
"versionName": "0.1.0",
|
||||
"versionCode": "100",
|
||||
"transformPx": false,
|
||||
"mp-weixin": {
|
||||
"appid": "",
|
||||
"appid": "wx3e7a133d2305fa2c",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"es6": true,
|
||||
|
||||
@@ -45,6 +45,12 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/teaching-schedule",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/info",
|
||||
"style": {
|
||||
|
||||
582
packages/app/src/pages/profile/teaching-schedule.vue
Normal file
582
packages/app/src/pages/profile/teaching-schedule.vue
Normal file
@@ -0,0 +1,582 @@
|
||||
<template>
|
||||
<view class="schedule-page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="我的课表" show-back />
|
||||
|
||||
<view class="schedule-hero">
|
||||
<view class="schedule-hero__copy">
|
||||
<text class="schedule-hero__eyebrow">Teaching Day</text>
|
||||
<text class="schedule-hero__title">按日查看当天课程与学员</text>
|
||||
<text class="schedule-hero__desc">只显示你当天有学员的课程,按时间顺序一屏速览。</text>
|
||||
</view>
|
||||
<view class="schedule-hero__meta">
|
||||
<text class="schedule-hero__meta-num">{{ summary.slotCount }}</text>
|
||||
<text class="schedule-hero__meta-label">节课程</text>
|
||||
<text class="schedule-hero__meta-sub">{{ summary.studentCount }} 位学员</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="schedule-toolbar">
|
||||
<DateSelector v-model="selectedDate" variant="booking" @select="handleDateSelect" />
|
||||
<view class="schedule-toolbar__summary">
|
||||
<view class="schedule-toolbar__chip">
|
||||
<text class="schedule-toolbar__chip-label">{{ dateLabel }}</text>
|
||||
</view>
|
||||
<view class="schedule-toolbar__chip schedule-toolbar__chip--soft">
|
||||
<text class="schedule-toolbar__chip-label">{{ summaryRangeLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
class="schedule-scroll"
|
||||
scroll-y
|
||||
refresher-enabled
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="handleRefresh"
|
||||
>
|
||||
<view v-if="loading && !refreshing" class="schedule-skeleton">
|
||||
<view v-for="i in 3" :key="i" class="schedule-skeleton__card">
|
||||
<view class="schedule-skeleton__time" />
|
||||
<view class="schedule-skeleton__line schedule-skeleton__line--long" />
|
||||
<view class="schedule-skeleton__line schedule-skeleton__line--short" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="slots.length === 0" class="schedule-empty">
|
||||
<view class="schedule-empty__badge">休</view>
|
||||
<text class="schedule-empty__title">这一天没有已预约课程</text>
|
||||
<text class="schedule-empty__desc">当前只展示有学员的课程安排,空白日期不会出现占位时段。</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="schedule-list">
|
||||
<view v-for="slot in slots" :key="slot.slotId" class="schedule-card">
|
||||
<view class="schedule-card__rail" />
|
||||
|
||||
<view class="schedule-card__header">
|
||||
<view>
|
||||
<text class="schedule-card__time">{{ slot.startTime.slice(0, 5) }}</text>
|
||||
<text class="schedule-card__range">{{ slot.startTime.slice(0, 5) }} - {{ slot.endTime.slice(0, 5) }}</text>
|
||||
</view>
|
||||
<view class="schedule-card__count">
|
||||
<text class="schedule-card__count-num">{{ slot.students.length }}</text>
|
||||
<text class="schedule-card__count-label">人</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="schedule-card__body">
|
||||
<view v-for="student in slot.students" :key="student.bookingId" class="student-row">
|
||||
<view class="student-row__avatar">{{ getNameInitial(student.nickname) }}</view>
|
||||
<view class="student-row__main">
|
||||
<view class="student-row__headline">
|
||||
<text class="student-row__name">{{ student.nickname || '未命名学员' }}</text>
|
||||
<text class="student-row__status" :class="statusClass(student.status)">
|
||||
{{ statusLabel(student.status) }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="student.phone" class="student-row__phone">{{ maskPhone(student.phone) }}</text>
|
||||
<text v-else class="student-row__phone student-row__phone--muted">未绑定手机号</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="schedule-bottom-space" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { TeachingScheduleSlot } from '@mp-pilates/shared'
|
||||
import { BookingStatus } from '@mp-pilates/shared'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import DateSelector from '../../components/DateSelector.vue'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { formatDate, getWeekdayLabel, isToday } from '../../utils/format'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
|
||||
const bookingStore = useBookingStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const { teachingSchedule, loadingTeachingSchedule } = storeToRefs(bookingStore)
|
||||
const { loggedIn, isAdmin } = storeToRefs(userStore)
|
||||
|
||||
const navBarHeight = ref('64px')
|
||||
const selectedDate = ref(formatDate(new Date()))
|
||||
const refreshing = ref(false)
|
||||
|
||||
const slots = computed<readonly TeachingScheduleSlot[]>(() => teachingSchedule.value)
|
||||
const loading = computed(() => loadingTeachingSchedule.value)
|
||||
|
||||
const summary = computed(() => ({
|
||||
slotCount: slots.value.length,
|
||||
studentCount: slots.value.reduce((sum, slot) => sum + slot.students.length, 0),
|
||||
}))
|
||||
|
||||
const dateLabel = computed(() => {
|
||||
const label = `${selectedDate.value.slice(5, 7)}月${selectedDate.value.slice(8, 10)}日 ${getWeekdayLabel(selectedDate.value)}`
|
||||
return isToday(selectedDate.value) ? `今天 · ${label}` : label
|
||||
})
|
||||
|
||||
const summaryRangeLabel = computed(() => {
|
||||
if (slots.value.length === 0) {
|
||||
return '暂无课程'
|
||||
}
|
||||
|
||||
const first = slots.value[0]
|
||||
const last = slots.value[slots.value.length - 1]
|
||||
return `${first.startTime.slice(0, 5)} - ${last.endTime.slice(0, 5)}`
|
||||
})
|
||||
|
||||
const STATUS_LABELS: Record<BookingStatus, string> = {
|
||||
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
|
||||
[BookingStatus.CONFIRMED]: '已确认',
|
||||
[BookingStatus.CANCELLED]: '已取消',
|
||||
[BookingStatus.COMPLETED]: '已完成',
|
||||
[BookingStatus.NO_SHOW]: '未出席',
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
if (!loggedIn.value) {
|
||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!isAdmin.value) {
|
||||
uni.showToast({ title: '仅管理员可查看', icon: 'none' })
|
||||
return
|
||||
}
|
||||
loadSchedule(selectedDate.value)
|
||||
})
|
||||
|
||||
function handleDateSelect(date: string) {
|
||||
selectedDate.value = date
|
||||
loadSchedule(date)
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
refreshing.value = true
|
||||
try {
|
||||
await loadSchedule(selectedDate.value)
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSchedule(date: string) {
|
||||
try {
|
||||
await bookingStore.fetchTeachingSchedule(date)
|
||||
} catch (err: unknown) {
|
||||
uni.showToast({ title: getErrorMessage(err, '课表加载失败'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function getNameInitial(name: string): string {
|
||||
const normalized = (name || '?').trim()
|
||||
return normalized.slice(0, 1).toUpperCase()
|
||||
}
|
||||
|
||||
function maskPhone(phone: string): string {
|
||||
return `${phone.slice(0, 3)} ${phone.slice(3, 7)} ${phone.slice(7, 11)}`
|
||||
}
|
||||
|
||||
function statusLabel(status: BookingStatus): string {
|
||||
return STATUS_LABELS[status] ?? status
|
||||
}
|
||||
|
||||
function statusClass(status: BookingStatus): string {
|
||||
return status === BookingStatus.PENDING_CONFIRMATION
|
||||
? 'student-row__status--pending'
|
||||
: 'student-row__status--confirmed'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.schedule-page {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(93, 140, 138, 0.18), transparent 34%),
|
||||
linear-gradient(180deg, #f3ede6 0%, #f7f4ef 30%, #fbfaf7 100%);
|
||||
}
|
||||
|
||||
.schedule-hero {
|
||||
margin: 24rpx 24rpx 20rpx;
|
||||
padding: 32rpx 30rpx;
|
||||
border-radius: 32rpx;
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(60, 86, 92, 0.96), rgba(108, 137, 127, 0.92)),
|
||||
#3e5b60;
|
||||
color: #f8f5ef;
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
box-shadow: 0 22rpx 60rpx rgba(55, 84, 82, 0.18);
|
||||
|
||||
&__copy {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
&__eyebrow {
|
||||
font-size: 20rpx;
|
||||
letter-spacing: 4rpx;
|
||||
text-transform: uppercase;
|
||||
color: rgba(248, 245, 239, 0.7);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 38rpx;
|
||||
line-height: 1.25;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
color: rgba(248, 245, 239, 0.78);
|
||||
}
|
||||
|
||||
&__meta {
|
||||
width: 164rpx;
|
||||
max-width: 100%;
|
||||
border-radius: 24rpx;
|
||||
padding: 22rpx 18rpx;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
&__meta-num {
|
||||
font-size: 52rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
&__meta-label,
|
||||
&__meta-sub {
|
||||
font-size: 22rpx;
|
||||
color: rgba(248, 245, 239, 0.78);
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
padding-bottom: 12rpx;
|
||||
background: linear-gradient(180deg, rgba(247, 244, 239, 0.94), rgba(247, 244, 239, 0.74));
|
||||
backdrop-filter: blur(14rpx);
|
||||
|
||||
&__summary {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
padding: 16rpx 24rpx 0;
|
||||
}
|
||||
|
||||
&__chip {
|
||||
padding: 14rpx 22rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid rgba(93, 140, 138, 0.12);
|
||||
box-shadow: 0 10rpx 24rpx rgba(80, 92, 82, 0.08);
|
||||
|
||||
&--soft {
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
}
|
||||
|
||||
&__chip-label {
|
||||
font-size: 22rpx;
|
||||
color: #5b6058;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-scroll {
|
||||
height: calc(100vh - v-bind(navBarHeight));
|
||||
}
|
||||
|
||||
.schedule-skeleton {
|
||||
padding: 12rpx 24rpx 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
|
||||
&__card {
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx;
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
}
|
||||
|
||||
&__time,
|
||||
&__line {
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(90deg, rgba(220, 223, 218, 0.7), rgba(239, 241, 238, 0.95), rgba(220, 223, 218, 0.7));
|
||||
background-size: 300% 100%;
|
||||
animation: shimmer 1.4s linear infinite;
|
||||
}
|
||||
|
||||
&__time {
|
||||
width: 180rpx;
|
||||
height: 38rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
&__line {
|
||||
height: 24rpx;
|
||||
margin-top: 14rpx;
|
||||
|
||||
&--long {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&--short {
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-empty {
|
||||
margin: 40rpx 24rpx 0;
|
||||
border-radius: 32rpx;
|
||||
padding: 72rpx 40rpx;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
box-shadow: 0 18rpx 44rpx rgba(108, 122, 112, 0.08);
|
||||
|
||||
&__badge {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: 32rpx;
|
||||
background: linear-gradient(145deg, #e8ddd2, #f5efe8);
|
||||
color: #7e7467;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 34rpx;
|
||||
color: #3f403c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 24rpx;
|
||||
color: #9b958b;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-list {
|
||||
padding: 12rpx 24rpx 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.schedule-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 30rpx;
|
||||
padding: 28rpx 28rpx 18rpx 40rpx;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
box-shadow: 0 20rpx 48rpx rgba(83, 95, 86, 0.1);
|
||||
|
||||
&__rail {
|
||||
position: absolute;
|
||||
top: 24rpx;
|
||||
left: 18rpx;
|
||||
bottom: 24rpx;
|
||||
width: 8rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(180deg, #5d8c8a, #d7c4b1);
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
&__time {
|
||||
display: block;
|
||||
font-size: 46rpx;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
color: #304549;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
&__range {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #8a8e86;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
&__count {
|
||||
min-width: 116rpx;
|
||||
padding: 14rpx 16rpx;
|
||||
border-radius: 20rpx;
|
||||
background: #f3efe8;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__count-num {
|
||||
font-size: 34rpx;
|
||||
color: #6e5b4f;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__count-label {
|
||||
margin-left: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: #907d6f;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.student-row {
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
padding: 20rpx 20rpx 20rpx 16rpx;
|
||||
border-radius: 22rpx;
|
||||
background: linear-gradient(135deg, rgba(246, 244, 239, 0.98), rgba(255, 255, 255, 0.9));
|
||||
|
||||
&__avatar {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 24rpx;
|
||||
background: linear-gradient(145deg, #5d8c8a, #86a99d);
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__headline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #313630;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__status {
|
||||
flex-shrink: 0;
|
||||
padding: 8rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
|
||||
&--pending {
|
||||
background: rgba(206, 164, 96, 0.14);
|
||||
color: #9b6e22;
|
||||
}
|
||||
|
||||
&--confirmed {
|
||||
background: rgba(93, 140, 138, 0.14);
|
||||
color: #376a69;
|
||||
}
|
||||
}
|
||||
|
||||
&__phone {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 24rpx;
|
||||
color: #7b8179;
|
||||
|
||||
&--muted {
|
||||
color: #b0b3ad;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-bottom-space {
|
||||
height: 36rpx;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.schedule-hero {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
&__meta {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-toolbar__summary {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.schedule-card__header,
|
||||
.student-row__headline {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
BookingWithUser,
|
||||
BookingStatusHistory,
|
||||
CreateBookingDto,
|
||||
TeachingScheduleSlot,
|
||||
} from '@mp-pilates/shared'
|
||||
import { get, post, put } from '../utils/request'
|
||||
|
||||
@@ -21,8 +22,10 @@ export const useBookingStore = defineStore('booking', () => {
|
||||
const slots = ref<readonly TimeSlotWithBookingStatus[]>([])
|
||||
const myBookings = ref<readonly BookingWithDetails[]>([])
|
||||
const upcomingBookings = ref<readonly BookingWithDetails[]>([])
|
||||
const teachingSchedule = ref<readonly TeachingScheduleSlot[]>([])
|
||||
const loadingSlots = ref(false)
|
||||
const loadingBookings = ref(false)
|
||||
const loadingTeachingSchedule = ref(false)
|
||||
|
||||
async function fetchSlots(date: string) {
|
||||
loadingSlots.value = true
|
||||
@@ -70,6 +73,21 @@ export const useBookingStore = defineStore('booking', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTeachingSchedule(date: string) {
|
||||
loadingTeachingSchedule.value = true
|
||||
try {
|
||||
const result = await get<TeachingScheduleSlot[]>('/admin/teaching-schedule', { date })
|
||||
teachingSchedule.value = Array.isArray(result) ? result : []
|
||||
return teachingSchedule.value
|
||||
} catch (err) {
|
||||
console.error('Fetch teaching schedule failed:', err)
|
||||
teachingSchedule.value = []
|
||||
throw err
|
||||
} finally {
|
||||
loadingTeachingSchedule.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Admin methods ──────────────────────────────────────────────────────
|
||||
|
||||
async function fetchAllAdminBookings(
|
||||
@@ -124,13 +142,16 @@ export const useBookingStore = defineStore('booking', () => {
|
||||
slots,
|
||||
myBookings,
|
||||
upcomingBookings,
|
||||
teachingSchedule,
|
||||
loadingSlots,
|
||||
loadingBookings,
|
||||
loadingTeachingSchedule,
|
||||
fetchSlots,
|
||||
createBooking,
|
||||
cancelBooking,
|
||||
fetchMyBookings,
|
||||
fetchUpcomingBookings,
|
||||
fetchTeachingSchedule,
|
||||
fetchAllAdminBookings,
|
||||
confirmBooking,
|
||||
completeBooking,
|
||||
|
||||
@@ -11,6 +11,10 @@ import { get, put } from '../utils/request'
|
||||
import { ROUTES } from '../utils/routes'
|
||||
import { cacheSubscriptionMessageTemplateConfig, resetSubscriptionMessageTemplateCache } from '../utils/wechat-subscription'
|
||||
|
||||
function syncSubscriptionTemplates(profile?: Pick<UserProfileResponse, 'subscriptionMessageTemplates'> | null) {
|
||||
cacheSubscriptionMessageTemplateConfig(profile?.subscriptionMessageTemplates)
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// State
|
||||
const user = ref<UserProfileResponse | null>(null)
|
||||
@@ -36,7 +40,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
const result = await wxLogin()
|
||||
token.value = result.token
|
||||
user.value = result.user
|
||||
cacheSubscriptionMessageTemplateConfig(result.user.subscriptionMessageTemplates)
|
||||
syncSubscriptionTemplates(result.user)
|
||||
return { user: result.user, isNewUser: result.isNewUser }
|
||||
} catch (err) {
|
||||
console.error('Login failed:', err)
|
||||
@@ -62,7 +66,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
if (!isLoggedIn()) return
|
||||
try {
|
||||
user.value = await get<UserProfileResponse>('/user/profile')
|
||||
cacheSubscriptionMessageTemplateConfig(user.value.subscriptionMessageTemplates)
|
||||
syncSubscriptionTemplates(user.value)
|
||||
return user.value
|
||||
} catch (err) {
|
||||
console.error('Fetch profile failed:', err)
|
||||
@@ -90,13 +94,13 @@ export const useUserStore = defineStore('user', () => {
|
||||
async function updateProfile(data: { nickname?: string; avatarUrl?: string }) {
|
||||
const updated = await put<UserProfileResponse>('/user/profile', data)
|
||||
user.value = updated
|
||||
cacheSubscriptionMessageTemplateConfig(updated.subscriptionMessageTemplates)
|
||||
syncSubscriptionTemplates(updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
function setProfile(profile: UserProfileResponse) {
|
||||
user.value = profile
|
||||
cacheSubscriptionMessageTemplateConfig(profile.subscriptionMessageTemplates)
|
||||
syncSubscriptionTemplates(profile)
|
||||
}
|
||||
|
||||
function checkAuth() {
|
||||
|
||||
@@ -103,10 +103,18 @@ async function fetchTemplateConfig(): Promise<SubscriptionMessageTemplateConfig>
|
||||
return config
|
||||
}
|
||||
|
||||
export function cacheSubscriptionMessageTemplateConfig(config: SubscriptionMessageTemplateConfig): SubscriptionMessageTemplateConfig {
|
||||
const normalized: SubscriptionMessageTemplateConfig = {
|
||||
templates: config.templates.filter((item) => item.templateId),
|
||||
function normalizeTemplateConfig(config?: Partial<SubscriptionMessageTemplateConfig> | null): SubscriptionMessageTemplateConfig {
|
||||
const templates = Array.isArray(config?.templates) ? config.templates : []
|
||||
|
||||
return {
|
||||
templates: templates.filter((item): item is SubscriptionMessageTemplate => !!item?.templateId),
|
||||
}
|
||||
}
|
||||
|
||||
export function cacheSubscriptionMessageTemplateConfig(
|
||||
config?: Partial<SubscriptionMessageTemplateConfig> | null,
|
||||
): SubscriptionMessageTemplateConfig {
|
||||
const normalized = normalizeTemplateConfig(config)
|
||||
cachedConfig = normalized
|
||||
uni.setStorageSync(TEMPLATE_CONFIG_STORAGE_KEY, normalized)
|
||||
return normalized
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
import { UnauthorizedException } from '@nestjs/common'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
import { MembershipStatus, UserRole } from '@mp-pilates/shared'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { AuthService, RANDOM_FN_TOKEN } from '../auth.service'
|
||||
import { WechatService } from '../wechat.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
@@ -23,6 +24,7 @@ const mockUser = {
|
||||
nickname: TEST_NICKNAME,
|
||||
avatarUrl: null,
|
||||
role: UserRole.MEMBER,
|
||||
adminBookingSubscriptionCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
@@ -30,6 +32,9 @@ const mockUser = {
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockPrismaService = {
|
||||
membership: {
|
||||
count: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
findUniqueOrThrow: jest.fn(),
|
||||
@@ -51,6 +56,10 @@ const mockInviteService = {
|
||||
bindInviterToUser: jest.fn(),
|
||||
}
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn(),
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AuthService', () => {
|
||||
@@ -64,6 +73,7 @@ describe('AuthService', () => {
|
||||
{ provide: WechatService, useValue: mockWechatService },
|
||||
{ provide: JwtService, useValue: mockJwtService },
|
||||
{ provide: InviteService, useValue: mockInviteService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: RANDOM_FN_TOKEN, useValue: () => 0 }, // deterministic nickname
|
||||
],
|
||||
}).compile()
|
||||
@@ -72,6 +82,8 @@ describe('AuthService', () => {
|
||||
|
||||
jest.clearAllMocks()
|
||||
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
|
||||
mockPrismaService.membership.count.mockResolvedValue(0)
|
||||
mockConfigService.get.mockReturnValue('tmpl-booking-confirmed')
|
||||
})
|
||||
|
||||
// ── login ──────────────────────────────────────────────────────────────────
|
||||
@@ -99,7 +111,17 @@ describe('AuthService', () => {
|
||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
||||
data: { openid: OPENID, nickname: TEST_NICKNAME, adminBookingSubscriptionCount: 0 },
|
||||
})
|
||||
expect(result.user).toEqual(mockUser)
|
||||
expect(result.user).toEqual(expect.objectContaining({
|
||||
id: mockUser.id,
|
||||
phone: mockUser.phone,
|
||||
nickname: mockUser.nickname,
|
||||
avatarUrl: mockUser.avatarUrl,
|
||||
role: mockUser.role,
|
||||
activeMembershipCount: 0,
|
||||
inviteShareEligible: false,
|
||||
adminBookingSubscriptionCount: 0,
|
||||
}))
|
||||
expect(result.user.subscriptionMessageTemplates.templates).toHaveLength(2)
|
||||
expect(result.isNewUser).toBe(true)
|
||||
expect(mockInviteService.bindInviterToUser).toHaveBeenCalledWith(USER_ID, undefined)
|
||||
})
|
||||
@@ -139,7 +161,11 @@ describe('AuthService', () => {
|
||||
where: { openid: OPENID },
|
||||
})
|
||||
expect(mockPrismaService.user.create).not.toHaveBeenCalled()
|
||||
expect(result.user).toEqual(mockUser)
|
||||
expect(result.user).toEqual(expect.objectContaining({
|
||||
id: mockUser.id,
|
||||
nickname: mockUser.nickname,
|
||||
role: mockUser.role,
|
||||
}))
|
||||
expect(result.isNewUser).toBe(false)
|
||||
})
|
||||
|
||||
@@ -160,11 +186,35 @@ describe('AuthService', () => {
|
||||
|
||||
const result = await authService.login(loginCode)
|
||||
|
||||
expect(result).toEqual({
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
token: JWT_TOKEN,
|
||||
user: mockUser,
|
||||
isNewUser: false,
|
||||
}))
|
||||
expect(result.user).toEqual(expect.objectContaining({
|
||||
id: mockUser.id,
|
||||
subscriptionMessageTemplates: {
|
||||
templates: [
|
||||
expect.objectContaining({ scene: 'BOOKING_CREATED' }),
|
||||
expect.objectContaining({ scene: 'ADMIN_BOOKING_CREATED' }),
|
||||
],
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
it('includes active membership count and invite eligibility in login response', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||
mockPrismaService.membership.count.mockResolvedValue(2)
|
||||
|
||||
const result = await authService.login(loginCode)
|
||||
|
||||
expect(mockPrismaService.membership.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: USER_ID,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
})
|
||||
expect(result.user.activeMembershipCount).toBe(2)
|
||||
expect(result.user.inviteShareEligible).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Request,
|
||||
UseGuards,
|
||||
} from '@nestjs/common'
|
||||
import type { UserProfileResponse } from '@mp-pilates/shared'
|
||||
import { AuthService } from './auth.service'
|
||||
import { LoginDto } from './dto/login.dto'
|
||||
import { BindPhoneDto } from './dto/bind-phone.dto'
|
||||
@@ -24,7 +25,7 @@ export class AuthController {
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(@Body() loginDto: LoginDto): Promise<{ token: string; user: User; isNewUser: boolean }> {
|
||||
async login(@Body() loginDto: LoginDto): Promise<{ token: string; user: UserProfileResponse; isNewUser: boolean }> {
|
||||
return this.authService.login(
|
||||
loginDto.code,
|
||||
loginDto.nickname,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'
|
||||
import { PassportModule } from '@nestjs/passport'
|
||||
import { JwtModule } from '@nestjs/jwt'
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config'
|
||||
import { MembershipModule } from '../membership/membership.module'
|
||||
import { AuthService, RANDOM_FN_TOKEN } from './auth.service'
|
||||
import { AuthController } from './auth.controller'
|
||||
import { WechatService } from './wechat.service'
|
||||
@@ -14,6 +15,8 @@ import { InviteModule } from '../invite/invite.module'
|
||||
imports: [
|
||||
PassportModule,
|
||||
InviteModule,
|
||||
ConfigModule,
|
||||
MembershipModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
import { User } from '@prisma/client'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
import {
|
||||
MembershipStatus,
|
||||
SubscriptionMessageScene,
|
||||
type SubscriptionMessageTemplate,
|
||||
type SubscriptionMessageTemplateConfig,
|
||||
type UserProfileResponse,
|
||||
UserRole,
|
||||
} from '@mp-pilates/shared'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { WechatService } from './wechat.service'
|
||||
import { InviteService } from '../invite/invite.service'
|
||||
|
||||
export interface LoginResult {
|
||||
token: string
|
||||
user: User
|
||||
user: UserProfileResponse
|
||||
isNewUser: boolean
|
||||
}
|
||||
|
||||
@@ -57,9 +65,53 @@ export class AuthService {
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly wechatService: WechatService,
|
||||
private readonly inviteService: InviteService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(RANDOM_FN_TOKEN) private readonly randomFn: () => number = Math.random,
|
||||
) {}
|
||||
|
||||
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
|
||||
const templates = [
|
||||
{
|
||||
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
|
||||
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
|
||||
usageTarget: 'consent' as const,
|
||||
},
|
||||
{
|
||||
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
|
||||
scene: SubscriptionMessageScene.ADMIN_BOOKING_CREATED,
|
||||
description: '管理员主动增加预约提醒次数,用于接收学员新预约通知',
|
||||
usageTarget: 'counter' as const,
|
||||
},
|
||||
] satisfies SubscriptionMessageTemplate[]
|
||||
|
||||
return {
|
||||
templates: templates.filter((item) => item.templateId),
|
||||
}
|
||||
}
|
||||
|
||||
private async mapLoginUser(user: User): Promise<UserProfileResponse> {
|
||||
const activeMembershipCount = await this.prisma.membership.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
phone: user.phone,
|
||||
nickname: user.nickname,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: user.role as UserRole,
|
||||
activeMembershipCount,
|
||||
inviteShareEligible: activeMembershipCount > 0,
|
||||
adminBookingSubscriptionCount: user.adminBookingSubscriptionCount,
|
||||
subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(),
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
async login(
|
||||
code: string,
|
||||
nickname?: string,
|
||||
@@ -96,7 +148,7 @@ export class AuthService {
|
||||
sessionKeyStore.set(updated.id, sessionKey)
|
||||
const payload: JwtPayload = { sub: updated.id, role: updated.role as UserRole }
|
||||
const token = this.jwtService.sign(payload)
|
||||
return { token, user: updated, isNewUser: false }
|
||||
return { token, user: await this.mapLoginUser(updated), isNewUser: false }
|
||||
}
|
||||
|
||||
sessionKeyStore.set(user.id, sessionKey)
|
||||
@@ -108,7 +160,7 @@ export class AuthService {
|
||||
const payload: JwtPayload = { sub: user.id, role: user.role as UserRole }
|
||||
const token = this.jwtService.sign(payload)
|
||||
|
||||
return { token, user, isNewUser }
|
||||
return { token, user: await this.mapLoginUser(user), isNewUser }
|
||||
}
|
||||
|
||||
async bindPhone(
|
||||
|
||||
@@ -173,6 +173,7 @@ describe('BookingService', () => {
|
||||
},
|
||||
timeSlot: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
membership: {
|
||||
@@ -903,4 +904,101 @@ describe('BookingService', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTeachingScheduleByDate', () => {
|
||||
it('returns sorted slots with active students only', async () => {
|
||||
;(prisma.timeSlot.findMany as jest.Mock).mockResolvedValue([
|
||||
{
|
||||
id: 'slot-02',
|
||||
startTime: '11:00',
|
||||
endTime: '12:00',
|
||||
bookedCount: 1,
|
||||
capacity: 1,
|
||||
bookings: [
|
||||
{
|
||||
id: 'booking-02',
|
||||
status: BookingStatus.CONFIRMED,
|
||||
createdAt: new Date('2026-04-19T01:00:00Z'),
|
||||
user: { id: 'user-02', nickname: '李四', phone: '13800000000' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'slot-01',
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
bookedCount: 2,
|
||||
capacity: 2,
|
||||
bookings: [
|
||||
{
|
||||
id: 'booking-01',
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
createdAt: new Date('2026-04-19T00:00:00Z'),
|
||||
user: { id: 'user-01', nickname: '张三', phone: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const result = await service.getTeachingScheduleByDate('2026-04-19')
|
||||
|
||||
expect(prisma.timeSlot.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
bookings: {
|
||||
some: {
|
||||
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
|
||||
},
|
||||
},
|
||||
}),
|
||||
orderBy: [
|
||||
{ startTime: 'asc' },
|
||||
{ endTime: 'asc' },
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{
|
||||
slotId: 'slot-01',
|
||||
date: '2026-04-19',
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
bookedCount: 2,
|
||||
capacity: 2,
|
||||
students: [
|
||||
{
|
||||
bookingId: 'booking-01',
|
||||
userId: 'user-01',
|
||||
nickname: '张三',
|
||||
phone: null,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slotId: 'slot-02',
|
||||
date: '2026-04-19',
|
||||
startTime: '11:00',
|
||||
endTime: '12:00',
|
||||
bookedCount: 1,
|
||||
capacity: 1,
|
||||
students: [
|
||||
{
|
||||
bookingId: 'booking-02',
|
||||
userId: 'user-02',
|
||||
nickname: '李四',
|
||||
phone: '13800000000',
|
||||
status: BookingStatus.CONFIRMED,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects invalid date input', async () => {
|
||||
await expect(service.getTeachingScheduleByDate('invalid-date')).rejects.toThrow(
|
||||
BadRequestException,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
@@ -91,6 +92,16 @@ export class BookingController {
|
||||
)
|
||||
}
|
||||
|
||||
@Get('admin/teaching-schedule')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
async getTeachingSchedule(@Query('date') date?: string) {
|
||||
if (!date) {
|
||||
throw new BadRequestException('date is required')
|
||||
}
|
||||
return this.bookingService.getTeachingScheduleByDate(date)
|
||||
}
|
||||
|
||||
@Put('booking/:id/confirm')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
|
||||
@@ -6,7 +6,13 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common'
|
||||
import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/client'
|
||||
import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus } from '@mp-pilates/shared'
|
||||
import {
|
||||
BookingStatus,
|
||||
CardTypeCategory,
|
||||
MembershipStatus,
|
||||
TimeSlotStatus,
|
||||
type TeachingScheduleSlot,
|
||||
} from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { MembershipService } from '../membership/membership.service'
|
||||
import { StudioService } from '../studio/studio.service'
|
||||
@@ -582,6 +588,72 @@ export class BookingService {
|
||||
}
|
||||
}
|
||||
|
||||
async getTeachingScheduleByDate(date: string): Promise<TeachingScheduleSlot[]> {
|
||||
const dayStart = new Date(`${date}T00:00:00.000Z`)
|
||||
if (Number.isNaN(dayStart.getTime())) {
|
||||
throw new BadRequestException('Invalid date')
|
||||
}
|
||||
|
||||
const slots = await this.prisma.timeSlot.findMany({
|
||||
where: {
|
||||
date: dayStart,
|
||||
bookings: {
|
||||
some: {
|
||||
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
bookings: {
|
||||
where: {
|
||||
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
nickname: true,
|
||||
phone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ status: 'asc' },
|
||||
{ createdAt: 'asc' },
|
||||
],
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ startTime: 'asc' },
|
||||
{ endTime: 'asc' },
|
||||
],
|
||||
})
|
||||
|
||||
return slots
|
||||
.map((slot) => ({
|
||||
slotId: slot.id,
|
||||
date,
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
bookedCount: slot.bookedCount,
|
||||
capacity: slot.capacity,
|
||||
students: slot.bookings.map((booking) => ({
|
||||
bookingId: booking.id,
|
||||
userId: booking.user.id,
|
||||
nickname: booking.user.nickname,
|
||||
phone: booking.user.phone,
|
||||
status: booking.status as BookingStatus,
|
||||
})),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const byStart = a.startTime.localeCompare(b.startTime)
|
||||
if (byStart !== 0) {
|
||||
return byStart
|
||||
}
|
||||
return a.endTime.localeCompare(b.endTime)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Private Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private async fetchBookingWithRelations(bookingId: string): Promise<BookingWithRelations> {
|
||||
|
||||
@@ -50,6 +50,8 @@ export type {
|
||||
Booking,
|
||||
BookingWithDetails,
|
||||
BookingWithUser,
|
||||
TeachingScheduleStudent,
|
||||
TeachingScheduleSlot,
|
||||
BookingStatusHistory,
|
||||
CreateBookingDto,
|
||||
Order,
|
||||
|
||||
@@ -37,6 +37,24 @@ export interface BookingWithUser extends BookingWithDetails {
|
||||
}
|
||||
}
|
||||
|
||||
export interface TeachingScheduleStudent {
|
||||
readonly bookingId: string
|
||||
readonly userId: string
|
||||
readonly nickname: string
|
||||
readonly phone: string | null
|
||||
readonly status: BookingStatus
|
||||
}
|
||||
|
||||
export interface TeachingScheduleSlot {
|
||||
readonly slotId: string
|
||||
readonly date: string
|
||||
readonly startTime: string
|
||||
readonly endTime: string
|
||||
readonly bookedCount: number
|
||||
readonly capacity: number
|
||||
readonly students: readonly TeachingScheduleStudent[]
|
||||
}
|
||||
|
||||
export interface BookingStatusHistory {
|
||||
readonly id: string
|
||||
readonly bookingId: string
|
||||
|
||||
@@ -11,7 +11,15 @@ export type { CardType, CreateCardTypeDto, UpdateCardTypeDto } from './card-type
|
||||
export type { Membership, MembershipWithCardType } from './membership'
|
||||
export type { WeekTemplate, WeekTemplateInput } from './week-template'
|
||||
export type { TimeSlot, TimeSlotWithBookingStatus, CreateManualSlotDto, ScheduleSlotPreview, PublishDaySlotItem, PublishDaySlotsDto } from './time-slot'
|
||||
export type { Booking, BookingWithDetails, BookingWithUser, BookingStatusHistory, CreateBookingDto } from './booking'
|
||||
export type {
|
||||
Booking,
|
||||
BookingWithDetails,
|
||||
BookingWithUser,
|
||||
TeachingScheduleStudent,
|
||||
TeachingScheduleSlot,
|
||||
BookingStatusHistory,
|
||||
CreateBookingDto,
|
||||
} from './booking'
|
||||
export type { Order, OrderWithDetails, CreateOrderDto, PaymentParams, CreateOrderResponse } from './order'
|
||||
export type {
|
||||
StudioConfig,
|
||||
|
||||
Reference in New Issue
Block a user