feat: 新的预约列表样式

This commit is contained in:
richarjiang
2026-04-06 21:22:18 +08:00
parent 168968f073
commit f94b48203f
11 changed files with 599 additions and 342 deletions

View File

@@ -1,83 +1,88 @@
<template> <template>
<view class="slot-card-wrapper"> <view class="slot-card-wrapper" :class="[`status-${statusClass}`]" @tap="emit('cardTap', timeSlot)">
<!-- Date display above card --> <!-- Ticket background image -->
<view class="slot-date"> <image
{{ timeSlot.date }} class="ticket-bg"
src="/static/courseBg.png"
mode="aspectFill"
/>
<!-- Card content overlay -->
<view class="ticket-content">
<!-- Top section: Time row (like flight ticket) -->
<view class="ticket-top">
<!-- Left: Start time -->
<view class="time-block">
<text class="time-main">{{ startTimeDisplay }}</text>
<text class="time-label">{{ timeSlot.date }}</text>
</view> </view>
<view class="slot-card" :class="[ <!-- Center: Duration + icon -->
{ 'slot-card--booked': timeSlot.isBookedByMe }, <view class="duration-block">
{ 'slot-card--past': isPast && !timeSlot.isBookedByMe }, <view class="duration-line">
{ 'slot-card--full': timeSlot.status === TimeSlotStatus.FULL }, <view class="line-dot" />
`status-${statusClass}` <view class="line-dash" />
]"> <view class="duration-icon">
<!-- Decorative left curves (ticket style) --> <text class="icon-text"></text>
<view class="curve curve-left"> </view>
<view class="curve-inner curve-top" /> <view class="line-dash" />
<view class="curve-inner curve-bottom" /> <view class="line-dot" />
</view>
<text class="duration-text">{{ durationMin }}分钟</text>
</view> </view>
<!-- Main content --> <!-- Right: End time -->
<view class="slot-content"> <view class="time-block time-block--right">
<!-- Left: Time column --> <text class="time-main">{{ endTimeDisplay }}</text>
<view class="time-section"> <view class="capacity-tag" :class="capacityClass">
<text class="start-time">{{ timeSlot.startTime.slice(0, 5) }}</text>
<view class="time-divider" />
<text class="end-time">{{ timeSlot.endTime.slice(0, 5) }}</text>
</view>
<!-- Center divider with dashes -->
<view class="divider-dashes" />
<!-- Center: Course info -->
<view class="course-section">
<text class="course-name">普拉提私教</text>
<view class="course-meta">
<text class="course-duration">{{ durationMin }}分钟</text>
<text class="meta-separator"></text>
<view class="course-capacity" :class="capacityClass">
<text class="capacity-text">{{ capacityLabel }}</text> <text class="capacity-text">{{ capacityLabel }}</text>
</view> </view>
</view> </view>
</view> </view>
<!-- Center divider with dashes --> <!-- Dashed tear-off line -->
<view class="divider-dashes" /> <view class="tear-line" />
<!-- Right: Action section --> <!-- Bottom section: Course name + Action -->
<view class="action-section"> <view class="ticket-bottom">
<!-- Expired badge --> <view class="course-info">
<text class="course-name">普拉提私教</text>
</view>
<!-- Action area -->
<view class="action-area">
<!-- Expired -->
<template v-if="isPast && !timeSlot.isBookedByMe"> <template v-if="isPast && !timeSlot.isBookedByMe">
<view class="action-badge badge-expired"> <view class="action-badge badge-expired">
<text>已过期</text> <text>已过期</text>
</view> </view>
</template> </template>
<!-- OPEN + not booked: Book button --> <!-- OPEN + not booked -->
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && !timeSlot.isBookedByMe"> <template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && !timeSlot.isBookedByMe">
<view class="action-button btn-book" @tap.stop="emit('book', timeSlot)"> <view class="action-btn btn-book" @tap.stop="emit('book', timeSlot)">
<text>预约</text> <text>预约</text>
</view> </view>
</template> </template>
<!-- OPEN + booked by me: Cancel link --> <!-- OPEN + booked by me -->
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && timeSlot.isBookedByMe"> <template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && timeSlot.isBookedByMe">
<view class="action-badge badge-booked"> <view class="action-badge badge-booked">
<text>已预约</text> <text>已预约</text>
</view> </view>
<view class="action-cancel" @tap.stop="emit('cancel', timeSlot)"> <view class="cancel-link" @tap.stop="emit('cancel', timeSlot)">
<text>取消</text> <text>取消</text>
</view> </view>
</template> </template>
<!-- FULL: Full badge --> <!-- FULL -->
<template v-else-if="timeSlot.status === TimeSlotStatus.FULL"> <template v-else-if="timeSlot.status === TimeSlotStatus.FULL">
<view class="action-badge badge-full"> <view class="action-badge badge-full">
<text>已约满</text> <text>已约满</text>
</view> </view>
</template> </template>
<!-- CLOSED: Closed badge --> <!-- CLOSED -->
<template v-else> <template v-else>
<view class="action-badge badge-closed"> <view class="action-badge badge-closed">
<text>已关闭</text> <text>已关闭</text>
@@ -85,12 +90,6 @@
</template> </template>
</view> </view>
</view> </view>
<!-- Decorative right curves (ticket style) -->
<view class="curve curve-right">
<view class="curve-inner curve-top" />
<view class="curve-inner curve-bottom" />
</view>
</view> </view>
</view> </view>
</template> </template>
@@ -109,8 +108,12 @@ const props = defineProps<Props>()
const emit = defineEmits<{ const emit = defineEmits<{
book: [timeSlot: TimeSlotWithBookingStatus] book: [timeSlot: TimeSlotWithBookingStatus]
cancel: [timeSlot: TimeSlotWithBookingStatus] cancel: [timeSlot: TimeSlotWithBookingStatus]
cardTap: [timeSlot: TimeSlotWithBookingStatus]
}>() }>()
const startTimeDisplay = computed(() => props.timeSlot.startTime.slice(0, 5))
const endTimeDisplay = computed(() => props.timeSlot.endTime.slice(0, 5))
const durationMin = computed(() => { const durationMin = computed(() => {
const [sh, sm] = props.timeSlot.startTime.split(':').map(Number) const [sh, sm] = props.timeSlot.startTime.split(':').map(Number)
const [eh, em] = props.timeSlot.endTime.split(':').map(Number) const [eh, em] = props.timeSlot.endTime.split(':').map(Number)
@@ -122,7 +125,7 @@ const capacityLabel = computed(() => {
if (status === TimeSlotStatus.CLOSED) return '已关闭' if (status === TimeSlotStatus.CLOSED) return '已关闭'
if (status === TimeSlotStatus.FULL) return '已约满' if (status === TimeSlotStatus.FULL) return '已约满'
const remaining = capacity - bookedCount const remaining = capacity - bookedCount
return `剩余 ${remaining} 个名额` return `剩余${remaining}`
}) })
const capacityClass = computed(() => { const capacityClass = computed(() => {
@@ -145,187 +148,147 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
/* ─── Wrapper ─── */
.slot-card-wrapper { .slot-card-wrapper {
padding: 0 24rpx 24rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.slot-date {
font-size: 24rpx;
color: #999;
padding: 0 12rpx;
font-weight: 500;
}
/* ─── Main Card ─── */
.slot-card {
position: relative; position: relative;
display: flex; margin: 0 24rpx 20rpx;
align-items: center; min-height: 220rpx;
background: #fff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
transition: all 0.2s ease; transition: all 0.2s ease;
min-height: 140rpx;
padding: 0;
&:active { &:active {
transform: scale(0.98); transform: scale(0.98);
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
} }
/* Status variants */ /* Status-based opacity */
&.status-open { &.status-expired {
background: linear-gradient(135deg, #fff 0%, #fafaf8 100%); opacity: 0.55;
}
&.status-booked {
background: linear-gradient(135deg, #f0f7fb 0%, #f5fbff 100%);
box-shadow: 0 4rpx 20rpx rgba(66, 133, 244, 0.1);
} }
&.status-full, &.status-full,
&.status-closed { &.status-closed {
background: #fafaf8; opacity: 0.75;
opacity: 0.85;
}
&.status-expired {
background: #f8f8f6;
opacity: 0.7;
} }
} }
/* ─── Decorative curves (ticket punch holes) ─── */ /* ─── Ticket background image ─── */
.curve { .ticket-bg {
position: absolute; position: absolute;
width: 48rpx; top: 0;
height: 140rpx; left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
/* ─── Content overlay ─── */
.ticket-content {
position: relative;
z-index: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 28rpx 40rpx 24rpx;
}
/* ═══ Top section: Time row ═══ */
.ticket-top {
display: flex;
align-items: flex-start;
justify-content: space-between; justify-content: space-between;
pointer-events: none;
&.curve-left {
left: -24rpx;
} }
&.curve-right { .time-block {
right: -24rpx; display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 100rpx;
&--right {
align-items: flex-end;
} }
} }
.curve-inner { .time-main {
width: 48rpx; font-size: 40rpx;
height: 48rpx; font-weight: 800;
background: #FAF8F5; /* Match page background */ color: #1a1a2e;
border-radius: 0 48rpx 48rpx 0; line-height: 1;
letter-spacing: 1rpx;
.curve-right & {
border-radius: 48rpx 0 0 48rpx;
}
} }
/* ─── Main content flex layout ─── */ .time-label {
.slot-content { margin-top: 8rpx;
font-size: 22rpx;
color: #999;
font-weight: 500;
}
/* Duration center block */
.duration-block {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
padding: 0 16rpx;
}
.duration-line {
display: flex; display: flex;
align-items: center; align-items: center;
width: 100%; width: 100%;
padding: 28rpx 48rpx; margin-top: 8rpx;
gap: 0;
} }
/* ─── Time Section ─── */ .line-dot {
.time-section { width: 10rpx;
display: flex; height: 10rpx;
flex-direction: column; border-radius: 50%;
align-items: center; background: #ccc;
min-width: 90rpx;
flex-shrink: 0; flex-shrink: 0;
gap: 8rpx;
} }
.start-time { .line-dash {
font-size: 38rpx; flex: 1;
font-weight: 700; height: 2rpx;
color: #1a1a1a;
line-height: 1;
}
.end-time {
font-size: 26rpx;
font-weight: 500;
color: #999;
line-height: 1;
}
.time-divider {
width: 2rpx;
height: 20rpx;
background: #e0dcd6;
border-radius: 1rpx;
margin: 4rpx 0;
}
/* ─── Dashed dividers ─── */
.divider-dashes {
flex: 0 0 auto;
width: 2rpx;
height: 80rpx;
background: repeating-linear-gradient( background: repeating-linear-gradient(
to bottom, to right,
#e0dcd6 0, #d0d0d0 0,
#e0dcd6 8rpx, #d0d0d0 8rpx,
transparent 8rpx, transparent 8rpx,
transparent 16rpx transparent 16rpx
); );
margin: 0 20rpx;
opacity: 0.6;
} }
/* ─── Course Section (center) ─── */ .duration-icon {
.course-section { flex-shrink: 0;
display: flex; width: 48rpx;
flex-direction: column; height: 48rpx;
flex: 1; border-radius: 50%;
align-items: center; background: rgba($primary-color, 0.1);
gap: 8rpx;
min-width: 120rpx;
}
.course-name {
font-size: 32rpx;
font-weight: 700;
color: #1a1a1a;
line-height: 1.2;
}
.course-meta {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10rpx; justify-content: center;
margin: 0 8rpx;
}
.icon-text {
font-size: 22rpx;
}
.duration-text {
margin-top: 6rpx;
font-size: 22rpx; font-size: 22rpx;
color: #999; color: #999;
}
.course-duration {
font-weight: 500; font-weight: 500;
} }
.meta-separator { /* Capacity tag */
color: #ddd; .capacity-tag {
} margin-top: 8rpx;
padding: 4rpx 12rpx;
.course-capacity { border-radius: 6rpx;
font-weight: 600; font-size: 20rpx;
padding: 4rpx 8rpx;
border-radius: 4rpx;
&.cap-open { &.cap-open {
color: #4caf50;
background: rgba(76, 175, 80, 0.08); background: rgba(76, 175, 80, 0.08);
.capacity-text { .capacity-text {
@@ -334,7 +297,6 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
} }
&.cap-almost { &.cap-almost {
color: #f59e0b;
background: rgba(245, 158, 11, 0.08); background: rgba(245, 158, 11, 0.08);
.capacity-text { .capacity-text {
@@ -343,7 +305,6 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
} }
&.cap-full { &.cap-full {
color: #ef4444;
background: rgba(239, 68, 68, 0.08); background: rgba(239, 68, 68, 0.08);
.capacity-text { .capacity-text {
@@ -352,7 +313,6 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
} }
&.cap-closed { &.cap-closed {
color: #999;
background: rgba(0, 0, 0, 0.04); background: rgba(0, 0, 0, 0.04);
.capacity-text { .capacity-text {
@@ -362,53 +322,76 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
} }
.capacity-text { .capacity-text {
font-size: 20rpx;
font-weight: 600; font-weight: 600;
} }
/* ─── Action Section (right) ─── */ /* ═══ Tear-off dashed line ═══ */
.action-section { .tear-line {
display: flex; margin: 20rpx -40rpx 16rpx;
flex-direction: column; height: 2rpx;
align-items: center; background: repeating-linear-gradient(
min-width: 100rpx; to right,
flex-shrink: 0; #e0dcd6 0,
gap: 8rpx; #e0dcd6 10rpx,
transparent 10rpx,
transparent 20rpx
);
opacity: 0.6;
} }
.action-button, /* ═══ Bottom section ═══ */
.action-badge { .ticket-bottom {
padding: 10rpx 20rpx;
border-radius: 20rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: space-between;
}
.course-info {
flex: 1;
}
.course-name {
font-size: 30rpx;
font-weight: 700;
color: #1a1a2e;
letter-spacing: 1rpx;
}
/* ─── Action area ─── */
.action-area {
display: flex;
align-items: center;
gap: 12rpx;
flex-shrink: 0;
}
.action-btn,
.action-badge {
padding: 10rpx 24rpx;
border-radius: 20rpx;
font-size: 26rpx; font-size: 26rpx;
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
} }
.action-button { .btn-book {
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%);
color: #fff;
box-shadow: 0 4rpx 16rpx rgba($primary-dark, 0.3);
min-width: 120rpx; min-width: 120rpx;
height: 60rpx; height: 60rpx;
transition: all 0.15s; transition: all 0.15s;
&:active { &:active {
opacity: 0.85; opacity: 0.85;
transform: scale(0.96);
} }
} }
.btn-book {
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%);
color: #fff;
box-shadow: 0 4rpx 12rpx rgba($primary-dark, 0.25);
}
.action-badge {
padding: 8rpx 16rpx;
font-size: 24rpx;
white-space: nowrap;
}
.badge-booked { .badge-booked {
background: linear-gradient(135deg, $primary-selected-bg, $primary-border); background: linear-gradient(135deg, $primary-selected-bg, $primary-border);
color: $primary-dark; color: $primary-dark;
@@ -429,8 +412,7 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
color: #bbb; color: #bbb;
} }
.action-cancel { .cancel-link {
padding: 4rpx 12rpx;
font-size: 22rpx; font-size: 22rpx;
color: #ef4444; color: #ef4444;
font-weight: 500; font-weight: 500;

View File

@@ -1,18 +1,24 @@
<template> <template>
<view class="booking-detail-page" :style="{ paddingTop: navBarHeight }"> <view class="booking-detail-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="预约详情" show-back /> <CustomNavBar :title="isSlotMode ? '预约课程' : '预约详情'" show-back />
<!-- Loading state --> <!-- Loading state -->
<view v-if="loading" class="loading-wrap"> <view v-if="loading" class="loading-wrap">
<view class="skeleton-card" /> <view class="skeleton-card" />
</view> </view>
<!-- Error state --> <!-- Booking mode: not found -->
<view v-else-if="!booking" class="empty-wrap"> <view v-else-if="!isSlotMode && !booking" class="empty-wrap">
<text class="empty-title">预约不存在</text> <text class="empty-title">预约不存在</text>
</view> </view>
<template v-else> <!-- Slot mode: not found -->
<view v-else-if="isSlotMode && !slotData" class="empty-wrap">
<text class="empty-title">课程不存在</text>
</view>
<!-- Booking detail mode -->
<template v-else-if="!isSlotMode && booking">
<!-- Booking info card --> <!-- Booking info card -->
<view class="info-card"> <view class="info-card">
<!-- Status banner --> <!-- Status banner -->
@@ -99,7 +105,7 @@
<!-- Content --> <!-- Content -->
<view class="timeline-content"> <view class="timeline-content">
<view class="timeline-content-header"> <view class="timeline-content-header">
<text class="timeline-status">{{ formatHistoryStatus(item.toStatus) }}</text> <text class="timeline-status">{{ bookingStatusLabel(item.toStatus) }}</text>
<text class="timeline-time">{{ formatDateTime(item.createdAt) }}</text> <text class="timeline-time">{{ formatDateTime(item.createdAt) }}</text>
</view> </view>
<text v-if="item.remark" class="timeline-remark">{{ item.remark }}</text> <text v-if="item.remark" class="timeline-remark">{{ item.remark }}</text>
@@ -139,17 +145,102 @@
</view> </view>
</view> </view>
</template> </template>
<!-- Slot preview mode -->
<template v-else-if="isSlotMode && slotData">
<!-- Slot info card -->
<view class="info-card">
<!-- Course info -->
<view class="info-section">
<view class="info-row">
<text class="info-label">课程日期</text>
<text class="info-value">{{ formatDateDisplay(slotData.date) }}</text>
</view>
<view class="info-row">
<text class="info-label">课程时间</text>
<text class="info-value">
{{ slotData.startTime.slice(0, 5) }} - {{ slotData.endTime.slice(0, 5) }}
</text>
</view>
<view class="info-row">
<text class="info-label">课程类型</text>
<text class="info-value">普拉提私教</text>
</view>
<view class="info-row">
<text class="info-label">授课方式</text>
<text class="info-value">私教</text>
</view>
</view>
<!-- Capacity info -->
<view class="info-section">
<view class="info-row">
<text class="info-label">课程容量</text>
<text class="info-value">{{ slotData.bookedCount }} / {{ slotData.capacity }} </text>
</view>
<view class="info-row">
<text class="info-label">剩余名额</text>
<view class="capacity-tag" :class="slotCapacityClass">
<text>{{ slotData.capacity - slotData.bookedCount }} </text>
</view>
</view>
</view>
<!-- Course description -->
<view class="info-section">
<view class="section-title">课程介绍</view>
<text class="desc-text">
普拉提私教课程由专业教练一对一指导根据您的身体状况制定个性化训练方案通过精准的核心控制训练帮助您改善体态增强肌力提升柔韧性
</text>
</view>
<!-- Notes -->
<view class="info-section">
<view class="section-title">温馨提示</view>
<text class="desc-text">· 请提前10分钟到达场馆\n· 建议穿着舒适运动服装\n· 课前2小时内避免大量进食\n· 如需取消请提前联系</text>
</view>
</view>
<!-- Action button -->
<view v-if="canBook" class="action-bar">
<view class="action-btn action-btn--confirm" @tap="handleSlotBook">
<text class="action-btn-text">立即预约</text>
</view>
</view>
<view v-else-if="slotData.status === TimeSlotStatus.FULL" class="action-bar">
<view class="action-btn action-btn--disabled">
<text class="action-btn-text">已约满</text>
</view>
</view>
</template>
<!-- Booking confirm popup (slot mode) -->
<BookingConfirmPopup
v-if="isSlotMode"
:visible="showConfirmPopup"
:time-slot="slotData"
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
@confirm="onConfirmBooking"
@cancel="showConfirmPopup = false"
/>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import type { BookingWithDetails, BookingWithUser, BookingStatusHistory } from '@mp-pilates/shared' import type {
import { BookingStatus } from '@mp-pilates/shared' BookingWithDetails,
BookingWithUser,
BookingStatusHistory,
TimeSlotWithBookingStatus,
MembershipWithCardType,
} from '@mp-pilates/shared'
import { BookingStatus, TimeSlotStatus } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking' import { useBookingStore } from '../../stores/booking'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { isSlotPast } from '../../utils/format'
import { import {
formatDateDisplay, formatDateDisplay,
bookingStatusLabel, bookingStatusLabel,
@@ -157,16 +248,29 @@ import {
bookingTimelineDotClass, bookingTimelineDotClass,
} from '../../utils/booking-helpers' } from '../../utils/booking-helpers'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
const bookingStore = useBookingStore() const bookingStore = useBookingStore()
const userStore = useUserStore() const userStore = useUserStore()
const navBarHeight = ref('64px') const navBarHeight = ref('64px')
const loading = ref(false) const loading = ref(false)
// ─── Page mode ────────────────────────────────────────────────────────────
const isSlotMode = ref(false)
// ─── Booking mode state ───────────────────────────────────────────────────
const bookingId = ref('') const bookingId = ref('')
const booking = ref<BookingWithDetails | BookingWithUser | null>(null) const booking = ref<BookingWithDetails | BookingWithUser | null>(null)
const history = ref<BookingStatusHistory[]>([]) const history = ref<BookingStatusHistory[]>([])
// ─── Slot mode state ──────────────────────────────────────────────────────
const slotId = ref('')
const slotDate = ref('')
const slotData = ref<TimeSlotWithBookingStatus | null>(null)
const showConfirmPopup = ref(false)
// ─── Shared computed ──────────────────────────────────────────────────────
const isAdmin = computed(() => userStore.isAdmin) const isAdmin = computed(() => userStore.isAdmin)
const showActions = computed(() => const showActions = computed(() =>
booking.value?.status === BookingStatus.PENDING_CONFIRMATION || booking.value?.status === BookingStatus.PENDING_CONFIRMATION ||
@@ -180,11 +284,23 @@ function hasUser(b: BookingWithDetails | BookingWithUser | null): b is BookingWi
const bookingUser = computed(() => hasUser(booking.value) ? booking.value.user : null) const bookingUser = computed(() => hasUser(booking.value) ? booking.value.user : null)
// ─── Status helpers ─────────────────────────────────────────────────────── // Slot mode computed
function formatHistoryStatus(status: string): string { const canBook = computed(() => {
return bookingStatusLabel(status) if (!slotData.value) return false
} if (slotData.value.status !== TimeSlotStatus.OPEN) return false
if (slotData.value.isBookedByMe) return false
return !isSlotPast(slotData.value.date, slotData.value.startTime)
})
const slotCapacityClass = computed(() => {
if (!slotData.value) return ''
const { bookedCount, capacity } = slotData.value
if (bookedCount >= capacity) return 'cap-full'
if (bookedCount >= capacity * 0.8) return 'cap-almost'
return 'cap-open'
})
// ─── Status helpers ───────────────────────────────────────────────────────
function formatDateTime(dateStr: string): string { function formatDateTime(dateStr: string): string {
if (!dateStr) return '-' if (!dateStr) return '-'
const d = new Date(dateStr) const d = new Date(dateStr)
@@ -197,10 +313,9 @@ function formatDateTime(dateStr: string): string {
} }
// ─── Data loading ───────────────────────────────────────────────────────── // ─── Data loading ─────────────────────────────────────────────────────────
async function loadData() { async function loadBookingData() {
loading.value = true loading.value = true
try { try {
// Fetch booking details and history in parallel
const [bookingData, historyData] = await Promise.all([ const [bookingData, historyData] = await Promise.all([
bookingStore.fetchBookingById(bookingId.value), bookingStore.fetchBookingById(bookingId.value),
bookingStore.fetchBookingHistory(bookingId.value), bookingStore.fetchBookingHistory(bookingId.value),
@@ -216,7 +331,79 @@ async function loadData() {
} }
} }
// ─── Actions ────────────────────────────────────────────────────────────── async function loadSlotData() {
loading.value = true
try {
slotData.value = await bookingStore.fetchSlotById(slotId.value)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '加载失败'
uni.showToast({ title: message, icon: 'none' })
} finally {
loading.value = false
}
}
// ─── Slot mode: Booking flow ─────────────────────────────────────────────
async function handleSlotBook() {
if (!userStore.loggedIn) {
uni.showModal({
title: '提示',
content: '请先登录后再预约课程',
confirmText: '去登录',
success: async (res) => {
if (res.confirm) {
try {
const { isNewUser } = await userStore.loginWithSetup()
if (!isNewUser) {
handleSlotBook()
}
} catch {
uni.showToast({ title: '登录失败', icon: 'none' })
}
}
},
})
return
}
if (!userStore.hasValidMembership) {
uni.showModal({
title: '暂无可用会员卡',
content: '您当前没有有效的会员卡,购买后即可预约课程',
confirmText: '去购买',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
uni.$emit('scrollToCardShop')
uni.switchTab({ url: '/pages/home/index' })
}
},
})
return
}
showConfirmPopup.value = true
}
async function onConfirmBooking(payload: { timeSlotId: string; membershipId: string }) {
showConfirmPopup.value = false
uni.showLoading({ title: '预约中...' })
try {
const result = await bookingStore.createBooking(payload)
uni.hideLoading()
uni.showToast({ title: '预约成功!', icon: 'success' })
// Switch to booking detail mode to show the new booking
isSlotMode.value = false
bookingId.value = result.id
await loadBookingData()
} catch (err: unknown) {
uni.hideLoading()
const message = err instanceof Error ? err.message : '预约失败,请重试'
uni.showToast({ title: message, icon: 'none' })
}
}
// ─── Booking mode: Admin/User actions ────────────────────────────────────
async function handleConfirm() { async function handleConfirm() {
uni.showModal({ uni.showModal({
title: '确认预约', title: '确认预约',
@@ -229,7 +416,7 @@ async function handleConfirm() {
await bookingStore.confirmBooking(bookingId.value) await bookingStore.confirmBooking(bookingId.value)
uni.hideLoading() uni.hideLoading()
uni.showToast({ title: '已确认', icon: 'success' }) uni.showToast({ title: '已确认', icon: 'success' })
await loadData() await loadBookingData()
} catch (err: unknown) { } catch (err: unknown) {
uni.hideLoading() uni.hideLoading()
const msg = err instanceof Error ? err.message : '操作失败' const msg = err instanceof Error ? err.message : '操作失败'
@@ -251,7 +438,7 @@ async function handleComplete() {
await bookingStore.completeBooking(bookingId.value) await bookingStore.completeBooking(bookingId.value)
uni.hideLoading() uni.hideLoading()
uni.showToast({ title: '已核销', icon: 'success' }) uni.showToast({ title: '已核销', icon: 'success' })
await loadData() await loadBookingData()
} catch (err: unknown) { } catch (err: unknown) {
uni.hideLoading() uni.hideLoading()
const msg = err instanceof Error ? err.message : '操作失败' const msg = err instanceof Error ? err.message : '操作失败'
@@ -273,7 +460,7 @@ async function handleNoShow() {
await bookingStore.markNoShow(bookingId.value) await bookingStore.markNoShow(bookingId.value)
uni.hideLoading() uni.hideLoading()
uni.showToast({ title: '已标记', icon: 'success' }) uni.showToast({ title: '已标记', icon: 'success' })
await loadData() await loadBookingData()
} catch (err: unknown) { } catch (err: unknown) {
uni.hideLoading() uni.hideLoading()
const msg = err instanceof Error ? err.message : '操作失败' const msg = err instanceof Error ? err.message : '操作失败'
@@ -291,12 +478,12 @@ async function handleCancel() {
confirmColor: '#ef4444', confirmColor: '#ef4444',
success: async (res) => { success: async (res) => {
if (!res.confirm) return if (!res.confirm) return
uni.showLoading({ title: '处理中...' }) uni.showLoading({ title: '取消中...' })
try { try {
await bookingStore.cancelBooking(bookingId.value) await bookingStore.cancelBooking(bookingId.value)
uni.hideLoading() uni.hideLoading()
uni.showToast({ title: '已取消', icon: 'success' }) uni.showToast({ title: '已取消', icon: 'success' })
await loadData() await loadBookingData()
} catch (err: unknown) { } catch (err: unknown) {
uni.hideLoading() uni.hideLoading()
const msg = err instanceof Error ? err.message : '操作失败' const msg = err instanceof Error ? err.message : '操作失败'
@@ -307,14 +494,28 @@ async function handleCancel() {
} }
// ─── Lifecycle ──────────────────────────────────────────────────────────── // ─── Lifecycle ────────────────────────────────────────────────────────────
onMounted(() => { onMounted(async () => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px` navBarHeight.value = `${getSystemLayout().navBarHeight}px`
// Load memberships if logged in (needed for BookingConfirmPopup)
if (userStore.loggedIn && userStore.activeMemberships.length === 0) {
await userStore.fetchMemberships()
}
}) })
onLoad((query) => { onLoad((query) => {
bookingId.value = (query as Record<string, string>).id || '' const q = query as Record<string, string>
if (bookingId.value) {
loadData() if (q.slotId) {
// Slot preview mode
isSlotMode.value = true
slotId.value = q.slotId
slotDate.value = q.date || ''
loadSlotData()
} else if (q.id) {
// Booking detail mode
isSlotMode.value = false
bookingId.value = q.id
loadBookingData()
} }
}) })
</script> </script>
@@ -361,30 +562,6 @@ onLoad((query) => {
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.05); box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.05);
} }
.status-banner {
padding: 24rpx;
display: flex;
align-items: center;
justify-content: center;
&--pending { background: rgba(245, 158, 11, 0.1); }
&--confirmed { background: rgba(201, 168, 124, 0.1); }
&--completed { background: rgba(102, 187, 106, 0.1); }
&--cancelled { background: rgba(0, 0, 0, 0.04); }
&--noshow { background: rgba(239, 83, 80, 0.1); }
}
.status-banner-text {
font-size: 32rpx;
font-weight: 600;
.status-banner--pending & { color: #f59e0b; }
.status-banner--confirmed & { color: $primary-dark; }
.status-banner--completed & { color: #66bb6a; }
.status-banner--cancelled & { color: #bbb; }
.status-banner--noshow & { color: #ef5350; }
}
.info-section { .info-section {
padding: 24rpx; padding: 24rpx;
border-bottom: 1rpx solid #f5f5f5; border-bottom: 1rpx solid #f5f5f5;
@@ -421,6 +598,34 @@ onLoad((query) => {
font-weight: 500; font-weight: 500;
} }
.capacity-tag {
padding: 4rpx 16rpx;
border-radius: 8rpx;
font-size: 24rpx;
font-weight: 600;
&.cap-open {
background: rgba(76, 175, 80, 0.08);
color: #4caf50;
}
&.cap-almost {
background: rgba(245, 158, 11, 0.08);
color: #f59e0b;
}
&.cap-full {
background: rgba(239, 68, 68, 0.08);
color: #ef4444;
}
}
.desc-text {
font-size: 26rpx;
color: #666;
line-height: 1.7;
}
/* ── Timeline card ────────────────────────────────────── */ /* ── Timeline card ────────────────────────────────────── */
.timeline-card { .timeline-card {
margin: 24rpx; margin: 24rpx;
@@ -570,6 +775,10 @@ onLoad((query) => {
&--cancel { &--cancel {
background: rgba(0, 0, 0, 0.04); background: rgba(0, 0, 0, 0.04);
} }
&--disabled {
background: rgba(0, 0, 0, 0.06);
}
} }
.action-btn-text { .action-btn-text {
@@ -584,5 +793,9 @@ onLoad((query) => {
.action-btn--noshow & { .action-btn--noshow & {
color: #ef5350; color: #ef5350;
} }
.action-btn--disabled & {
color: #bbb;
}
} }
</style> </style>

View File

@@ -61,6 +61,7 @@
:time-slot="item" :time-slot="item"
@book="onBookTap" @book="onBookTap"
@cancel="onCancelTap" @cancel="onCancelTap"
@card-tap="onSlotCardTap"
/> />
</view> </view>
@@ -150,7 +151,7 @@ updateLayout()
// ─── Filtered slots ─────────────────────────────────────── // ─── Filtered slots ───────────────────────────────────────
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => { const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
const slots = bookingStore.slots as TimeSlotWithBookingStatus[] const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
if (!selectedPeriod.value) return [...slots] if (!selectedPeriod.value) return slots
const period = TIME_PERIODS[selectedPeriod.value] const period = TIME_PERIODS[selectedPeriod.value]
return slots.filter((slot) => { return slots.filter((slot) => {
@@ -177,7 +178,19 @@ function onDateSelect(date: string) {
} }
function onPeriodChange(_period: PeriodKey) { function onPeriodChange(_period: PeriodKey) {
// Filtering is done client-side via computed property // No-op: filtering is done client-side via computed property
void _period
}
// ─── Card tap → navigate to detail ───────────────────────
function onSlotCardTap(slot: TimeSlotWithBookingStatus) {
if (slot.isBookedByMe && slot.myBookingId) {
// Already booked → show booking detail
uni.navigateTo({ url: `/pages/booking/detail?id=${slot.myBookingId}` })
} else {
// Not booked → show slot preview with booking action
uni.navigateTo({ url: `/pages/booking/detail?slotId=${slot.id}&date=${slot.date}` })
}
} }
// ─── Book flow ──────────────────────────────────────────── // ─── Book flow ────────────────────────────────────────────
@@ -213,7 +226,9 @@ async function onBookTap(slot: TimeSlotWithBookingStatus) {
cancelText: '取消', cancelText: '取消',
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm) {
uni.navigateTo({ url: '/pages/store/index' }) // Switch to home tab and auto-scroll to card shop
uni.$emit('scrollToCardShop')
uni.switchTab({ url: '/pages/home/index' })
} }
}, },
}) })
@@ -352,7 +367,7 @@ onMounted(async () => {
} }
.skeleton-card { .skeleton-card {
height: 140rpx; height: 220rpx;
border-radius: 20rpx; border-radius: 20rpx;
background: #fff; background: #fff;
display: flex; display: flex;
@@ -360,6 +375,7 @@ onMounted(async () => {
align-items: center; align-items: center;
padding: 28rpx 48rpx; padding: 28rpx 48rpx;
gap: 20rpx; gap: 20rpx;
margin: 0 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.03); box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.03);
} }

View File

@@ -48,8 +48,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, nextTick } from 'vue'
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onShow, onUnmount, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import BrandBanner from '../../components/BrandBanner.vue' import BrandBanner from '../../components/BrandBanner.vue'
import StudioInfo from '../../components/StudioInfo.vue' import StudioInfo from '../../components/StudioInfo.vue'
@@ -86,10 +86,27 @@ const refreshing = ref(false)
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null) const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
const cardShopAnchorId = 'card-shop-anchor' const cardShopAnchorId = 'card-shop-anchor'
const scrollTop = ref(0) const scrollTop = ref(0)
const pendingScrollToCardShop = ref(false)
// Listen for cross-page scroll request (e.g. from booking page "去购买")
uni.$on('scrollToCardShop', () => {
pendingScrollToCardShop.value = true
})
onUnmount(() => {
uni.$off('scrollToCardShop')
})
// Refresh all data on every show // Refresh all data on every show
onShow(async () => { onShow(async () => {
await refreshData() await refreshData()
// If another page requested scroll to card shop, execute after data is ready
if (pendingScrollToCardShop.value) {
pendingScrollToCardShop.value = false
await nextTick()
scrollToCardShop()
}
}) })
async function refreshData() { async function refreshData() {
@@ -118,14 +135,22 @@ async function handleRefresh() {
} }
function scrollToCardShop() { function scrollToCardShop() {
// Reset first so setting the same value still triggers scroll
scrollTop.value = 0
nextTick(() => {
uni.createSelectorQuery() uni.createSelectorQuery()
.select(`#${cardShopAnchorId}`) .select(`#${cardShopAnchorId}`)
.boundingClientRect((rect) => { .boundingClientRect()
if (rect) { .selectViewport()
scrollTop.value = rect.top .scrollOffset()
.exec((res) => {
if (res && res[0] && res[1]) {
const rectTop = (res[0] as UniApp.NodeInfo).top ?? 0
const viewportScroll = (res[1] as UniApp.NodeInfo).scrollTop ?? 0
scrollTop.value = viewportScroll + rectTop
} }
}) })
.exec() })
} }
</script> </script>

View File

@@ -4,25 +4,12 @@
<CustomNavBar title="我的" transparent /> <CustomNavBar title="我的" transparent />
<!-- User card --> <!-- User card -->
<UserCard <UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :stats="stats" :memberships="memberships"
:logged-in="loggedIn" :loading="loginLoading" :nav-bar-height="navBarHeight" @login="handleLogin" />
:has-profile="hasProfile"
:user="user"
:stats="stats"
:memberships="memberships"
:loading="loginLoading"
:nav-bar-height="navBarHeight"
@login="handleLogin"
/>
<!-- Menu section: always visible --> <!-- Menu section: always visible -->
<ProfileMenu <ProfileMenu :is-admin="isAdmin" :require-auth="loggedIn" @clear-cache="handleClearCache" @about="handleAbout"
:is-admin="isAdmin" @require-login="handleLogin" />
:require-auth="loggedIn"
@clear-cache="handleClearCache"
@about="handleAbout"
@require-login="handleLogin"
/>
<!-- Logout button: only when logged in --> <!-- Logout button: only when logged in -->
<view v-if="loggedIn" class="profile-page__logout-wrap"> <view v-if="loggedIn" class="profile-page__logout-wrap">
@@ -131,7 +118,6 @@ function handleAbout() {
<style lang="scss" scoped> <style lang="scss" scoped>
.profile-page { .profile-page {
min-height: 100vh; min-height: 100vh;
background: $bg-page;
&__logout-wrap { &__logout-wrap {
margin: $spacing-xl $spacing-lg $spacing-xl; margin: $spacing-xl $spacing-lg $spacing-xl;

View File

@@ -299,7 +299,7 @@ onMounted(async () => {
position: relative; position: relative;
margin: 0 $spacing-lg $spacing-md; margin: 0 $spacing-lg $spacing-md;
padding: 36rpx 32rpx; padding: 36rpx 32rpx;
background: linear-gradient(135deg, $brand-color 0%, lighten($brand-color, 12%) 100%); background: linear-gradient(135deg, $brand-color 0%, #6b5d52 100%);
border-radius: $radius-lg; border-radius: $radius-lg;
overflow: hidden; overflow: hidden;
@@ -551,7 +551,7 @@ onMounted(async () => {
width: 100%; width: 100%;
height: 96rpx; height: 96rpx;
border-radius: 48rpx; border-radius: 48rpx;
background: linear-gradient(135deg, $brand-color, lighten($brand-color, 8%)); background: linear-gradient(135deg, $brand-color, #5e5045);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -115,6 +115,11 @@ export const useBookingStore = defineStore('booking', () => {
return result return result
} }
async function fetchSlotById(slotId: string) {
const result = await get<TimeSlotWithBookingStatus>(`/time-slot/${slotId}`)
return result
}
return { return {
slots, slots,
myBookings, myBookings,
@@ -131,6 +136,7 @@ export const useBookingStore = defineStore('booking', () => {
completeBooking, completeBooking,
markNoShow, markNoShow,
fetchBookingHistory, fetchBookingHistory,
fetchSlotById,
fetchBookingById, fetchBookingById,
} }
}) })

View File

@@ -40,8 +40,11 @@ export class TimeSlotController {
} }
@Get(':id') @Get(':id')
getSlotById(@Param('id') id: string) { getSlotById(
return this.timeSlotService.getSlotById(id) @Param('id') id: string,
@CurrentUser('sub') userId: string,
) {
return this.timeSlotService.getSlotById(id, userId)
} }
} }

View File

@@ -10,23 +10,58 @@ import type { PublishDaySlotsDto } from './dto/publish-day-slots.dto'
export class TimeSlotService { export class TimeSlotService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
private toDateOfDay(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0))
}
private toEndOfDay(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 23, 59, 59, 999))
}
private mapToWithBookingStatus(
slot: {
id: string
date: Date
startTime: string
endTime: string
capacity: number
bookedCount: number
status: string
source: string
templateId: string | null
createdAt: Date
updatedAt: Date
},
myBooking: { id: string } | null,
): TimeSlotWithBookingStatus {
return {
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 as TimeSlotStatus,
source: slot.source as TimeSlotSource,
templateId: slot.templateId,
createdAt: slot.createdAt.toISOString(),
updatedAt: slot.updatedAt.toISOString(),
isBookedByMe: myBooking !== null,
myBookingId: myBooking?.id ?? null,
}
}
async getAvailableSlots( async getAvailableSlots(
date: string, date: string,
userId?: string, userId?: string,
): Promise<TimeSlotWithBookingStatus[]> { ): Promise<TimeSlotWithBookingStatus[]> {
const parsedDate = new Date(date) const parsedDate = new Date(date)
// Build start/end of day boundaries for the query
const startOfDay = new Date(parsedDate)
startOfDay.setUTCHours(0, 0, 0, 0)
const endOfDay = new Date(parsedDate)
endOfDay.setUTCHours(23, 59, 59, 999)
const slots = await this.prisma.timeSlot.findMany({ const slots = await this.prisma.timeSlot.findMany({
where: { where: {
date: { date: {
gte: startOfDay, gte: this.toDateOfDay(parsedDate),
lte: endOfDay, lte: this.toEndOfDay(parsedDate),
}, },
status: { not: TimeSlotStatus.CLOSED }, status: { not: TimeSlotStatus.CLOSED },
}, },
@@ -50,44 +85,44 @@ export class TimeSlotService {
? slot.bookings[0] ? slot.bookings[0]
: null : null
return { return this.mapToWithBookingStatus(slot, myBooking)
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 as TimeSlotStatus,
source: slot.source as TimeSlotSource,
templateId: slot.templateId,
createdAt: slot.createdAt.toISOString(),
updatedAt: slot.updatedAt.toISOString(),
isBookedByMe: myBooking !== null,
myBookingId: myBooking?.id ?? null,
} satisfies TimeSlotWithBookingStatus
}) })
} }
async getSlotById(id: string) { async getSlotById(id: string, userId?: string): Promise<TimeSlotWithBookingStatus> {
const slot = await this.prisma.timeSlot.findUnique({ const slot = await this.prisma.timeSlot.findUnique({
where: { id }, where: { id },
include: { bookings: true }, include: {
bookings: userId
? {
where: {
userId,
status: BookingStatus.CONFIRMED,
},
select: { id: true },
}
: false,
},
}) })
if (!slot) { if (!slot) {
throw new NotFoundException(`TimeSlot ${id} not found`) throw new NotFoundException(`TimeSlot ${id} not found`)
} }
return slot const myBooking =
userId && Array.isArray(slot.bookings) && slot.bookings.length > 0
? slot.bookings[0]
: null
return this.mapToWithBookingStatus(slot, myBooking)
} }
async createManualSlot(dto: CreateManualSlotDto) { async createManualSlot(dto: CreateManualSlotDto) {
const date = new Date(dto.date) const parsedDate = new Date(dto.date + 'T00:00:00Z')
date.setUTCHours(0, 0, 0, 0)
return this.prisma.timeSlot.create({ return this.prisma.timeSlot.create({
data: { data: {
date, date: parsedDate,
startTime: dto.startTime, startTime: dto.startTime,
endTime: dto.endTime, endTime: dto.endTime,
capacity: dto.capacity ?? DEFAULT_SLOT_CAPACITY, capacity: dto.capacity ?? DEFAULT_SLOT_CAPACITY,
@@ -156,15 +191,11 @@ export class TimeSlotService {
*/ */
async getSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> { async getSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
const parsedDate = new Date(date) 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) // 1. Check for existing TimeSlot records (all statuses)
const existingSlots = await this.prisma.timeSlot.findMany({ const existingSlots = await this.prisma.timeSlot.findMany({
where: { where: {
date: { gte: startOfDay, lte: endOfDay }, date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) },
}, },
orderBy: { startTime: 'asc' }, orderBy: { startTime: 'asc' },
}) })
@@ -212,17 +243,12 @@ export class TimeSlotService {
* - Existing DB slots not referenced → delete (or CLOSE if they have bookings) * - Existing DB slots not referenced → delete (or CLOSE if they have bookings)
*/ */
async publishDaySlots(dto: PublishDaySlotsDto) { async publishDaySlots(dto: PublishDaySlotsDto) {
const parsedDate = new Date(dto.date) const parsedDate = new Date(dto.date + 'T00:00:00Z')
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) => { return this.prisma.$transaction(async (tx) => {
// 1. Get existing slots for this date // 1. Get existing slots for this date
const existing = await tx.timeSlot.findMany({ const existing = await tx.timeSlot.findMany({
where: { date: { gte: startOfDay, lte: endOfDay } }, where: { date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) } },
}) })
const existingMap = new Map(existing.map((s) => [s.id, s])) const existingMap = new Map(existing.map((s) => [s.id, s]))
const keptIds = new Set<string>() const keptIds = new Set<string>()

File diff suppressed because one or more lines are too long