fix: 老师代约只允许已发布课表,去掉临时加开时段
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
<view v-else-if="filteredSlots.length === 0" class="empty-slots">
|
<view v-else-if="filteredSlots.length === 0" class="empty-slots">
|
||||||
<text class="empty-title">这天还没有可安排的课表</text>
|
<text class="empty-title">这天还没有可安排的课表</text>
|
||||||
<text class="empty-sub">可以去排课管理发布,或在下方加开一个时段</text>
|
<text class="empty-sub">请先去排课管理发布时段</text>
|
||||||
<view class="ghost-link" @tap="goSchedule">
|
<view class="ghost-link" @tap="goSchedule">
|
||||||
<text class="ghost-link-text">前往排课管理</text>
|
<text class="ghost-link-text">前往排课管理</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
<view v-else class="slot-list">
|
<view v-else class="slot-list">
|
||||||
<view
|
<view
|
||||||
v-for="slot in filteredSlots"
|
v-for="slot in filteredSlots"
|
||||||
:key="slot.startTime + slot.endTime + (slot.id || 'draft')"
|
:key="slot.id"
|
||||||
class="slot-row"
|
class="slot-row"
|
||||||
:class="{ 'slot-row--disabled': !canPickSlot(slot) }"
|
:class="{ 'slot-row--disabled': !canPickSlot(slot) }"
|
||||||
@tap="onPickSlot(slot)"
|
@tap="onPickSlot(slot)"
|
||||||
@@ -73,25 +73,6 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="custom-block">
|
|
||||||
<text class="custom-title">加开此时段</text>
|
|
||||||
<view class="custom-row">
|
|
||||||
<picker mode="time" :value="customStart" @change="onCustomStartChange">
|
|
||||||
<view class="custom-picker">
|
|
||||||
<text class="custom-picker-text">{{ customStart }} – {{ customEnd }}</text>
|
|
||||||
<text class="custom-picker-arrow">选择开始时间</text>
|
|
||||||
</view>
|
|
||||||
</picker>
|
|
||||||
<view
|
|
||||||
class="custom-btn"
|
|
||||||
:class="{ 'custom-btn--disabled': !canArrange }"
|
|
||||||
@tap="onCustomArrange"
|
|
||||||
>
|
|
||||||
<text class="custom-btn-text">加开并安排</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-if="confirmVisible && pendingSlot" class="mask" @tap="confirmVisible = false">
|
<view v-if="confirmVisible && pendingSlot" class="mask" @tap="confirmVisible = false">
|
||||||
<view class="sheet" @tap.stop>
|
<view class="sheet" @tap.stop>
|
||||||
<text class="sheet-kicker">立即确认</text>
|
<text class="sheet-kicker">立即确认</text>
|
||||||
@@ -127,7 +108,7 @@ import type {
|
|||||||
AdminMemberDetail,
|
AdminMemberDetail,
|
||||||
ScheduleSlotPreview,
|
ScheduleSlotPreview,
|
||||||
} from '@mp-pilates/shared'
|
} from '@mp-pilates/shared'
|
||||||
import { MembershipStatus, TIME_PERIODS, TimeSlotStatus, TimeSlotSource } from '@mp-pilates/shared'
|
import { MembershipStatus, TIME_PERIODS, TimeSlotStatus } from '@mp-pilates/shared'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
import DateSelector from '../../components/DateSelector.vue'
|
import DateSelector from '../../components/DateSelector.vue'
|
||||||
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
||||||
@@ -147,7 +128,6 @@ const selectedDate = ref(formatDate(new Date()))
|
|||||||
const selectedPeriod = ref<PeriodKey>(null)
|
const selectedPeriod = ref<PeriodKey>(null)
|
||||||
const slots = ref<ScheduleSlotPreview[]>([])
|
const slots = ref<ScheduleSlotPreview[]>([])
|
||||||
const slotsLoading = ref(false)
|
const slotsLoading = ref(false)
|
||||||
const customStart = ref('10:00')
|
|
||||||
const confirmVisible = ref(false)
|
const confirmVisible = ref(false)
|
||||||
const pendingSlot = ref<ScheduleSlotPreview | null>(null)
|
const pendingSlot = ref<ScheduleSlotPreview | null>(null)
|
||||||
const arranging = ref(false)
|
const arranging = ref(false)
|
||||||
@@ -175,34 +155,27 @@ const deductHint = computed(() => {
|
|||||||
return '将立即确认该课并扣除 1 次,会员无需再确认。'
|
return '将立即确认该课并扣除 1 次,会员无需再确认。'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const publishedSlots = computed(() =>
|
||||||
|
slots.value.filter((slot): slot is ScheduleSlotPreview & { id: string } =>
|
||||||
|
Boolean(slot.isPublished && slot.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const filteredSlots = computed(() => {
|
const filteredSlots = computed(() => {
|
||||||
if (!selectedPeriod.value) return slots.value
|
if (!selectedPeriod.value) return publishedSlots.value
|
||||||
const period = TIME_PERIODS[selectedPeriod.value]
|
const period = TIME_PERIODS[selectedPeriod.value]
|
||||||
return slots.value.filter((slot) => slot.startTime >= period.start && slot.startTime < period.end)
|
return publishedSlots.value.filter((slot) => slot.startTime >= period.start && slot.startTime < period.end)
|
||||||
})
|
})
|
||||||
|
|
||||||
const customEnd = computed(() => addHour(customStart.value))
|
|
||||||
|
|
||||||
function addHour(time: string): string {
|
|
||||||
const [hours, minutes] = time.split(':').map(Number)
|
|
||||||
const total = hours * 60 + (minutes || 0) + 60
|
|
||||||
const nextHours = Math.floor(total / 60) % 24
|
|
||||||
const nextMinutes = total % 60
|
|
||||||
return `${String(nextHours).padStart(2, '0')}:${String(nextMinutes).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function canPickSlot(slot: ScheduleSlotPreview): boolean {
|
function canPickSlot(slot: ScheduleSlotPreview): boolean {
|
||||||
if (!canArrange.value) return false
|
if (!canArrange.value) return false
|
||||||
if (isSlotPast(slot.date, slot.startTime)) return false
|
if (isSlotPast(slot.date, slot.startTime)) return false
|
||||||
if (slot.status === TimeSlotStatus.CLOSED) return false
|
if (slot.status === TimeSlotStatus.CLOSED) return false
|
||||||
if (slot.isPublished && (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity)) {
|
if (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity) return false
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function slotLabel(slot: ScheduleSlotPreview): string {
|
function slotLabel(slot: ScheduleSlotPreview): string {
|
||||||
if (!slot.isPublished || !slot.id) return '未发布时段'
|
|
||||||
if (slot.status === TimeSlotStatus.CLOSED) return '已关闭'
|
if (slot.status === TimeSlotStatus.CLOSED) return '已关闭'
|
||||||
if (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity) return '已满员'
|
if (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity) return '已满员'
|
||||||
if (isSlotPast(slot.date, slot.startTime)) return '已过点'
|
if (isSlotPast(slot.date, slot.startTime)) return '已过点'
|
||||||
@@ -210,7 +183,6 @@ function slotLabel(slot: ScheduleSlotPreview): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function slotActionLabel(slot: ScheduleSlotPreview): string {
|
function slotActionLabel(slot: ScheduleSlotPreview): string {
|
||||||
if (!slot.isPublished || !slot.id) return '加开'
|
|
||||||
if (!canPickSlot(slot)) return '—'
|
if (!canPickSlot(slot)) return '—'
|
||||||
return '安排'
|
return '安排'
|
||||||
}
|
}
|
||||||
@@ -239,63 +211,26 @@ function onDateSelect(date: string) {
|
|||||||
loadSlots(date)
|
loadSlots(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCustomStartChange(e: { detail: { value: string } }) {
|
|
||||||
customStart.value = e.detail.value
|
|
||||||
}
|
|
||||||
|
|
||||||
function goSchedule() {
|
function goSchedule() {
|
||||||
uni.navigateTo({ url: '/pages/admin/schedule' })
|
uni.navigateTo({ url: '/pages/admin/schedule' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPickSlot(slot: ScheduleSlotPreview) {
|
function onPickSlot(slot: ScheduleSlotPreview) {
|
||||||
if (!canPickSlot(slot)) return
|
if (!canPickSlot(slot) || !slot.id) return
|
||||||
pendingSlot.value = slot
|
pendingSlot.value = slot
|
||||||
confirmVisible.value = true
|
confirmVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCustomArrange() {
|
|
||||||
if (!canArrange.value) {
|
|
||||||
uni.showToast({ title: '请先开通有效会员卡', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (customEnd.value <= customStart.value) {
|
|
||||||
uni.showToast({ title: '结束时间必须晚于开始时间', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (isSlotPast(selectedDate.value, customStart.value)) {
|
|
||||||
uni.showToast({ title: '不能安排已经过去的时间', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pendingSlot.value = {
|
|
||||||
id: null,
|
|
||||||
date: selectedDate.value,
|
|
||||||
startTime: customStart.value,
|
|
||||||
endTime: customEnd.value,
|
|
||||||
capacity: 1,
|
|
||||||
bookedCount: 0,
|
|
||||||
status: TimeSlotStatus.OPEN,
|
|
||||||
source: TimeSlotSource.MANUAL,
|
|
||||||
templateId: null,
|
|
||||||
isPublished: false,
|
|
||||||
}
|
|
||||||
confirmVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmArrange() {
|
async function confirmArrange() {
|
||||||
const slot = pendingSlot.value
|
const slot = pendingSlot.value
|
||||||
const membership = selectedMembership.value
|
const membership = selectedMembership.value
|
||||||
if (!slot || !membership || arranging.value) return
|
if (!slot?.id || !membership || arranging.value) return
|
||||||
arranging.value = true
|
arranging.value = true
|
||||||
try {
|
try {
|
||||||
let timeSlotId = slot.id
|
|
||||||
await adminStore.arrangeMemberBooking({
|
await adminStore.arrangeMemberBooking({
|
||||||
userId: userId.value,
|
userId: userId.value,
|
||||||
membershipId: membership.id,
|
membershipId: membership.id,
|
||||||
...(timeSlotId ? { timeSlotId } : {}),
|
timeSlotId: slot.id,
|
||||||
date: slot.date,
|
|
||||||
startTime: slot.startTime.slice(0, 5),
|
|
||||||
endTime: slot.endTime.slice(0, 5),
|
|
||||||
capacity: slot.capacity || 1,
|
|
||||||
})
|
})
|
||||||
confirmVisible.value = false
|
confirmVisible.value = false
|
||||||
uni.showToast({ title: '已安排并确认', icon: 'success' })
|
uni.showToast({ title: '已安排并确认', icon: 'success' })
|
||||||
@@ -539,61 +474,6 @@ onMounted(async () => {
|
|||||||
color: $accent-color;
|
color: $accent-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-block {
|
|
||||||
margin: 12rpx 24rpx 40rpx;
|
|
||||||
padding: 24rpx;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
border: 1rpx dashed rgba(180, 160, 130, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-title {
|
|
||||||
display: block;
|
|
||||||
font-size: 22rpx;
|
|
||||||
letter-spacing: 3rpx;
|
|
||||||
color: $text-hint;
|
|
||||||
margin-bottom: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-picker {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-picker-text {
|
|
||||||
font-size: 30rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: $text-primary;
|
|
||||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-picker-arrow {
|
|
||||||
font-size: 20rpx;
|
|
||||||
color: $text-hint;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-btn {
|
|
||||||
padding: 16rpx 22rpx;
|
|
||||||
border-radius: 14rpx;
|
|
||||||
background: $brand-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-btn--disabled { opacity: 0.4; }
|
|
||||||
|
|
||||||
.custom-btn-text {
|
|
||||||
font-size: 24rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #fff8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mask {
|
.mask {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -1318,100 +1318,5 @@ describe('BookingService', () => {
|
|||||||
)
|
)
|
||||||
expect(tx.booking.create).not.toHaveBeenCalled()
|
expect(tx.booking.create).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reuses an existing slot when arranging by date and time', async () => {
|
|
||||||
const tx = buildTxMock()
|
|
||||||
stubArrangeSuccess(tx)
|
|
||||||
|
|
||||||
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
|
|
||||||
userId: MOCK_USER_ID,
|
|
||||||
membershipId: MOCK_MEMBERSHIP_ID,
|
|
||||||
date: '2099-12-31',
|
|
||||||
startTime: '09:00',
|
|
||||||
endTime: '10:00',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(tx.timeSlot.findUnique).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: {
|
|
||||||
date_startTime_endTime: {
|
|
||||||
date: new Date('2099-12-31T00:00:00.000Z'),
|
|
||||||
startTime: '09:00',
|
|
||||||
endTime: '10:00',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
expect(tx.timeSlot.create).not.toHaveBeenCalled()
|
|
||||||
expect(tx.booking.create).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates a manual slot when arranging a missing date and time', async () => {
|
|
||||||
const tx = buildTxMock()
|
|
||||||
const createdSlot = { ...mockOpenSlot, id: 'slot-manual-001', source: 'MANUAL' }
|
|
||||||
tx.timeSlot.findUnique
|
|
||||||
.mockResolvedValueOnce(null)
|
|
||||||
.mockResolvedValueOnce({ ...createdSlot, bookedCount: 1 })
|
|
||||||
tx.timeSlot.create.mockResolvedValue(createdSlot)
|
|
||||||
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
|
|
||||||
tx.booking.findUnique.mockResolvedValue(null)
|
|
||||||
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
|
||||||
tx.timeSlot.updateMany.mockResolvedValue({ count: 1 })
|
|
||||||
tx.booking.create.mockResolvedValue({
|
|
||||||
...mockConfirmedBooking,
|
|
||||||
timeSlotId: createdSlot.id,
|
|
||||||
status: BookingStatus.CONFIRMED,
|
|
||||||
})
|
|
||||||
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 })
|
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
|
||||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
|
||||||
...mockConfirmedBooking,
|
|
||||||
timeSlotId: createdSlot.id,
|
|
||||||
status: BookingStatus.CONFIRMED,
|
|
||||||
timeSlot: createdSlot,
|
|
||||||
membership: mockActiveMembership,
|
|
||||||
})
|
|
||||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' })
|
|
||||||
studioService.getInfo.mockResolvedValue({
|
|
||||||
...mockStudioConfig,
|
|
||||||
name: 'FocusCore Pilates',
|
|
||||||
})
|
|
||||||
subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true)
|
|
||||||
|
|
||||||
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
|
|
||||||
userId: MOCK_USER_ID,
|
|
||||||
membershipId: MOCK_MEMBERSHIP_ID,
|
|
||||||
date: '2099-12-31',
|
|
||||||
startTime: '09:00',
|
|
||||||
endTime: '10:00',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(tx.timeSlot.create).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
data: expect.objectContaining({
|
|
||||||
startTime: '09:00',
|
|
||||||
endTime: '10:00',
|
|
||||||
source: 'MANUAL',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects custom slots whose end time is not after start time', async () => {
|
|
||||||
const tx = buildTxMock()
|
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.adminArrangeBooking(MOCK_ADMIN_ID, {
|
|
||||||
userId: MOCK_USER_ID,
|
|
||||||
membershipId: MOCK_MEMBERSHIP_ID,
|
|
||||||
date: '2099-12-31',
|
|
||||||
startTime: '23:00',
|
|
||||||
endTime: '00:00',
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(BadRequestException)
|
|
||||||
expect(tx.timeSlot.findUnique).not.toHaveBeenCalled()
|
|
||||||
expect(tx.timeSlot.create).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,13 +5,11 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
import { Booking, Membership, Prisma, TimeSlot, BookingStatusHistory } from '@prisma/client'
|
import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/client'
|
||||||
import {
|
import {
|
||||||
BookingStatus,
|
BookingStatus,
|
||||||
CardTypeCategory,
|
CardTypeCategory,
|
||||||
DEFAULT_SLOT_CAPACITY,
|
|
||||||
MembershipStatus,
|
MembershipStatus,
|
||||||
TimeSlotSource,
|
|
||||||
TimeSlotStatus,
|
TimeSlotStatus,
|
||||||
type TeachingScheduleSlot,
|
type TeachingScheduleSlot,
|
||||||
} from '@mp-pilates/shared'
|
} from '@mp-pilates/shared'
|
||||||
@@ -51,23 +49,6 @@ function buildSlotStartMs(slotDate: Date, startTime: string): number {
|
|||||||
return d.getTime()
|
return d.getTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeClock(time: string): string {
|
|
||||||
return time.slice(0, 5)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clockToMinutes(time: string): number {
|
|
||||||
const [hours, minutes] = normalizeClock(time).split(':').map(Number)
|
|
||||||
return hours * 60 + minutes
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSlotDate(date: string): Date {
|
|
||||||
const parsed = new Date(`${date}T00:00:00.000Z`)
|
|
||||||
if (Number.isNaN(parsed.getTime())) {
|
|
||||||
throw new BadRequestException('Invalid date')
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Service ───────────────────────────────────────────────────────────────
|
// ─── Service ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -298,7 +279,12 @@ export class BookingService {
|
|||||||
dto: AdminArrangeBookingDto,
|
dto: AdminArrangeBookingDto,
|
||||||
): Promise<BookingWithRelations> {
|
): Promise<BookingWithRelations> {
|
||||||
const booking = await this.prisma.$transaction(async (tx) => {
|
const booking = await this.prisma.$transaction(async (tx) => {
|
||||||
const timeSlot = await this.resolveArrangeSlot(tx, dto)
|
const timeSlot = await tx.timeSlot.findUnique({
|
||||||
|
where: { id: dto.timeSlotId },
|
||||||
|
})
|
||||||
|
if (!timeSlot) {
|
||||||
|
throw new NotFoundException(`TimeSlot ${dto.timeSlotId} not found`)
|
||||||
|
}
|
||||||
if (timeSlot.status !== TimeSlotStatus.OPEN) {
|
if (timeSlot.status !== TimeSlotStatus.OPEN) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`TimeSlot is not available (status: ${timeSlot.status})`,
|
`TimeSlot is not available (status: ${timeSlot.status})`,
|
||||||
@@ -433,55 +419,6 @@ export class BookingService {
|
|||||||
return arrangedBooking
|
return arrangedBooking
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveArrangeSlot(
|
|
||||||
tx: Prisma.TransactionClient,
|
|
||||||
dto: AdminArrangeBookingDto,
|
|
||||||
): Promise<TimeSlot> {
|
|
||||||
if (dto.timeSlotId) {
|
|
||||||
const slot = await tx.timeSlot.findUnique({
|
|
||||||
where: { id: dto.timeSlotId },
|
|
||||||
})
|
|
||||||
if (!slot) {
|
|
||||||
throw new NotFoundException(`TimeSlot ${dto.timeSlotId} not found`)
|
|
||||||
}
|
|
||||||
return slot
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dto.date || !dto.startTime || !dto.endTime) {
|
|
||||||
throw new BadRequestException('timeSlotId or date+startTime+endTime is required')
|
|
||||||
}
|
|
||||||
|
|
||||||
const startTime = normalizeClock(dto.startTime)
|
|
||||||
const endTime = normalizeClock(dto.endTime)
|
|
||||||
if (clockToMinutes(endTime) <= clockToMinutes(startTime)) {
|
|
||||||
throw new BadRequestException('End time must be after start time')
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = parseSlotDate(dto.date)
|
|
||||||
const existing = await tx.timeSlot.findUnique({
|
|
||||||
where: {
|
|
||||||
date_startTime_endTime: {
|
|
||||||
date,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (existing) {
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.timeSlot.create({
|
|
||||||
data: {
|
|
||||||
date,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
capacity: dto.capacity ?? DEFAULT_SLOT_CAPACITY,
|
|
||||||
source: TimeSlotSource.MANUAL,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Complete / NoShow Booking (Admin) ──────────────────────────────────
|
// ─── Complete / NoShow Booking (Admin) ──────────────────────────────────
|
||||||
|
|
||||||
async completeBooking(
|
async completeBooking(
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
import { Type } from 'class-transformer'
|
import { IsUUID } from 'class-validator'
|
||||||
import {
|
|
||||||
IsDateString,
|
|
||||||
IsInt,
|
|
||||||
IsOptional,
|
|
||||||
IsUUID,
|
|
||||||
Matches,
|
|
||||||
Min,
|
|
||||||
ValidateIf,
|
|
||||||
} from 'class-validator'
|
|
||||||
|
|
||||||
export class AdminArrangeBookingDto {
|
export class AdminArrangeBookingDto {
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
@@ -16,25 +7,6 @@ export class AdminArrangeBookingDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
membershipId!: string
|
membershipId!: string
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
timeSlotId?: string
|
timeSlotId!: string
|
||||||
|
|
||||||
@ValidateIf((dto: AdminArrangeBookingDto) => !dto.timeSlotId)
|
|
||||||
@IsDateString()
|
|
||||||
date?: string
|
|
||||||
|
|
||||||
@ValidateIf((dto: AdminArrangeBookingDto) => !dto.timeSlotId)
|
|
||||||
@Matches(/^\d{2}:\d{2}(:\d{2})?$/)
|
|
||||||
startTime?: string
|
|
||||||
|
|
||||||
@ValidateIf((dto: AdminArrangeBookingDto) => !dto.timeSlotId)
|
|
||||||
@Matches(/^\d{2}:\d{2}(:\d{2})?$/)
|
|
||||||
endTime?: string
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
capacity?: number
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,9 +73,5 @@ export interface CreateBookingDto {
|
|||||||
export interface AdminArrangeBookingDto {
|
export interface AdminArrangeBookingDto {
|
||||||
readonly userId: string
|
readonly userId: string
|
||||||
readonly membershipId: string
|
readonly membershipId: string
|
||||||
readonly timeSlotId?: string
|
readonly timeSlotId: string
|
||||||
readonly date?: string
|
|
||||||
readonly startTime?: string
|
|
||||||
readonly endTime?: string
|
|
||||||
readonly capacity?: number
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user